每周技巧 #142:多参数构造函数和 `explicit`

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #142: Multi-parameter Constructors and explicit

原文最初作为 TotW #142 发布于 2018 年 1 月 29 日。

作者:James Dennett

更新于 2020 年 4 月 6 日。

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

“显式优于隐式。” —— PEP 20

TL;DR:

大多数构造函数都应该是 explicit

引言

C++11 之前,explicit 关键字只对可以用单个实参调用的构造函数有意义,而我们的风格指南要求这类构造函数使用它,以免它们充当“转换构造函数”。这个要求并不适用于多参数构造函数。事实上,风格指南过去还不鼓励在多参数构造函数上使用 explicit,因为它没有意义。现在情况不再如此。

在 C++11 中,explicit 对从花括号列表进行的拷贝初始化有了意义,例如调用函数 void f(std::pair<int, int>) 时传入 f({1, 2}),或者用 std::vector<char> bad = {"hello", "world"}; 初始化变量 bad

等一下!最后一个例子里类型并不匹配。这不可能编译,对吧?std::vector<std::string> good = {"hello", "world"}; 是合理的,但 std::vector<char> 不能保存两个 std::string。然而它确实能编译(至少当前所有 C++ 编译器都能)。这是怎么回事?稍后再回到这个问题,先多谈谈 explicit

改变类型但不改变值的构造函数

未标记为 explicit 的构造函数可以由编译器调用,在不提及类型名的情况下创建一个值。当我们已经有了想要的值,只是类型不太匹配时,这很好用:也许我们有 const char[],需要 std::string;或者有两个 std::string,想要 std::vector<std::string>;或者有一个 int,想要 BigNum。简言之,如果转换前后的值本质上相同,它就很适合。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 某个基下笛卡尔平面中的点坐标。
class Coordinate2D {
 public:
  Coordinate2D(double x, double y);
  // ...
};

// 计算给定点 `p` 的欧几里得范数。
double EuclideanNorm(Coordinate2D p);

// 使用非 explicit 构造函数:
double norm = EuclideanNorm({3.0, 4.0});  // 传递函数实参
Coordinate2D origin = {0.0, 0.0};         // 使用 `=` 初始化
Coordinate2D Translate(Coordinate2D p, Vector2D v) {
  return {p.x() + v.x(), p.y() + v.y()};  // 从函数返回值
}

通过把构造函数 Coordinate2D(double, double) 声明为非 explicit,我们允许把 {3.0, 4.0} 传给接受 Coordinate2D 参数的函数。鉴于 {3.0, 4.0} 对这类对象来说是完全合理的值,这种便利不会造成困惑。

做得更多的构造函数

如果构造函数输出的值和输入不同,或者它可能有前置条件,那么隐式调用这个构造函数就不是好主意。

考虑一个带有构造函数 Request(Server*, Connection*)Request 类。请求对象的值并不“是”服务器和连接;只是我们可以创建一个使用它们的请求。可能有许多语义上不同的类型都可以从 {server, connection} 构造出来,例如 Response。这样的构造函数应该是 explicit,这样我们就不能把 {server, connection} 传给接受 Request(或 Response)参数的函数。在这类情况下,把构造函数标记为 explicit,要求实例化时说出目标类型,从而让代码对读者更清楚,也有助于避免意外转换导致的 bug。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 一条直线由两个不同的点定义。
class Line {
 public:
  // 构造穿过给定点的直线。
  // REQUIRES: p1 != p2
  explicit Line(Coordinate2D p1, Coordinate2D p2);

  // 判断这条直线是否包含给定点 `p`。
  bool ContainsPoint(Coordinate2D p) const;
};

Line line({0, 0}, {42, 1729});

// 计算 `line` 的斜率。如果 `line` 垂直,则返回无穷值。
double Gradient(const Line& line);

通过把构造函数 Line(Coordinate2D, Coordinate2D) 声明为 explicit,我们阻止代码把不相关的点传给 Gradient,除非先有意把它们变成 Line 对象。Line 的“值”并不是两个点,而且并非任意两个点都能定义一条 Line

另一个例子是,std::unique_ptr 中从裸指针转移所有权的构造函数使用 explicit,可以防止这里的重复 delete bug:

1
2
3
4
5
std::vector<std::unique_ptr<int>> v;
int* p = new int(-1);
v.push_back(p);  // error: cannot convert int* to std::unique_ptr<int>
// ...
v.push_back(p);

要传递 std::unique_ptr,程序员必须显式创建一个,这会给读者留下所有权正在转移的视觉线索。explicit 构造函数有助于记录这个约束:裸指针必须拥有其目标。

建议

  • 拷贝构造函数和移动构造函数永远不应该是 explicit
  • 除非构造函数实参“就是”新创建对象的值,否则应让构造函数成为 explicit。(注意:Google 风格指南目前要求所有单参数构造函数都是 explicit。)
  • 特别是,当类型的身份(地址)与其值相关时,该类型的构造函数应该是 explicit
  • 对值施加额外约束(也就是有前置条件)的构造函数应该是 explicit。有时把它们实现为工厂函数更好(见 技巧 #42:优先使用工厂函数,而不是初始化方法)。

结束语

本技巧可以看作 技巧 #88:初始化:=、() 和 {} 的另一面,后者建议我们在从“预期字面值”初始化时使用拷贝初始化语法(带 =)。本技巧的建议是:只有当技巧 #88 建议使用拷贝初始化时,才省略 explicit(否则这种初始化会被 explicit 关键字禁止)。

最后提醒一句:C++ 标准库并不总是做对。回到我们的例子(错误地声明了一个 std::vector<char>,而不是字符串容器):

1
std::vector<char> bad = {"hello", "world"};

我们会发现 std::vector 有一个模板化的“范围”构造函数,接收一对迭代器;它在这里匹配,并把参数类型推导为 const char*。如果应用本技巧中的建议,这个构造函数会是 explicit,因为 std::vector<char> 的值不是两个迭代器(而是一串字符)。现实中,explicit 被省略了,于是这段示例代码产生未定义行为,因为第二个迭代器无法从第一个迭代器到达。

每周技巧 #141:小心到 `bool` 的隐式转换

上一节

每周技巧 #143:C++11 删除函数(`= delete`)

下一节