取自《Effective C++:改善程序与设计的55个具体做法》中文版第三版
二、构造/析构/赋值运算
05:了解C++默默编写并调用哪些函数
对于创建一个空类,当C++处理过后,如果没有声明,则编译器会为它声明一个 copy 构造函数、一个 copy assignment 操作符和一个析构函数,此外还有 default 构造函数。因此,对于 class Empty { };
,等同于如下代码:
1 | class Empty { |
如果类中声明了一个构造函数,则编译器不再为它创建 default 构造函数。
编译器产生的 copy assignment 操作符,一般而言只有当生出的代码合法且有适当机会证明其意义,才会去创建它,否则会拒绝创建。见下实例:
1 | template<class T> |
对于 p = s;
的赋值操作,C++的响应是拒绝编译,其一是C++并不允许 “让 reference 改指向不同对象”;再者就是更改 const 成员是不合法的,编译器不知道如何处理;因此,这就需要自己去定义 copy assignment 操作符了。此外,如果一个基类的 copy assignment 操作符声明为 private,编译器就会拒绝生成子类的 copy assignment 操作符,原因则是默认生成的函数无权调用二点权限问题。
对于 “C++并不允许 ‘让 reference 改指向不同对象’ ” 的解释,我所理解的就是,假设程序可以运行的情况下, ‘p = s’ 希望得到的是 p.nameValue 去引用 oldDog,而因为这一条机制的原因,实际得到的为:p.nameValue 引用的对象并没有变更,变更的是 newDog = “Satch”,尽管输出看起来一致,但实现机制不一样。
编译器可以暗自为 class 创建 default 构造函数、copy 构造函数、copy assignment 操作符,以及析构函数。
06:若不想使用编译器自动生成的函数,就该明确拒绝
对于一个 class,如果不希望其支持某一个功能,只要不声明对应函数即可,但对于系统会自动生成的 copy 构造函数、copy assignment 操作符,则可以采用将其声明为 private 的方式去阻止外部调用,而对于内部调用的情况,可以通过不去定义函数来避免调用。
1 | class HomeForSale { |
对于上述定义,如果尝试拷贝 HomeForSale 对象,则编译器就会提示错误信息。 因为这个机制,可以单独设计一个 base class,然后去继承即可,如下示例:
1 | class Uncopyable { |
为驳回编译器自动提供的机能,可将相应的成员函数声明为 private 并且不予实现。使用像 Uncopyable 这样的 base class 也是一种做法。
07:为多态基类声明 virtual 析构函数
1 | class TimeKeeper { |
对于上诉计时代码,若设计工厂函数,返回指针指向一个计时对象,如下:
1 | TimeKeeper* ptk = getTimeKeeper(); //从继承体系获取一个动态分配对象 |
然而,getTimeKeeper() 返回的是一个派生类对象(如 AtomicClock),而对象经由基类释放,因C++规则:当派生类对象经由基类指针删除,而基类带有非虚析构函数,其结果未有定义–实际执行导致该对象的派生类成分可能未被销毁,即派生类的析构函数未被执行,从而导致资源泄露等问题。解决方式:给基类一个虚析构函数:
1 | class TimeKeeper { |
对于不被用于继承的类,则不需要设计虚析构函数。虚函数在运行期间决定被调用是由一个 vptr 指针指出,因此如果一个类含有虚函数,其对象的占用空间也相应的会增加大小,可能会带来不必要的麻烦。
由于很多基类的设计目的并不是用于多态用途,如标准 string 和 STL容器等,不具有虚析构函数,因此应尽量避免用于继承。
如果在一个需要设计抽象类的场合却没有纯虚函数的情况下,可以通过声明纯虚析构函数实现,相应的也需要为这个纯虚析构函数添加定义,如下示例:
1 | class AWOV { |
polymorphic(带多态性质的)基类应该声明一个虚析构函数;如果类带有任何虚函数,也应该声明一个虚析构函数。
如果一个类的设计不用于基类使用,或者不具备多态性质,则不该声明虚析构函数。
08:别让异常逃离析构函数
C++ 并不禁止析构函数吐出异常,但也不鼓励这样做。比如,对于一个容器 vector 含有多个 Wigdets 对象元素,析构第一个元素期间,有个异常被抛出,此时其他元素仍会被销毁,若第二个析构又抛出异常,则会存在两个异常,对于C++而言,程序若不是结束执行就是导致不明确行为。
如果析构函数必须执行一个动作,而这个动作可能会在失败时抛出异常,怎么办呢?通常有两种方法:
- 如果抛出异常就结束程序,通常通过 abort 完成
1 | DBConn::~DBConn() |
- 吞下因调用 close 而发生的异常
1 | DBConn::~DBConn() |
一个更好的策略是提供一个 close 函数,将调用 close 的责任从 DBConn 析构函数转移到客户手上。
1 | class DBConn { |
析构函数绝对不要吐出异常。如果一个析构函数调用的函数可能吐出异常,应捕捉异常后吞下或者结束程序。
如果客户需要对某个操作函数运行期间抛出的异常作出反应,那么类应提供一个普通函数执行该操作。
09:绝不在构造和析构过程中调用 virtual 函数
1 | class Transaction { |
对于以上示例,如果此时调用 BuyTransaction b;
,首先 Transaction 构造函数会被最先调用,而此时 BuyTransaction 的构造函数还未被调用,这时候调用的 logTransaction 函数是 Transaction 内部的版本,而 Transaction 内部版本为纯虚函数,因此程序无法正常执行。唯一避免该问题的做法是:确定构造函数和析构函数都没有调用 virtual 函数。
如何确保每一次 Transaction 继承体系的对象被创建时都会调用 logTransaction 函数呢,一种做法是将该函数改为非虚函数。示例如下:
1 | class Transaction { |
此种方式将 “无法使用virtual函数从基类向下调用” 替换为 “令子类将必要的构造信息向上传递至基类”。
在构造和析构期间不要调用虚函数,因为这类调用不会下降至子类。
10:令 operator= 返回一个 reference to *this
1 | int x, y, z; |
为了实现类的连锁复制,赋值操作符必须返回一个 reference 指向操作符的左侧实参。
1 | class Widget { |
令赋值操作符返回一个 reference to *this。
11:在 operator= 中处理 “自我赋值”
1 | w = w; //自我赋值 |
如果会运用对象来管理资源,且确定“资源管理对象”在复制时正确,则“自我赋值为安全的”;但是如果自行管理资源,可能会出现“复制前释放资源”的问题,举例如下:
1 | class Bitmap { ... }; |
这里存在的安全性问题为若 *this 和 rhs 指的是同一个对象,如在出现一个指针指向一个被销毁的对象的情况。传统的一个解决做法为在最前面添加一个“证同测试”检验:
1 | if (this == &rhs) return *this; //证同测试 |
但是该方法仍存在一个隐患:假设 new Bitmap 异常,那么始终会有一个指针指向一个被删除的对象。那么从“异常安全性”的方面考虑,可得到方案如下–复制后删除:
1 | Widget& Widget::operator=(const Widget& rhs) |
该方案即使 new Bitmap 异常,pb 也会保持原样。另外一种方案是 copy and swap 技术。
1 | class Widget { |
确保当对象自我赋值时 operator= 有良好行为。
确定任何函数如果操作一个以上的对象,其中多个对象指向同一个对象时,其行为仍正确。
12:复制对象时勿忘其每一个成分
如果设计类没有使用系统自动生成的拷贝函数,而是设计自己的拷贝函数,那么出错时编译器有可能不会提示。如果拷贝函数中忘记写全成员变量,那么其会保持不变。
拷贝函数应该确保复制“对象类的所有成员变量”及“所有基类成分”。
不要尝试某个拷贝函数实现另一个拷贝函数,应该将共同代码放入第三个函数中再调用。