条款17:理解特殊成员函数的生成

在C++术语中,特殊成员函数是指 C++ ⾃⼰⽣成的函数。C++98 有四个:默认构造函数函数,析构函数,拷⻉构造函数,拷⻉赋值运算符。这些函数仅在需要的时候才⽣成,⽐如某个代码使⽤它们但是它们没有在类中声明。默认构造函数仅在类完全没有构造函数的时候才⽣成。⽣成的特殊成员函数是隐式public且inline,除⾮该类是继承⾃某个具有虚函数的类,否则⽣成的析构函数是⾮虚的。

但是时代改变了,C++⽣成特殊成员的规则也改变了。要留意这些新规则,因为⽤ C++ ⾼效编程⽅⾯很少有像它们⼀样重要的东西需要知道。

C++11 特殊成员函数俱乐部迎来了两位新会员:移动构造函数和移动赋值运算符。它们的签名是:

1
2
3
4
5
6
7
class Widget {
public:
...
Widget(Widget&& rhs);
Widget& operator=(Widget&& rhs);
...
};

掌控它们⽣成和⾏为的规则类似于拷⻉系列。移动操作仅在需要的时候⽣成,如果⽣成了,就会对⾮ static 数据执⾏逐成员的移动。那意味着移动构造函数根据 rhs 参数⾥⾯对应的成员移动构造出新部分,移动赋值运算符根据参数⾥⾯对应的⾮ static 成员移动赋值。移动构造函数也移动构造基类部分(如果有的话),移动赋值运算符也是移动赋值基类部分。

现在,当我对⼀个数据成员或者基类使⽤移动构造或者移动赋值时,没有任何保证移动⼀定会真的发⽣。逐成员移动,实际上,更像是逐成员移动请求,因为对不可移动类型使⽤移动操作实际上执⾏的是拷⻉操作。逐成员移动的核⼼是对对象使⽤ std::move,然后函数决议时会选择执⾏移动还是拷⻉操作。简单记住如果⽀持移动就会逐成员移动类成员和基类成员,如果不⽀持移动就执⾏拷⻉操作。

两个拷⻉操作是独⽴的:声明⼀个不会限制编译器声明另⼀个。所以如果你声明⼀个拷⻉构造函数,但是没有声明拷⻉赋值运算符,如果写的代码⽤到了拷⻉赋值,编译器会帮助你⽣成拷⻉赋值运算符重载。同样的,如果你声明拷⻉赋值运算符但是没有拷⻉构造,代码⽤到拷⻉构造编译器就会⽣成它。上述规则在 C++98 和 C++11 中都成⽴。

如果你声明了某个移动函数,编译器就不再⽣成另⼀个移动函数。这与复制函数的⽣成规则不太⼀样。这条规则的背后原因是,如果你声明了某个移动函数,就表明这个类型的移动操作不再是“逐⼀移动成员变量”的语义,即你不需要编译器默认⽣成的移动函数的语义,因此编译器也不会为你⽣成另⼀个移动函数。

再进⼀步,如果⼀个类显式声明了拷⻉操作,编译器就不会⽣成移动操作。这种限制的解释是如果声明拷⻉操作就暗⽰着默认逐成员拷⻉操作不适⽤于该类,编译器会明⽩如果默认拷⻉不适⽤于该类,移动操作也可能是不适⽤的。

这是另⼀个⽅向。声明移动操作使得编译器不会⽣成拷⻉操作。(编译器通过给这些函数加上 delete 来保证,参⻅Item11)。如果逐成员移动对该类来说不合适,也没有必要指望逐成员的拷贝操作是合适的。听起来会破坏 C++98 的某些代码,因为 C++11 中拷⻉操作可⽤的条件⽐ C++98 更受限,但事实并⾮如此。C++98 的代码没有移动操作,因为 C++98 中没有移动对象这种概念。只有⼀种⽅法能让⽼代码使用用户声明的移动操作,那就是使⽤C++11标准然后添加这些操作, 并在享受这些操作带来的好处同时接受C++11特殊成员函数⽣成规则的限制。

Rule of Three 规则:如果你声明了拷⻉构造函数,拷⻉赋值运算符,或者析构函数三者之⼀,你应该也声明其余两个。它来源于⻓期的观察,即⽤⼾接管拷⻉操作的需求⼏乎都是因为该类会做其他资源的管理,这也⼏乎意味着:(1)⽆论哪种资源管理如果能在⼀个拷⻉操作内完成,也应该在另⼀个拷⻉操作内完成(2)类析构函数也需要参与资源的管理(通常是释放)。通常意义的资源管理指的是内存(如STL容器会动态管理内存),这也是为什么标准库⾥⾯那些管理内存的类都声明了“the big three”:拷⻉构造,拷⻉赋值和析构。

Rule of Three 带来的后果就是只要出现⽤⼾定义的析构函数就意味着简单的逐成员拷⻉操作不适⽤于该类。接着,如果⼀个类声明了析构也意味着拷⻉操作可能不应该⾃定⽣成,因为它们做的事情可能是错误的。在C++98提出的时候,上述推理没有得倒⾜够的重视,所以C++98⽤⼾声明析构不会左右编译器⽣成拷⻉操作的意愿。C++11 中情况仍然如此,但仅仅是因为限制拷⻉操作⽣成的条件会破坏⽼代码。

Rule of Three 规则背后的解释依然有效,再加上对声明拷⻉操作阻⽌移动操作隐式⽣成的观察,使得C++11不会为那些有⽤⼾定义的析构函数的类⽣成移动操作。所以仅当下⾯条件成⽴时才会⽣成移动操作:

  • 类中没有拷⻉操作
  • 类中没有移动操作
  • 类中没有⽤⼾定义的析构

有时,类似的规则也会扩展⾄移动操作上⾯,因为现在类声明了拷⻉操作,C++11 不会为它们⾃动⽣成其他拷⻉操作。这意味着如果你的某个声明了析构或者拷⻉的类依赖⾃动⽣成的拷⻉操作,你应该考虑升级这些类,消除依赖。假设编译器⽣成的函数⾏为是正确的(即逐成员拷⻉类数据是你期望的⾏为),你的⼯作很简单,C++11的 = default 就可以表达你想做的:

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
...
~Widget();
...
Widget(const Widget&) = default;
Widget&
operator=(const Widget&) = default; // behavior is OK
...
};

这种⽅法通常在多态基类中很有⽤,即根据继承⾃哪个类来定义接口。多态基类通常有⼀个虚析构函数,因为如果它们⾮虚,⼀些操作(⽐如对⼀个基类指针或者引⽤使⽤ delete 或者 typeid)会产⽣未定义或错误结果。除⾮类继承⾃⼀个已经是 virtual 的析构函数,否则要想析构为虚函数的唯⼀⽅法就是加上 virtual 关键字。通常,默认实现是对的, = default 是⼀个不错的⽅式表达默认实现。然而⽤⼾声明的析构函数会抑制编译器⽣成移动操作,所以如果该类需要具有移动性,就为移动操作加上 = default。声明移动会抑制拷⻉⽣成,所以如果拷⻉性也需要⽀持,再为拷⻉操作加上 = default

1
2
3
4
5
6
7
8
9
class Base {
public:
virtual ~Base() = default;
Base(Base&&) = default;
Base& operator=(Base&&) = default;
Base(const Base&) = default;
Base& operator=(const Base&) = default;
...
};

实际上,就算编译器乐于为你的类⽣成拷⻉和移动操作,⽣成的函数也如你所愿,你也应该⼿动声明它们然后加上 = default。这看起来⽐较多余,但是它让你的意图更明确,也能帮助你避免⼀些微妙的 bug。⽐如,你有⼀个字符串哈希表,即键为整数 id,值为字符串,⽀持快速查找的数据结构:

1
2
3
4
5
6
7
class StringTable {
public:
StringTable() {}
...
private:
std::map<int, std::string> values;
};

假设这个类没有声明拷⻉操作,没有移动操作,也没有析构,如果它们被⽤到编译器会⾃动⽣成。没错,很⽅便。后来需要在对象构造和析构中打⽇志,增加这种功能很简单:

1
2
3
4
5
6
7
8
9
10
11
class StringTable {
public:
StringTable()
{ makeLogEntry("Creating StringTable object"); }
~StringTable()
{ makeLogEntry("Destroying StringTable object"); }
...
Item 17 | 113
private:
std::map<int, std::string> values; // as before
};

看起来合情合理,但是声明析构有潜在的副作⽤:它阻⽌了移动操作的⽣成。然而,拷⻉操作的⽣成是不受影响的。因此代码能通过编译,运⾏,也能通过功能(译注:即打⽇志的功能)测试。功能测试也包括移动功能,因为即使该类不⽀持移动操作,对该类的移动请求也能通过编译和运⾏。这个请求正如之前提到的,会转而由拷⻉操作完成。它意味着对 StringTable 对象的移动实际上是对对象的拷⻉,即拷⻉⾥⾯的 std::map<int, std::string> 对象。拷⻉ std::map<int, std::string> 对象很可能⽐移动慢⼏个数量级。简单的加个析构就引⼊了极⼤的性能问题!对拷⻉和移动操作显式加个 = default,问题将不再出现。

默认构造和析构:

  • 默认构造函数:和 C++98 规则相同。仅当类不存在⽤⼾声明的构造函数时才⾃动⽣成。
  • 析构函数:基本上和 C++98 相同;稍微不同的是现在的析构默认 noexcept(参⻅Item14)。和 C++98 ⼀样,仅当基类析构为虚函数时该类析构才为虚函数。
  • 拷⻉构造函数:和 C++98 运⾏时⾏为⼀样:逐成员拷⻉⾮ static 数据。仅当类没有⽤⼾定义的拷⻉构造时才⽣成。如果类声明了移动操作它就是 delete。当⽤⼾声明了拷⻉赋值或者析构,该函数不再⾃动⽣成。
  • 拷⻉赋值运算符:和 C++98 运⾏时⾏为⼀样:逐成员拷⻉赋值⾮ static 数据。仅当类没有⽤⼾定义的拷⻉赋值时才⽣成。如果类声明了移动操作它就是 delete。当⽤⼾声明了拷⻉构造或者析构,该函数不再⾃动⽣成。
  • 移动构造函数和移动赋值运算符:都对⾮ static 数据执⾏逐成员移动。仅当类没有⽤⼾定义的拷⻉操作,移动操作或析构时才⾃动⽣成。

注意没有成员函数模版阻⽌编译器⽣成特殊成员函数的规则。这意味着如果 Widget 是这样:

1
2
3
4
5
6
7
class Widget {
...
template<typename T>
Widget(const T& rhs);
template<typename T>
Widget& operator=(const T& rhs); ...
};

编译器仍会⽣成移动和拷⻉操作(假设正常⽣成它们的条件满⾜),即使可以模板实例化产出拷⻉构造和拷⻉赋值运算符的函数签名(当 TWidget 时)。Item16 将会详细讨论它可能带来的后果。

总结:

  • 特殊成员函数是编译器可能⾃动⽣成的函数:默认构造,析构,拷⻉操作,移动操作。
  • 移动操作仅当类没有显式声明移动操作,拷⻉操作,析构时才⾃动⽣成。
  • 拷⻉构造仅当类没有显式声明拷⻉构造时才⾃动⽣成,并且如果⽤⼾声明了移动操作,拷⻉构造就是 delete。拷⻉赋值运算符仅当类没有显式声明拷⻉赋值运算符时才⾃动⽣成,并且如果⽤⼾声明了移动操作,拷⻉赋值运算符就是 delete。当⽤⼾声明了析构函数,拷⻉操作不再⾃动⽣成。