条款07:为多态基类声明virtual析构函数
先直接给个例子:
#include <iostream>
using namespace std;
const double PI = 3.1415926;
class Figure
{
public:
void set_size(double x, double y=0)
{
x_size = x;
y_size = y;
}
virtual double get_area() = 0;
protected:
double x_size, y_size;
};
class Triangle : public Figure
{
public:
virtual double get_area(){
return ( x_size * y_size / 2 );
}
};
class Circle : public Figure
{
public:
virtual double get_area(){
return ( x_size * x_size * PI );
}
};
int main() {
Figure* figure1 = new Triangle();
Figure* figure2 = new Circle();
figure1->set_size(1.2,1.2);
cout << figure1->get_area() << endl;
figure2->set_size(1.2);
cout << figure2->get_area() << endl;
delete figure1;
delete figure2;
return 0;
}
在上面的程序中有一个基类Figure和它的两个派生类Triangle、Circle。在基类中有一个纯虚函数get_area(),有多态行为。我们在主函数中动态声明两个通过基类指向派生类的指针figure1和figure2,当最后我们需要delete它们的时候,问题出现了,编译器出报warnings:
出现的原因就是当派生类对象经由一个基类指针被删除,而该基类带着一个非虚的析构函数(由编译器自动生成的析构函数是非虚函数),其结果未有定义——实际执行时通常发生的是对象的派生类成分没被销毁。也就是说,在该例中,当执行delete figure1时,编译器简单地析构了基类Figure的部分,而使得派生类Triangle的部分被架空了,所以它的析构函数也未能执行起来,造成内存的泄露。
解决这个问题也很简单:给基类加上一个虚析构函数。class Figure
{
public:
void set_size(double x, double y=0){
x_size = x;
y_size = y;
}
virtual double get_area() = 0;
virtual ~Figure(){}
protected:
double x_size, y_size;
};
有了虚析构函数,析构时就会检查指针实际指向的对象,也就是会先定位到相应的派生类,完成派生类的析构之后,再析构基类。
在C++中,如果一个类中有虚函数,就会维护一个虚表指针vptr,它指向一个由函数指针构成的数组,称为虚表,用来在运行期决定哪一个虚函数应该被调用。当对象调用某一个虚函数时,实际被调用的函数取决于该对象的虚表指针所指的那个虚表中的某一个虚函数。
在编程的时候,我们其实可以将基类里的所有方法都声明为虚方法,这样就能一劳永逸地确保程序的行为符合预期。但是带来的代价就是效率的降低,因为要维护一个vptr。
此外,在实现一个多层次的类继承关系的时候,最顶级的基类应该只有虚方法。一般来说,最顶级的基类应该是一个抽象类,但是如果类中没有一个纯虚函数,像本例中的
virtual double get_area() = 0;
一个好的方法就是将虚析构函数变成纯虚析构函数,像这样:
virtual~Figure() = 0{};
最后需要强调一点的是:只有当一个基类具有多态性质时,基类的析构函数才应该声明为虚函数。而非基类和不具有多态性的基类就不应该这么使用。
条款08:别让异常逃离析构函数
首先,先讨论一下C++中的异常。如果一个对象在运行期间出现了异常,C++的异常处理模型就应该清楚那些由于出现异常所导致的已经失效的对象,并释放该对象原理所分配的资源,这些都是由析构函数来完成的任务。所以析构函数本身就已经是异常处理的一部分。
因此,一般来说,C++的析构函数是不应该抛出异常的。
在More Effective C++这本书中给出了两点理由:
①如果析构函数抛出异常,则异常之后的程序是不会执行的。那么如果在这些不执行的部分中有释放资源的动作,就会造成内存泄漏的问题。
②通常异常发生时,C++会调用该对象的析构函数来释放资源。如果此时析构函数又抛出异常,则之前一个异常还未处理,现在又有了新的异常,这样会陷入一个无限的递归嵌套中,最终造成程序的崩溃。
如果真的无法保证在析构函数中不发生异常,那么最好的办法就是把异常完全封装在析构函数内部,决不让异常抛出函数之外,即在内部“消化”掉这些异常。如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么应该在类中提供一个普通的函数来执行该操作而不是在析构函数中。
条款09:绝不在构造和析构过程中调用virtual函数
想像一下,如果在一个基类的构造函数中调用了虚函数,而当派生类实例化一个对象的时候,先要调用派生类的构造函数,而在此之前,还需要调用基类的构造函数。这样一来问题就出现了,那就是调用基类的构造函数,而该构造函数中还有虚函数,那么这个虚函数怎么可能体现出派生类的行为?所以虚函数是体现的基类的行为。举个例子:
#include <iostream>
using namespace std;
class Base
{
public:
Base() {
output();
}
virtual void output() {
cout << "Base Class" << endl;
}
virtual ~Base() {}
};
class Derived: public Base
{
public:
void output() {
cout << "Derived Class" << endl;
}
};
int main()
{
Derived d;
}
在基类的构造函数中调用了虚函数output(),按照多态的思路,应该输出的是"Derived Class",但是事实上,输出"BaseClass"。
书中用了一句非常简明的非正式的说法:“在base class构造期间,virtual函数不是virtual函数”。在本例中,派生类对象的基类构造期间,对象的类型是基类而不是派生类。C++的如此做法是很合理的,因为在派生类未构造之前就去调用它们,本质上是“要求使用对象内部尚未初始化的成为”,这是非常危险的。所以C++在面对这种情况的时候,直接视其不存在。
同理,在调用析构函数的时候也是如此。但是需要提醒的是,析构函数的调用次序是相反的,一旦派生类的析构函数开始执行,对象内的派生类成员变量便呈现未定义值,进入基类析构函数后对象就成为一个基类对象。
这样看起来似乎该问题很容易避免,但是如果一个类有多个构造函数,往往会把它们在一个函数内实现。比如:
class Base
{
public:
Base() {
init();
}
……
private:
void init(){
output(); //这里调用了虚函数
}
}
想像一下,在上下文很长的代码中藏着这样一个隐患是很难发现的。所以我们不仅要确保构造函数本身不调用虚函数,还要保证构造函数所调用的所有函数中都不调用构造函数。