条款七:为多态基类声明virtual析构函数
严格来说,多态分为编译时多态和运行时多态,编译时多态是函数的重载,而运行时多态则是迟绑定技术,即根据基类指针或引用的实际指向来决定采取何种行动,一般来说,多态特指运行时多态。下面我们来举有关C++多态的一个简单例子
class Shape
{
private:
int color;
public:
virtual void print() = 0;
};
class Circle: public Shape
{
private:
double radius;
public:
void print(){cout << "This is a Circle" << endl;}
};
class Square: public Shape
{
private:
double length;
public:
void print(){cout << "This is a Square" << endl;}
};
多态前提是继承,如上面这个例子,Shape是基类,它有一个所有形状都具有的属性:颜色。Circle和Square派生自Shape,它们都有颜色的属性,但同时Circle还有半径的属性,Square有边长的属性。这里print()函数是一个有多态行为的函数,比如:
Shape *p1 = new Square;
p1->print(); // 这时的运行结果就是“This is a Square”
Shape *p2 = new Circle;
p2->print(); // 这时的运行结果就是“This is a Circle”
但这里会有一个问题,如果接下来操作:
delete p1;
delete p2;
就会出现内存的泄露,为什么会这样?因为Shape没有显式定义析构函数,编译器为我们生成的析构函数不是虚的,这时候delete p1,编译器就简单地析构了基类的部分,派生类的部分就被架空了,造成了内存的泄露。解决方法是基类声明virtual析构函数:
class Shape
{
private:
int color;
public:
virtual void print() = 0;
virtual ~Shape(){}
};
有了virtual关键字,析构时就会检查指针实际指向的对象,也就会先定位到相应的派生类,完成派生类的析构之后,再析构基类。那么是怎么“检查”指针实际指向的对象呢?C++为每个含有virtual关键字的类中提供了一个虚表指针vptr,它指向一个由函数指针构成的数组,称为虚表。程序在运行时会维护类对象中的vptr,比如:
Shape *p1 = new Square;
vptr就会指向Square的print()函数。
那是不是为了安全起见,将所有的析构函数都加上virtual呢?这样的确可以防止多态过程中析构造成的内存泄露,但同时也有问题,即一旦出现了virtual,编译器就会生成vptr,这个vptr本身是要占空间的,对于一个并不大的类而言,vptr所占空间(32位机上是4字节,64位机上是8字节)的百分比还是很可观的。所以virtual要相时而加,出现多态继承关系时一定要加上,而非基类或者不用于多态的基类(比如条款6说的Uncopyable)就不要加。另外,所有的STL,包括string,vector等等,在设计时就没把它们当作基类,所以它们的析构函数都不是虚函数,因此试图定义一个类去继承STL是非常危险的!
再看
class Shape
{
private:
int color;
public:
virtual void print() = 0;
virtual ~Shape(){}
};
Shape obj; 就不能通过编译。使不想生成对象的类成为抽象类,这是一个很好的技术实现方案,但万一有时候难以找到一个纯虚函数,怎么办呢?自己强行加一个像print()一样的函数固然是可以的,但不自然,一个好的方法是把它的析构函数变成纯虚函数,像这样:
class Shape
{
private:
int color;
public:
virtual ~Shape() = 0;
};
问题算是解决一半了,还有一个问题,编译器会在Shape派生类的析构函数中创建一个对~Shape()的调用动作,如果不实现这个~Shape(),链接器会报错,一个解决方案就是简单地加上括号,像这样:
class Shape
{
private:
int color;
public:
virtual ~Shape() = 0{};
};
好了,这下Shape既成了抽象类(不能拥有对象),也能在继承链中妥善地处理析构的内存问题。
总结一下:
(1) 带有多态性质的基类应该声明一个virtual析构函数。如果class带有任何virtual函数,它就应该拥有一个virtual析构函数;
(2) 若一个类不作为基类,或者不具备多态性(比如Uncopyable),就不该声明virtual析构函数。
条款八:别让异常逃离析构函数
class Widget
{
public:
~Widget(){…} // 析构函数
};
void DoSomething()
{
vector<Widget> v;
}
STL的vector在析构时会逐一调用容器内每个元素的析构函数,这样问题就来了,万一在Widget的析构函数里出现了异常,又没有及时地在析构函数中处理这个异常,导致异常被抛出,因为vector中存在多个Widget元素,这样在析构时就会多次抛出未处理的异常,多个异常同时存在是非常危险的,它会导致不明确的行为。
最好的解决方案就是在析构函数里面把异常给解决掉,像这样:
~Widget()
{
try
{…}
catch
{
记录出错的位置
终止程序
}
}
记录错误总是要做的,但关于要不要出现异常就终止程序,在书上也作了一番探讨,主要在于这个程序是否可以终止,还是必须要坚持运作下去,这取决于实际的需要。
书上还举了一个例子,就是对析构函数做的事情模块化成一个函数,将这个函数设置成public,交由用户自行调用,若用户忘了做这件事情,再交给析构函数做。我觉得这样太麻烦,把程序弄的冗长了,所以这里就不介绍了。
下面总结一下:
(1) 析构函数绝对不要吐出异常,应该在内部“消化”掉这些异常;
(2) 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数,而非在析构函数中执行该操作。
条款九:绝不在构造和析构函数中调用virtual函数
class Base
{
public:
Base()
{
VirtualFunction();
}
virtual void VirtualFunction()
{
cout << "In the Base Class" << endl;
};
};
class Derived: public Base
{
public:
void VirtualFunction()
{
cout << "In the Derived Class" << endl;
}
};
int main()
{
Derived d;
}
定义派生类对象时,会先构造基类,调用基类的构造函数,在基类的构造函数中调用了虚函数,如果按照多态的思路,行为的执行者应该是派生类的VirtualFunction(),也就是输出的是In the Derived Class,然而实际跑一下这个程序,运行结果却是In the Base Class,为什么会这样呢?
一种很好的理解方法就是,派生类部分必须在基类部分构造完全之后才会去构造,因此在虚表中尚未注册派生类的VirtualFunction(),这时只能调用基类的VirtualFunction()。对于析构函数,同样是如此,派生类部分先析构,这时基类中的虚函数将无法定位到派生类,只能调用基类自身的函数。书上指出“在base class构造期间,virtual函数不是virtual函数”。
这样的结果会使读者感到困惑,与多态法则的效果不一致,所以本书作者强调“绝不在构造和析构函数中调用virtual函数”。
当实例化一个派生类对象时,首先进行基类部分的构造,然后再进行派生类部分的构造。即创建Derive对象时,会先调用Base的构造函数,再调用Derive的构造函数。
当在构造基类部分时,派生类还没被完全创建,从某种意义上讲此时它只是个基类对象。即当Base::Base()执行时Derive对象还没被完全创建,此时它被当成一个Base对象,而不是Derive对象,因此Foo绑定的是Base的Foo。
在构造函数中,所有基类构造函数总是在继承类构造函数执行之前被调用,而继承类对象将在构造函数执行完之后被完整创建。如果在构造函数中调用一个虚函数,则会不会发生动态绑定呢?答案是调用的是这个虚函数的本地版本。在构造函数中,基类构造函数已经被执行完成,如果在这个时候通过基类引用或指针调用一个虚函数,会把对象视为base class对象,因为这个时候,继承类对象还尚未被初始化完成,所以面对它们,最安全的做法就是视它们不存在。
所有的构造函数被调用后才会有最后派生的VTABLE,如果在构造函数中进行虚函数调用,应该使用早绑定,因为它们知道,晚绑定只会对本地函数产生调用。在构造函数中调用虚函数往往得到的结果并不是我们想要的,一般好的设计并不在构造函数中调用虚函数。在《Effective C++》中有条款9:绝不在构造函数和析构过程中调用virtual函数。
最后总结一下:
在构造和析构期间不要调用virtual函数,因为这类调用从不下降至派生类。