每周技巧 #131:特殊成员函数和 `= default`

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #131: Special Member Functions and = default

原文最初作为 TotW #131 发布于 2017 年 3 月 24 日。

作者:James Dennett

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

自 C++ 诞生以来,它就支持由编译器声明某些所谓特殊成员函数的版本:默认构造函数、析构函数、拷贝构造函数和拷贝赋值运算符。C++11 把移动构造和移动赋值加入了这个列表,并添加了语法(=default=delete),用来控制这些默认函数何时声明和定义。

=default 做什么,为什么要用它?

写下 =default,就是告诉编译器:“对这个特殊成员函数,做你通常会做的事情。”为什么我们会想这样做,而不是手写一个实现,或者让编译器替我们声明一个?

  • 我们可以改变访问级别(例如把构造函数设为 protected 而不是 public)、让析构函数成为虚函数,或者恢复某个本来会被抑制的函数(例如一个已经有其他用户声明构造函数的类的默认构造函数),同时仍然让编译器替我们生成函数。
  • 如果复制/移动成员本身就足够,编译器定义的拷贝和移动操作不需要在每次添加或移除成员时维护。
  • 编译器提供的特殊成员函数可以是平凡的(当它们调用的所有操作自身也是平凡的),这能让它们更快、更安全。
  • 带有默认化构造函数的类型可以是聚合,因而支持聚合初始化;而带有用户提供构造函数的类型不能。
  • 显式声明一个默认化成员,给了我们一个记录所得函数语义的位置。
  • 在类模板中,=default 是一种基于某个底层类型是否提供相应操作来有条件声明操作的简单方式。

当我们在特殊成员函数的初始声明上使用 =default 时,编译器会检查能否为该函数合成一个内联定义。如果可以,它就这么做。如果不可以,这个函数实际上会被声明为已删除,就像我们写了 =delete 一样。对于透明包装一个类来说(比如定义类模板时),这正是我们需要的;但它可能让读者惊讶。

如果一个函数的初始声明使用了 =default,或者编译器声明了一个不是用户声明的特殊成员函数,那么会推导出合适的 noexcept 规格,这可能允许生成更快的代码。

它如何工作?

C++11 之前,如果我们需要默认构造函数,而又已经有其他构造函数,可能会这样写:

1
2
3
4
class A {
 public:
  A() {}  // 用户提供的非平凡构造函数使 A 不是聚合。
};

从 C++11 开始,我们有更多选项。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class C {
 public:
  C() = default;  // 误导性写法:C 的默认构造函数已删除
 private:
  const int i;  // const => 必须总是初始化。
};

class D {
 public:
  D() = default;  // 不意外,但不显式:D 有默认构造函数
 private:
  std::unique_ptr<int> p;  // std::unique_ptr 有默认构造函数
};

显然,我们不应该写出 class C 这样的代码:在非模板中,只有当你确实打算让类支持该操作时才使用 =default(然后测试它确实支持)。clang-tidy 包含针对这一点的检查。

=default 用在特殊成员函数的第一次声明之后(也就是类外)时,它的含义更简单:它告诉编译器定义这个函数,并且如果无法定义就报错。当 =default 在类外使用时,这个默认化函数不会是平凡的:平凡性由第一次声明决定(这样所有客户端都会对该操作是否平凡达成一致)。

如果你不需要类成为聚合,也不需要构造函数是平凡的,那么像下面的 EF 示例一样,在类定义之外默认化构造函数通常是个好选择。它的含义对读者清楚,也会由编译器检查。对于默认化默认构造函数或析构函数这种特殊情况,我们可以写 {} 而不是 =default;但对其他默认化操作,编译器生成的实现没那么简单,并且为了保持一致,在所有适用情况下写 =default 是好习惯。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class E {
 public:
  E();  // 承诺有默认构造函数,但是……
 private:
  const int i;  // const => 必须总是初始化。
};
inline E::E() = default;  // 这里编译错误:不会初始化 `i`

class F {
 public:
  F();  // 承诺有默认构造函数
 private:
  std::unique_ptr<int> p;  // std::unique_ptr 有默认构造函数
};
inline F::F() = default;  // 如预期工作

建议

优先使用 =default,而不是手写等价实现,即使那个实现只是 {}。也可以选择在初始声明中省略 =default,并提供一个单独的默认化实现。

默认化移动操作时要小心。被移动后的对象仍必须满足其类型的不变量,而默认实现通常不会保留字段之间的关系。

在模板之外,如果 =default 无法提供实现,就改写 =delete

每周技巧 #130:命名空间命名

上一节

每周技巧 #132:避免冗余的 map 查找

下一节