《Effective C++》第二部分:构造、析构、赋值运算

条款5:了解 C++ 默默编写并调用哪些函数

提要

  1. 编译器可以暗自为 class 创建 default 构造函数、copy 构造函数、copy assignment 操作符、以及析构函数。

解释

如果你自己没声明,编译器会为你声明拷贝构造函数、拷贝赋值运算符、默认构造函数和一个析构函数,所有这些函数都是 public 且 inline 的。

首先对于默认构造函数和析构,编译器生成他们其实主要是为了自己使用。比如类含有虚函数时,自身的虚表要初始化,因此当程序员不写的时候,编译器需要自己弄出一个默认构造函数完成虚表的初始化。(其实在四种情况下编译器会声明默认构造函数,能不声明是编译器是不会干活的,比如当这些函数不会被调用的话,编译器就不会生成。但是这四种情况在这里不细说了,这个属于更高级的东西,我自己也没搞明白。)默认构造函数和析构函数还会调用自身的基类和非静态成员的构造和析构函数。

另外,编译器生成的析构函数是non-virtual的,除非基类中有 virtual 析构函数。【$$$\Rightarrow Item7$$$】

对于拷贝构造函数,编译器版本单纯的对每一个非静态成员拷贝到目标对象。对于有构造函数的成员,比如string类型或者自定义类型,会调用其拷贝构造函数,而对于内置基本类型,会按位拷贝。

对于拷贝赋值运算符,其行为与拷贝构造函数基本一样,但是要符合某些条件的时候,编译器才会生成。这里的条件指的是,如果类含有 reference 成员或者是 const 成员的话,编译器不会生成拷贝赋值运算符。原因在于,假设执行了obj1 = obj2,而对象内含有引用类型的话,编译器不知道该怎么处理引用类型的成员,C++ 不允许“让引用指向不同对象”,那不让obj1的引用转而去引用obj2中引用的对象,而是让obj1直接修改呢?也不合适,因为可能会影响其他该对象的引用或者指向该对象的指针,所以编译器索性不干这个麻烦事。另外,如果基类的拷贝赋值运算符是私有的,编译器也不会生成默认版本的拷贝赋值运算符。

条款6:若不想使用编译器自动生成的函数,就该明确拒绝

提要

  1. 为驳回编译器(暗中)自动提供的机能,可将相应的成员函数声明为 private 并且不予实现。使用 Uncopyable 这样的基类也是一种做法。

解释

有的时候我们定义一些类,并希望其对象不能被复制,那么最好的情况就是让编译器检测是否有对象的复制行为发生,如果存在的话报告给我们。如果仅仅是放任不管是不能做到这一点的,因为如果有人调用拷贝构造,编译器会“自觉地把它生成出来”。因此我们要做的是,手动以 private 方式声明拷贝构造函数(以及拷贝赋值运算符),这样的话没有默认版本,而手动声明的版本又不能使用,漂亮!但是,还有一种情况我们没有考虑。因为,私有并不代表不可以使用,友元函数和成员函数是不受此限制的,那怎么办!?我们可以用一个小技巧,就是只声明,不定义,那么如果在友元或者成员函数中使用拷贝的话,就会得到一个“连接错误”。这个小 trick 甚至被用到了 C++ 自己的源码中,你想的到是哪里吗?答案是:ios_basebasic_ios还有sentry

但是,我们希望尽可能早的发现问题,有没有可能把上面的连接错误也放到编译期完成?答案是肯定的,这里给出了一种解法,就是使用 Uncopyable 的基类。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Uncopyable {
protected:
Uncopyable();
~Uncopyable();
private:
Uncopyable(const Uncopyable&);
Uncopyable& operator=(const Uncopyable&);
};


class Someclass : private Uncopyable{
/*...*/
}

然后把它作为一个混入类就可以使其派生类完全不允许进行复制,并在编译期检测可能的错误,这会因为当编译器尝试对派生类手动生成拷贝构造函数的时候,会尝试使用基类的对应函数,然后被 private 标识符阻止。

对于Uncopyable,有着“empty base class optimization”资格【$$$\Rightarrow Item7$$$】。它不一定要使用公有继承【$$$\Rightarrow Item32;39$$$】。而且他的析构函数不一定要是虚的【$$$\Rightarrow Item7$$$】。这些如果看不懂没关系,我们只需要记住,这个玩意通常可以很好的、符合我们预期的工作就行了。最后,Boost库中有个类似的东西叫noncopyable,我们也可以使用它。【$$$\Rightarrow Item55$$$】

条款7:为多态基类声明 virtual 析构函数

提要

  1. polymorphic(多态的) base classes应该声明一个析构函数。如果一个类带有任何函数,他就应该拥有一个析构函数。
  2. 类的设计目的如果不是作为基类使用,或不是为了具备多态性,就不该声明析构函数。

解释

出于设计的角度考虑,我们定义基类来表现多态性是很常见的手段,具体使用时,经常用Base* p = new Derived();这种形式出现,即基类指针指向派生类对象。或者我们可能不是明确的 new 一个派生类对象,因为这样耦合性较强,我们可能使用一个工厂方法,比如Base* p = Factory(some_param);。【$$$\Rightarrow Item13;18$$$】

这时我们就要注意了,这个基类已经有了多态性,多态性在 C++ 不出意外就是通过虚函数表现的,因此Base类中会有虚函数。这时就要注意了,如果我们是Base类的实现者,要确保Base类中存在虚析构函数。因为,在 C++ 标准中明确指出,当派生类对象通过基类指针删除的时候,如果基类不存在虚析构函数,那么结果未定义。通常会发生的情况就是,基类部分的资源可以正常释放,而派生类的析构函数不会执行,派生类的资源也不会释放。因此我们记住一个原则:任何类只要带有虚函数,就几乎确定应同时具有虚析构函数。

考虑到,如果基类不含有虚函数,通常表示它不意图用做基类,此时其析构函数最好不要是虚的。因为虚函数会使基类带有一个虚指针vptr指向虚表vtbl,这使得一些小类的体积会大幅增加。这带来两个问题:一、可能本来小类可以放入寄存器,体积增大后就不行了;二、此时 C++ 中该对象的结构和 C 中的声明不具有相同结构(虚表)了,可移植性也就没了。

这个原则很简单,我们自己可能会不犯这个错误,但是使用别人的代码甚至标准库时,也要考虑到这些问题。比如,可能有些情况会继承 STL 中的 vector 得到自己的UserVector 类,此时如果我们用一个 vector 指针指向 UserVector,就会出现和上面一样的析构问题了。这里的分析不仅仅适用于 vector,所有不带虚析构函数的类都一样,包括所有的 STL 容器。这需要我们自己克制自己,因为 C++ 不像 Java 一样带有final关键字。

有时我们只想定义接口的时候,会用到抽象类,即包含纯虚函数的类。如果没有合适的用于纯虚函数的候选函数,可以让虚析构函数成为纯虚的。注意此时,必须在实现时为该基类的纯虚函数提供一份定义。这是因为,派生类在析构的时候是自底向上的,编译器会自动在派生类的析构函数中调用基类的析构函数,因此要为纯虚函数加上一个定义,否则会链接失败。

记住!带虚函数的类才要加上虚析构函数,因为本质上我们考虑的是多态性,这种类的目的就是“用基类的指针处理派生类”。如果不是用于多态的目的的基类就不要考虑本原则了,比如标准string和 STL 容器。还有一些混入类被设计用于基类,但不是用于多态,比如上一个条款中提到的Uncopyable,这种类也不需要虚析构函数。

条款8:别让异常逃离析构函数

提要

  1. 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下他们或者结束程序。
  2. 如果客户需要对某个操作函数运行期间抛出的异常作反应,那么类应该提供一个普通函数(而非在析构函数中)执行该操作。

解释

C++ 本身不禁止析构函数抛出异常,但是却不鼓励这么做。考虑这种情况,一个自定义类的析构函数假设可能抛出异常,那么如果我们有一个 vector 中存放了十个该类实例化出来的对象,那么在程序退出的时候或者退出作用域的时候,可能这些对象在析构时会一起抛出异常,程序此时会提前结束或者出现未定义行为。不只是 vector 其他标准库中的容器或者 TR1 中的容器甚至数组都有类似的问题。因此,不要在析构函数中抛出异常。【$$$\Rightarrow Item54$$$】

那怎么办呢,假设我们在自己的类中维护了一个数据库连接dbconn作为自身的成员,这种涉及到资源的操作,总会有一些可能抛出异常,比如close操作。我们考虑到使用者可能忘记关闭,因此我们尝试在自定义类的析构函数中为用户dbconn的关闭。这其实就出现了上面的问题。解决的方式(其实也不算解决,只是一种处理思路),就是吧可能抛出异常的操作单独拿出来给用户,也就是说在自己的类中再写一个close函数,该函数负责对dbconn进行关闭,然后析构函数检查是否正确关闭,如果没有关闭再尝试关闭。哎!这不又在析构函数中处理了吗?没错,重点是析构函数不能抛出异常,但是析构函数自身是可以处理异常的,析构函数在执行dbconn.close时如果发生异常,就对其进行处理或者直接终止程序,只要不抛出就行。

这么做的想法就是,我们把close的责任交给用户,用户如果不做,那么析构时我们就不管这么多了。

说到这里,考虑一下 python 怎么处理类似的资源管理?我在之前做数据库相关的项目的时候就是对管理数据库连接的类实现了上下文管理协议,没有考虑在__exit__方法中出现异常怎么办……又看了一下__exit__自身会处理with语句块中发生的异常,但是在__exit__中发生的异常仍然需要自己处理。

条款9:决不在构造和析构过程中调用虚函数

提要

  1. 在构造和析构期间不要调用虚函数,因为基类构造和析构期间,C++从不下降到派生类中。

解释

当基类的目标是实现多态性时,自身一定具有虚函数,并且自身的派生类中含有该虚函数的不同版本。那么可能如果在基类的构造函数中执行这个虚函数(举例来说,这个虚函数是一个日志函数),然后执行Base* p = new Derive1();会怎么样呢?我们是实际构造的是派生类对象,在派生类中我们会先执行基类的构造函数,也就是说,当基类开始构造的时候,派生类特有的部分还没有初始化,假设此时虚函数动态的去执行派生类版本,那么就有可能使用到这些未经初始化的内容,这就违反了条款4。因此 C++ 索性在此时就放弃虚函数的能力,只执行基类自身的版本。说的正式一点就是:基类构造期间,虚函数不会下降到派生层。或者我们可以这么理解:基类构造的时候,虚函数不是虚的。

除了派生成员未经初始化,更根本的原因在于,基类构造期间,对象的类型就是基类类型!这是因为此时派生类还没有构造,因此 C++ 就当做派生类不存在,此时及时使用动态类型检查typeid,也会得到对象类型是基类类型的结果。析构函数与此顺序相反,但是原因是一致的。

如果基类的虚函数是纯虚的还好,因为执行时就会发生链接错误,但是如果是普通虚函数,就可能使程序正常运行并导致难以调试的错误。另外之前我们提到过,在多个构造函数中可以用一个自定义的init函数完成大部分重复工作,因此想init这种在构造期间执行的函数也要遵守本原则的约定。

但是如果我们真的想在基类构造期间执行一点与派生类类型相关的动作怎么办!我们此时被限制了“在基类构造期间,C++不会下降到虚函数”,那么我们只好手动将信息从派生类送上基类所在的层。也就是让基类构造函数接受一些额外的参数,在派生类调用基类构造函数时,把参数送进去。这个参数可以使派生类通过函数生成的,那么这个信息生成函数必须是static的,因为此时派生类自身的成员还没有初始化,依旧不可以使用。

条款10:令 operator= 返回一个 reference to *this

提要

  1. 令赋值操作符返回一个自身的引用。

解释

赋值通常可以写为a = b = c的连锁形式,由于赋值运算符符合右结合律,因此前式被解释为a = (b = c)。因此为了让我们的赋值运算符也支持这种形式,赋值运算符要返回*this,因此后面假设还有赋值操作,他们接受的参数类型通常是const T&。除了单纯的operator=,还有operator+=等非标准赋值操作符也要考虑到。

这是一个 C++ 设计的协议,但不是标准。虽然没有强制性,我们依旧应该遵守他们。因为这份协议被所有的内置类型和标准程序库提供的类型如stringvectorcomplextr1::shared_ptr或即将提供的类型共同遵守。【$$$\Rightarrow Item54$$$】

条款11:在 operator= 中处理“自我赋值”

提要

  1. 确保当前对象“自我赋值”时,operator= 有良好行为。其中技术包括比较来源对象和目标对象的地址、精心周到的语句顺序以及“copy-and-swap”。
  2. 确定任何函数如果操作一个以上的对象,而其中多个对象其实是同一个对象时,其行为仍然正确。

解释

通常我们不会手动执行“自己等于自己”的这种奇葩操作的,但是,由于引用和指针的存在,我们可能无意间执行这种操作。

如果遵守资源管理相关条款,我们会通过对象来管理资源,而且在可以确定“资源管理对象”在copy时有正确的举措时,那么我们的赋值操作符对于自赋值应该没问题。但是如果不通过对象来管理,而是自己管理的话,我们就要注意了。

1
2
3
4
5
6
7
8
9
10
11
12
class Widget {
...
private:
vector<int>* p;
};

Widget&
Widget::operator=(const Widget& rhs) {
delete p;
p = new vector(*rhs.p)
return *this;
}

我也不需要解释,大家都能看明白这样的赋值操作符在自赋值发生时有什么问题:经过自赋值后,该对象的指针指向一个被删除的对象。为了解决该问题,方法大家也都可以想到,就是加上一个额外的测试if(this = &rhs) return *this;即可。

此时我们处理了“自赋值安全性”,但是没有处理“异常安全性”,异常安全性就是指当代码发生问题的时候,代码可以对异常进行很好的处理。这里会发生异常的地方就是new操作了(比如内存不足)。如果new操作异常,那么我们又把持有的指针指向的内存删除了,结果就是指针又指向了一块被删除的内存。解决方法就是,在获得新的指针之前不要删除旧的。通常我们集中精力解决了“异常安全性”,就会发现此时的代码已经可以带有“自赋值安全性”了。【$$$\Rightarrow Item29$$$】

1
2
3
4
5
6
7
Widget&
Widget::operator=(const Widget& rhs) {
vector<int>* old = p;
p = new vector(*rhs.p)
delete old;
return *this;
}

可以看到,这段代码没有使用单独的测试语句依旧可以解决自赋值问题。我们可以考虑一下“自赋值”发生的可能性有多少,如果经常发生的话,我们依旧可以加上“自赋值测试”,因为此时的代码在自赋值时的无用操作很多,比如无用的拷贝构造与内存释放。但是如果发生的频率很小,那就不要加上单独的测试了,因为开头的“自赋值测试”,不但会使代码稍微大上一些,还会引入一个新的控制流,这会影响prefetching、caching 和 pipeline 的效率。

这里对于自赋值的处理已经结束了,但是再提一句一个替代方案:“copy and swap”。该技术和“异常安全性”联系紧密。

1
2
3
4
5
6
Widget&
Widget::operator=(const Widget& rhs) {
Widget temp(rhs); // 如果该operator=的参数不是引用,而是直接传值,那么这句话都省了,因为传值会自动创建局部副本
swap(this, temp); // 假装有这种交换函数
return *this;
}

条款12:复制对象时勿忘其每一个成分

提要

  1. copy函数应该确保复制“对象内的所有成员变量”以及“所有的基类成分”。
  2. 不要尝试以某个 copy 函数实现另一个 copy 函数。应该讲共同机能放到第三个函数中,并由两个 copy 函数共同调用。

解释

有时我们觉得这些原则好简单啊,会觉得“我写拷贝构造函数时当然会每一个成员都进行拷贝了”。但是有时问题发生的场景是我们不经意的一些地方。举个例子,我们写好了一个类,然后我们突然接到需求,然后我们要加一个数据成员,注意了,即使之前的类都做得很棒,但是此时可能会有很多的条款被违背了。我们需要一一检查,如构造函数,拷贝构造函数、所有版本的赋值操作符(如+=)。假如忘了,编译器通常不会对这种问题进行警告。【$$$\Rightarrow Item53;4;45;10$$$】

单纯的忘记修改拷贝构造函数会导致局部拷贝问题。然而该问题还有更隐蔽的版本:即被修改的类中继承了别的类。

1
2
3
4
5
6
class B: public A {
B(const B& rhs);
B& operator=(const B& rhs);
private:
int a;
}

对于一个这样的类,对于其拷贝构造函数,一定要手动调用其基类的构造函数(记住派生类的构造要手动处理基类部分的构造),否则编译器会自动调用基类的默认构造函数,这样对于基类部分就没有进行拷贝。对于operator=也是同理,要显式的调用基类的operator=,否则基类部分的成员会保持不变。对operator=的实现给出参考。

1
2
3
4
5
6
B&
B::operator=(const B& rhs) {
A::operator(rhs); //对基类部分进行赋值
a = rhs.a; //对派生类部分进行处理
return *this;
}

顺便提一下很简单的一点,就是派生类是访问不了基类的私有成员的,只有通过基类自己的函数才可以。因此,派生类要做的就是别忘了调用基类的函数,基类部分的正确性由基类自己保证。

在写拷贝构造函数和拷贝赋值运算符的时候,可能会发现大部分的代码会有重复,那我们又会闻到“坏味道”了。但是此处不能用条款三中使用到的“代理技巧”来应用到这里。因为拷贝构造函数的目的是“构建一个新的对象”,而拷贝赋值运算符的目的是修改一个已有的变量,二者的应用场景不同,因此不可以混用。解决方法和条款四中用到的小技巧类似:将二者共有部分移至一个新的函数中,并由二者共同调用。

本站总访问量