构造函数、析构函数、赋值运算符,可以说是一个对象最重要的三个成员函数,它们分别掌管成员变量的出生、死亡以及变化。
Item5: Know what functions C++ silently writes and calls
虽然我们只是创建一个空类,没有写任何的函数,但是,编译器还是会默认声明一些特殊的成员函数。具体来说,包括:
| 函数 | 说明 |
|---|---|
| 默认构造函数 | Empty() = default; |
| 析构函数 | ~Empty() = default; |
| 拷贝构造函数 | Empty(const Empty&) = default; |
| 拷贝赋值运算符 | Empty& operator=(const Empty&) = default; |
| 移动构造函数 | Empty(Empty&&) = default; |
| 移动赋值运算符 | Empty& operator=(Empty&&) = default; |
【补充知识】五法则:
除了默认构造函数之外,剩下的五个特殊的成员函数,如果我们手动定义了其中一个,剩下的四个就都不会被编译器自动生成。这是因为,一旦我们需要手动定义这五个函数中的一个,就说明存在需要手动释放的资源(比如
new出的内存、打开的文件、拿到的锁等等)。既然有资源需要手动释放,那么默认的拷贝(浅拷贝)一定会出问题——两个对象会持有同一份资源,析构时双重释放。这个规则被称为“五法则”,其逻辑链是: 需要自定义某个函数 → 说明持有资源 → 拷贝/移动不能信任默认实现 → 五个都要自定义
回到正题。比如说,我们定义:
| |
但实际上,编译器生成的等价于:
| |
这些函数都是 public 并且 inline 的,且只有它们在被实际调用的时候,编译器才会真正生成它们的定义(不是在声明时就生成)。
此外,它们只做浅拷贝(也就是逐成员复制),对于含指针/资源的类通常不够用。
这些默认生成的函数做的事情,大概可以这么理解:
- 构造函数:先调用基类的构造函数,逐成员调用对应的构造函数。
- 赋值运算符:先调用基类的赋值运算符,逐成员调用对应的赋值运算符。
- 析构函数:调用所有成员的析构函数,然后再调用基类的析构函数。
【补充知识】派生类 Derived 对象的内存布局大致是:
┌─────────────────────┐
│ Base 部分 │ ← 基类子对象
│ - Base 的成员变量 │
├─────────────────────┤
│ Derived 部分 │
│ - x │
└─────────────────────┘有些情况,编译器默认生成无法处理,比如说,下面这种情况,成员里面带有 reference 和 const 类型:
| |
如果遇到下面这种情况:
| |
这时候,编译器帮我们自动生成的拷贝赋值运算符会做什么?它会自动逐元素去拷贝。但是,我们知道,两个引用对象之间的赋值存在歧义(改引用本身是不可能的,但改引用指向的对象,就会意外影响所有引用同一个 string 的其他对象),而且 const 类型的常量值之间的赋值也不被允许。所以,上面这段代码最终就会编译错误。
Things to Remember:
Compilers may implicitly generate a class’s default constructor, copy constructor, copy assignment operator and destructor.
Item6: Explicitly disaalow the use of compiler-generated functions you do not want
简单来说,在一些场景下,compiler 自动生成的一些函数(比如前面提到的那些)并不是我们需要的,甚至是不被允许的。
- 在 C++11 之前,将需要禁用的函数声明为
private且不实现。(或者更简单地,定义一个基类,利用这个private声明+不实现的方法禁用掉我们不想要的函数,然后再让其他类去继承这个基类) - 在 C++11 之后,我们通常用
= delete关键字来禁用这些函数。
由于 Effective C++出版的时候,C++11 还没有发布,所以这里只介绍了第一种做法。
Things to Remember
To disallow functionality automatically provided by compilers, declare the corresponding member functions private and give no implementations. Using a base class like Uncopyable is one way to do this.
Item7: Declare destructors virtual in polymorphic base classes
这一条也是老生常谈了,即:在实现多态的基类中,一定要将析构函数声明为虚函数。
我们来具体看看是怎么一回事。
这里提到了一个概念:Factory Function (工厂函数)。
- 这个函数的作用就是创建一个新生成的派生类,然后返回一个基类指针。
然后,理解一个概念:delete 和析构函数的先后关系。
- 简单来说,因为 heap 上的对象由我们手动管理,所以需要手动调用
delete来释放资源,而delete被调用之后会自动调用析构函数,释放内存。- 对于 stack 上的对象,在自己的生命周期结束时,会自动调用析构函数,而不需要用
delete关键字来触发。
析构函数和释放内存之间的关系:
析构函数是我们写的一段代码,用于释放资源,而内存只是资源中的一种。
delete帮我们调用析构函数,在析构函数中,我们可以做任何的清理工作(释放内存、关闭文件、释放锁),这是在释放类内部管理的资源;做完这些清理工作之后,再去释放类对象本身的内存。
回到本小节的问题:通过基类指针去 delete 派生类的对象,同时该基类的析构函数不是虚函数,这样的行为在 C++ 中是未定义的。
运行时最常见的情况是,派生类对象中,独属于派生类的那部分完全没有被销毁。(“partially destroyed” object)
解决这个问题的方法很简单:将基类的析构函数定义为虚函数即可。
那么,定义虚函数的代价是什么?
如果我们不打算将一个类作为基类,那么最好不要在类中声明任何函数为虚函数。这是因为,声明虚函数会带来额外的时间和空间开销,主要就是 vptr 和 vtbl,可以看下面这段话:
The implementation of virtual functions requires that objects carry information that can be used at runtime to determine which virtual functions should be invoked on the object. This information typically takes the form of a pointer called a vptr (“virtual table pointer”). The vptr points to an array of function pointers called a vtbl (“virtual table”); each class with virtual functions has an associated vtbl. When a virtual function is invoked on an object, the actual function called is determined by following the object’s vptr to a vtbl and then looking up the appropriate function pointer in the vtbl.
- 空间开销:该类的每个对象需要额外存储一个 vptr。
- 时间开销:调用虚函数时,需要先根据 vptr 跳转到 vtbl,再从 vtbl 跳转到对应的函数实现。
如果给一个非基类的类增加虚函数,那么 vptr 导致的内存增加结合内存对齐的要求,可能使得类对象的大小膨胀,同时和其他语言(主要就是 C 语言)的兼容性变差(因为 C 里面的相同结构体没有 vptr,内存布局不同,不能直接传递)。
所以,不要盲目定义虚函数,只有在确定需要使用多态时,再去定义。
同样地,不要使用一些没有虚函数定义的类作为基类,那也是危险的行为。比如说,std::string 这个类的析构函数不是虚函数,但却有下面这个类继承了 std::string:
| |
所有 STL container 都没有虚析构函数,所以不要让其他类去继承这些类!
注:本书写作时,C++还没有引入 final 关键字,但 C++11 之后,使用 final 关键字就可以阻止继承,或者阻止某个特定的函数被 override 了,具体如下:
| |
| |
我们还可以定义纯虚函数 (pure virtual function)。拥有纯虚函数的类被称为抽象类 (abstract class),它们无法被初始化(即:无法创建一个属于该类的对象)。
Things to Remember:
- Polymorphic base classes should declare virtual destructors. If a calss has any virtual functions, it should have a virtual destructor.
- Classes not designed to be base classes or not designed to be used polymorphically should not declare virtual destructors.
虚析构函数只在你需要通过基类指针销毁派生类对象时才有意义。纯作为工具基类、不涉及多态时,虚析构函数不仅不必要,还会白费一个 vptr的开销。
Item8: Prevent exceptions from leaving destructors
异常抛出后,程序不一定立即结束,而是开始栈展开 (stack unwinding),即:沿着调用栈逐层退出,每退出一层就开始调用该局部对象的析构函数。
在 C++03 那个年代,析构函数抛出异常可能导致栈展开时同时存在多个异常,这是未定义行为,非常危险。
而在 C++11 之后,析构函数隐式 noexcept(true),即承诺自己不会抛出异常。
对于声明了 noexcept 的函数,如果它抛出异常或者它内部抛出异常,并且没有被 catch,那么程序不会把异常传播出去,而是会直接调用 std::terminate 结束进程。
所以,“析构函数不要抛出异常”这条守则在现在其实更加正确了,它被编程语言的机制硬性执行。
那么,如果析构函数确实需要执行一个可能抛出异常的操作(可能失败的事情),该怎么做?
通常有两种方式:
- Swallow the exception(吞掉异常)
- Terminate the program(终止程序)
Swallow the exception:
| |
含义:不吭声地处理掉异常,不让它跑出去。这时候,看着一片祥和,但是调用者根本不知道 close 失败了,这可能导致未释放的资源悄悄积累,最后酿成大祸。
Terminate the program:
| |
含义:这个错误无法忽略,但又不能往外抛异常,那么就直接终止程序。适合那种,“资源泄漏比程序挂了更糟糕”的场景。
其实现在最好的做法是:让用户自己调用可能抛出异常的函数,然后用析构函数做兜底。
- 用户自己调用这个函数,抛出的异常可以正常传递给用户。
- 如果用户忘记调用了,那么在析构函数中调用对应函数,再吞掉异常并记录日志。
比如下面的设计:
| |
“If an operation may fail by throwing an exception and there may be a need to handle that exception, the exception has to come from some non-destructor function.” (Dulimov, p. 47)
这样的做法其实是把选择权交给了用户自己。如果用户自己选择不去调用可能抛出异常的函数,那么他也不应该抱怨析构函数把异常给吞了。
Things to Remember:
- Destructors should never emit exceptions. If functions called in a destructor may throw, the destructor should catch any exceptions, then swallow them or terminate the program.
- If class clients need to be able to react to exceptions thrown during an operation, the class should provide a regular (i.e., non-destructor) function that performs the operation.
Item9: Never call virtual functions during constructions or destruction
在构造函数或者析构函数里面调用虚函数,虚函数不会“虚”——调用的不是派生类版本,而是当前类自己的版本。
注意:在构造过程中,对象的实际动态类型是在变化的。当基类部分被创建完且派生类部分尚未初始化时,对象的动态类型就是基类。只有当派生类的部分也构造完成,对象的动态类型才变成派生类。
书里也有类似的描述:
It’s actually more fundamental than that. During base class construction of a derived class object, the type of the object is that of the base class.
这是很合理的,因为属于派生类的部分尚未构造完成,编译器显然不能将这个类型作为派生类来使用,否则可能访问到未初始化的成员变量。
至于变量的类型信息凭什么能动态变化,那是因为对象的 vptr 的值是运行时写入的。这个 vptr 指向哪张虚表,就决定了虚函数走哪个版本。
可以看下面的例子:
| |
为什么呢?因为构造顺序是基类先于派生类。Base 构造时,Derived 部分还没有开始初始化。如果这时候虚函数调用到了 Derived::log(),它可能访问 Derived 未初始化的成员变量——这是灾难。
所以 C++ 规定:构造/析构期间,虚函数按静态类型绑定,不按动态类型。
析构也是同理,析构顺序是派生类先析构,基类后析构,所以当 Base::~Base() 时,派生类的部分已经销毁了,再调派生类的虚函数没有意义。
实际容易踩坑的地方:构造函数中没有直接调用虚函数,但是调用了一个普通函数,这个普通函数调用了虚函数,那么最终的调用链还是碰到了虚函数。
| |
解决构造函数调用虚函数的方式,就是将原本需要使用的函数定义为 Non-virtual,并由派生类向上传递一些信息,用参数化来代替多态:
| |
这里有个小细节:createLogString 这个用于传递信息的成员函数被声明为 static 类型。
- 我们知道,静态成员函数是没有
this指针的,所以它无法访问任何非静态成员变量。 - 这样,就从根本上杜绝了“访问派生类未初始化的成员变量”的问题。
Things to Remember:
- Don’t call virtual functions during construction or destruction, because such calls will never go to a more derived class than that of the currently executing constructor or destructor.
Item10: Have assignment operators return a reference to *this
所有赋值类运算符(=、+=、-=、*=、/= 等)都应该返回 *this 的引用。
为什么?这是为了支持链式赋值。
先理解一下赋值运算符 = 本身在做什么。我们知道,赋值运算符本身对应一个函数 operator=,比如,定义下面的赋值运算符:
| |
那么 a = b 本质上就是下面这个函数调用的简化写法。
| |
如果我们将赋值运算符的返回值定义为 *this 的引用,那么上面的 a = b 或者说 a.operator=(b) 返回的值就是 a 自己的引用。
这样的话,如果有一个新的变量 c,进行如下链式赋值:
| |
赋值运算符是右结合的,所以上面的式子又可以等价于:
| |
而 a = b 返回的是 a 的引用,所以就相当于将 a 的当前值赋给了 c。而 a 的值在 a = b 的作用下,就是 b 的值。这样,我们就完成了链式赋值。
而如果返回值为 void,显然无法做到这一点;如果返回值不是引用类型,那么会多一次拷贝构造的开销,也不符合内置类型的约定(如内置类型 int 的赋值返回是左值引用,我们的自定义类应该和这些内置类型的运算保持一致)。
这是一个约定俗成的协议,内置类型和标准库(string、vector 等)都遵守。你的类也该遵守,保持一致性。
Things to Remember:
- Have assignment operators return a reference to
*this.
Item11: Handle assignment to self in operator=
这个标题里的"assignment to self"表示自我赋值。整个标题的意思是:在 operator= 这个函数中处理自我赋值的问题。
先来看一段错误的代码:
| |
在 Widget 类的 operator= 函数中,我们想先释放自己的资源,再将新资源分配过来。看上去很合理,先释放原有资源,防止造成资源泄漏。
但是,如果发生自我赋值 w = w:
delete pb— 把自己的pb删了new Bitmap(*rhs.pb)—rhs就是自己,rhs.pb已经被删了,访问已释放内存,未定义行为!
你可能会说:我怎么会搞自我赋值这么蠢的事情?事实上,自我赋值有时候并不那么明显,看看下面两个例子:
| |
看吧,不同名称的变量很可能指向同一个对象,这时候就容易发生自我赋值。
那么,怎么解决呢?一种经典的策略是 copy-and-swap。
| |
这样写天然安全:
- 如果是自我赋值,先拷贝了一份,交换后原来的数据在 temp 里,析构时正常释放
- 同时也保证了异常安全(如果创建临时对象 temp 时抛出异常,
*this没有被修改,不会导致对象处于半损坏状态)
书里还提到了一种方法,也是更简单的方法,即在赋值函数的开头加一个“是否等于自己”的检查,但我们不推荐这种方法,因为它没有保证异常安全:
| |
还有一种方法,也是异常安全且解决了自我赋值问题的,但不如 copy-and-swap 优雅:
| |
其实思路都是一样的,本质都是先把新的状态准备在临时对象上,确认安全之后再替换旧状态。区别只是怎么替换:
- 手动管理中间变量。
- Copy-and-swap,swap 之后让析构函数处理。
Things to Remember:
- Make sure
operator=is well-behaved when an object is assigned to itself. Techniques include comparing addresses of source and target objects, careful statement ordering, and copy-and-swap.- Make sure that any function operating on more than one object behaves correctly if two or more of the objects are the same.
Item12: Copy all parts of an object
在 Item5 中,我们提到了“五法则”:当我们手动定义了析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符中的任何一个,就说明我们存在需要手动管理的资源,编译器就不会帮我们生成这些函数的默认形式。
这还不算完。编译器不但不会帮我们生成,而且在我们写错东西的时候,还不会提醒我们哪里写错了。作者说:这是编译器对你自己定义函数的“复仇”(看到这句话我真是笑飞了)
“That’s their revenge for your writing the copying functions yourself.” (Dulimov, p. 58)
所以,我们需要手动维护上面四个特殊的成员函数。但是由于本书写作年代原因(C++03),当时还没有移动语义的相关内容,所以只讨论了两个 copying functions,不过,对于两个 move functions,道理是一样的。
具体来说,当你给类添加了新成员变量,如果忘了同步更新拷贝构造函数和拷贝赋值运算符,编译器不会提醒你:
| |
遵守两条规则即可:
- 如果你写了拷贝构造函数,编译器不再自动生成 — 所以新加的成员不会被自动拷贝
- 拷贝构造函数和拷贝赋值运算符要同步更新 — 添加成员时要两个都改,容易漏
更隐蔽的情况是“继承”中漏掉基类的成员:
| |
派生类的拷贝函数必须显式调用基类的拷贝函数,否则基类部分会调用默认构造函数初始化,而不是拷贝。
顺便提一嘴,虽然拷贝赋值和拷贝构造里面的很多函数逻辑都类似,但它们不能相互调用,因为它们在底层逻辑上就是冲突的:
- 拷贝构造是生成一个新的对象。
- 拷贝赋值是修改已有的对象。
那么怎么处理重复逻辑呢?可以定义一个额外的函数 init 来处理,但是这样就又和前一个 item 中处理自我赋值问题那一部分冲突了(无法保证异常安全)……总之就是怎么做都不对。
所以 modern c++ 中才会引入智能指针等遵循 RAII 原则的封装类,来简洁地解决这些问题。
Things to Remember:
- Copying functions should be sure to copy all of an object’s data members and all of its base class parts.
- Don’t try to implement one of the copying functions in terms of the other. Instead, put common functionality in a third function that both call.
注:其实 modern c++ 中基本上使用 Rule of Zero——尽量用智能指针和标准库容器来管理资源,一个特殊成员函数都不写,全部让编译器自动生成,这样就不用操心漏掉任何一个。但本书比较老了,会提到过去的一些 C++ 写法。