每周技巧 #173:用选项结构体包装参数

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #173: Wrapping Arguments in Option Structs

原文最初作为 TotW #173 发布于 2019 年 12 月 19 日。

作者:John Bandela

更新于 2020 年 4 月 6 日。

快捷链接:abseil.io/tips/173

它不是装在包裹、盒子或袋子里来的。他想啊想,想得脑袋都疼了。

- Dr. Seuss

指定初始化器

指定初始化器是 C++20 特性,如今大多数编译器都已支持。指定初始化器让选项结构体更容易、更安全地使用,因为我们可以在函数调用处直接构造 options 对象。这会让代码更短,也避免选项结构体带来的许多临时对象生命周期问题。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
struct PrintDoubleOptions {
  absl::string_view prefix = "";
  int precision = 8;
  char thousands_separator = ',';
  char decimal_separator = '.';
  bool scientific = false;
};

void PrintDouble(double value,
                 const PrintDoubleOptions& options = PrintDoubleOptions{});

std::string name = "my_value";
PrintDouble(5.0, {.prefix = absl::StrCat(name, "="), .scientific = true});

如果想了解选项结构体为什么有用,以及指定初始化器能帮我们避开哪些潜在陷阱,请继续往下读。

传递大量参数的问题

接受很多参数的函数可能令人困惑。为了说明这一点,考虑下面这个打印浮点值的函数:

1
2
3
void PrintDouble(double value, absl::string_view prefix,  int precision,
                 char thousands_separator, char decimal_separator,
                 bool scientific);

因为它接受许多选项,这个函数给了我们很大的灵活性。

1
PrintDouble(5.0, "my_value=", 2, ',', '.', false);

上面的代码会打印出 "my_value=5.00"

然而,阅读这段代码时很难知道每个实参对应哪个形参。比如下面这里,我们不小心把 precisionthousands_separator 的顺序弄混了:

1
PrintDouble(5.0, "my_value=", ',', '.', 2, false);

过去,我们使用实参注释来澄清调用点处实参的含义,减少这种歧义。为上面的例子添加实参注释后,ClangTidy 就能检测出错误:

1
2
3
4
5
PrintDouble(5.0, "my_value=",
            /*precision=*/2,
            /*thousands_separator=*/',',
            /*decimal_separator=*/'.',
            /*scientific=*/false);

不过,实参注释仍有几个缺点:

  • 没有强制性:ClangTidy 警告不是在构建时必然捕获的。细微错误(例如少了一个 =)可能完全关闭检查且没有任何警告,给人一种错误的安全感。
  • 可用性:并非所有项目和平台都支持 ClangTidy。

无论实参是否带注释,指定大量选项也会很烦琐。很多时候这些选项都有合理默认值。为了解决这个问题,我们可以给参数添加默认值。

1
2
3
void PrintDouble(double value, absl::string_view prefix = "", int precision = 8,
                 char thousands_separator = ',', char decimal_separator = '.',
                 bool scientific = false);

现在调用 PrintDouble 时可以少写一些样板代码:

1
PrintDouble(5.0, "my_value=");

但是,如果我们想为 scientific 指定一个非默认值,仍然不得不为它之前的所有参数指定值:

1
2
3
4
5
PrintDouble(5.0, "my_value=",
            /*precision=*/8,              // 与默认值相同
            /*thousands_separator=*/',',  // 与默认值相同
            /*decimal_separator=*/'.',    // 与默认值相同
            /*scientific=*/true);

我们可以把所有选项放进一个选项结构体,从而解决这些问题:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct PrintDoubleOptions {
  absl::string_view prefix = "";
  int precision = 8;
  char thousands_separator = ',';
  char decimal_separator = '.';
  bool scientific = false;
};

void PrintDouble(double value,
                 const PrintDoubleOptions& options = PrintDoubleOptions{});

现在我们既有了值的名称,也可以灵活使用默认值。

1
2
3
PrintDoubleOptions options;
options.prefix = "my_value=";
PrintDouble(5.0, options);

注意事项

这个方案也有一些问题。首先,传递 options 时多了一点样板代码,虽然与收益相比这通常只是小成本。但还有几个点需要考虑。

临时对象生命周期

例如,当所有选项都是参数时,下面的代码是安全的:

1
2
std::string name = "my_value";
PrintDouble(5.0, absl::StrCat(name, "="));

上面的代码创建了一个临时 string,并把 string_view 绑定到它。临时对象的生命周期会持续到函数调用结束,因此是安全的。但如果用同样方式设置选项结构体,就会得到悬垂的 string_view

1
2
3
4
std::string name = "my_value";
PrintDoubleOptions options;
options.prefix = absl::StrCat(name, "=");
PrintDouble(5.0, options);

有两种方式可以修复。第一种是简单地把 prefix 的类型从 string_view 改为 string。缺点是选项结构体现在会比直接传参低效。另一种方式是添加 setter 成员函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class PrintDoubleOptions {
 public:
  PrintDoubleOptions& set_prefix(absl::string_view prefix) {
    prefix_ = prefix;
    return *this;
  }

  absl::string_view prefix() const { return prefix_; }

  // 其他成员变量的 setter 和 getter。

 private:
  absl::string_view prefix_ = "";
  int precision_ = 8;
  char thousands_separator_ = ',';
  char decimal_separator_ = '.';
  bool scientific_ = false;
};

这样就可以在调用中设置变量:

1
2
std::string name = "my_value";
PrintDouble(5.0, PrintDoubleOptions{}.set_prefix(absl::StrCat(name, "=")));

可以看到,代价是我们的选项结构体变成了更复杂的类,并带来了更多样板代码。

更简单的替代方案是使用开头展示的指定初始化器

类型推导

指定初始化器经常与复制列表初始化一起使用;此时花括号包围的初始化器列表附近并没有显式写出类型。

当直接把 options 传给一个形参类型就是选项结构体的函数时,花括号列表会立刻转换成该具体类型的实参。因此,这样可以正常工作:

1
PrintDouble(5.0, {.scientific=true})

但在 std::make_unique 这类使用“完美转发”的函数中,参数类型必须被推导出来,而花括号列表的类型无法被找到。因此,下面这样不行:

1
2
3
4
5
6
class DoublePrinter {
  explicit DoublePrinter(const PrintDoubleOptions& options);
  ...
};

auto printer1 = std::make_unique<DoublePrinter>({.scientific=true});

调用者必须写出选项结构体的类型,或者提供一个会写出该类型的辅助工厂函数。

1
2
3
4
5
6
7
8
class DoublePrinter {
  static std::unique_ptr<DoublePrinter> Make(const PrintDoubleOptions& options);
  explicit DoublePrinter(const PrintDoubleOptions& options);
};

auto printer1 = std::make_unique<DoublePrinter>(
    PrintDoubleOptions{.scientific=true});
auto printer2 = DoublePrinter::Make({.scientific=true});

嵌套类型中的默认值

如果选项结构体与某个类相关,把它嵌套在类内部通常是合理的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class DoublePrinter {
  struct Options {
    int precision = 8;
    ...
  };

  explicit DoublePrinter(const Options& options);

  static std::unique_ptr<DoublePrinter> Make(const Options& options);
};

但如果你需要允许完全省略选项结构体,例如把它添加到一个已有类上,并且这个嵌套结构体有默认成员初始化器(例如字段名 precision 后面的 = 8),你就不能使用一个让字段保持隐式默认的默认实参

在这种情况下,请提供另一个重载,而不是使用默认实参。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class DoublePrinter {
  struct Options {
    int precision = 8;
    ...
  };

  static std::unique_ptr<DoublePrinter> Make() { return Make({}); }
  static std::unique_ptr<DoublePrinter> Make(const Options& options);

  explicit DoublePrinter() : DoublePrinter({}) {}
  explicit DoublePrinter(const Options& options);

  // 不能这样做:
  //   static std::unique_ptr<DoublePrinter> Make(const Options& options = {});
  //   explicit DoublePrinter(const Options& options = {});
};

结论

  1. 对于接受多个参数的函数,如果调用者可能混淆这些参数,或者你希望指定默认实参而不用担心顺序,强烈考虑使用选项结构体来同时提升便利性和代码清晰度。
  2. 调用接受选项结构体的函数时,指定初始化器可以让代码更短,也可能避免临时对象生命周期问题。
  3. 指定初始化器的简洁和清晰,进一步让天平倾向于选择接受选项结构体的函数,而不是拥有大量参数的函数。

每周技巧 #172:指定初始化器

上一节

每周技巧 #175:C++14 和 C++17 中字面常量的变化

下一节