返回

[Effective C++ 笔记]07. 为多态基类声明 virtual 析构函数

简介

  • 带多态性质的(polymorphic)的基类应该将析构函数声明为 virtual。或:如果类中带有任何 virtual 函数,那么它的析构函数也应该是 virtual 的。
  • 如果类的设计目的不是用于基类,或者不是为了具备多态性(polymorphically),就不应该声明 virtual 析构函数。

引子

考虑以下例子:我们需要设计一个类 TimeKeeper 来记录时间。由于记录时间的方法有很多种,我们可以以 TimeKeeper 为基类进而设计一些派生类来作为不同的实现方法,如下所示:

class TimeKeeper {
public:
    TimeKeeper();
    ~TimeKeeper();
    // ...
};

class AtomicClock: public TimeKeeper {
     //...
};

class WaterClock: public TimeKeeper {
    //...
};

class WristWatch: public TimeKeeper {
    //...
};

但是对于用户而言,他们不需要知道具体时间的记录方式。因此我们可以用一个工厂函数返回一个基类(TimeKeeper)指针指向派生类对象,如下所示:

TimeKeeper* getTimeKeeper() {
    return new WaterClock(); //可调整返回不同派生类的指针
}

由于 getTimeKeeper() 中返回的对象位于堆中,用户在进程结束前需要将其 delete 掉释放该空间,如下所示:

TimeKeeper* ptk = getTimeKeeper();
delete ptk;

目前我们暂且不讨论这种方式的好坏(依赖于客户手动删除对象),但是即使用户正确地 delete 掉该对象,这段程序依然有问题!首先我们为每个类的析构函数都添加输出信息好让我们方便得知其是否有被调用,(运行代码见 Ex1):

class TimeKeeper {
public:
    TimeKeeper() {}
    ~TimeKeeper() {
        std::cout << "Destory TimeKeeper" << std::endl;
    }
    // ...
};

class AtomicClock: public TimeKeeper {
     //...
     ~AtomicClock() {
         std::cout << "Destory AtomicClock" << std::endl;
     }
};

class WaterClock: public TimeKeeper {
    //...
    ~WaterClock() {
        std::cout << "Destroy WaterClock" << std::endl;
    }
};

class WristWatch: public TimeKeeper {
    //...
    ~WristWatch() {
        std::cout << "Destroy WristWatch" << std::endl;
    }
};

此时,当我们 delete 掉 ptk 时,输出如下:

Destory TimeKeeper

可以发现,当我们 delete 一个指向派生类的基类指针时,在这种情况下(所有析构函数都是 non-virtual)只有基类的析构函数被调用了!。这种结果会导致有一部分空间(派生类的额外成员变量)没有释放干净而导致内存泄露,接下来我们来研究一下如何避免这种情况。

声明基类析构函数为 virtual

消除上述问题也很简单,只需要对基类中的析构函数声明为 virtual 即可,如下所示,(代码见 Ex2):

class TimeKeeper {
public:
    TimeKeeper() {}
    virtual ~TimeKeeper() {
        std::cout << "Destory TimeKeeper" << std::endl;
    }
    // ...
};

//...

TimeKeeper* ptk = getTimeKeeper();
delete ptk;

输出:

Destroy WaterClock
Destory TimeKeeper

如上可知,目前的行为正确,delete 操作已经正确的释放掉派生类对象的所有空间。

不要将非基类的析构函数声明为 virtual

为了避免出现上述问题,有人可能会想将所有类的析构函数都声明为 virtual,这同样是不提倡的。让我们回顾一下 virtual 函数的实现过程:每一个带有 virtual 函数的对象都会存有一份虚表指针(virtual table pointer, vtpr)指向所有由函数指针组成的驻数组,该数组称为虚表(virtual table, vtbl)。当对象调用某一个 virtual 函数时,程序会通过虚表中查找对应的函数来调用。由此可以知道,声明析构函数为 virtual 会带来额外的内存开销。下面考虑以下例子(代码见 Ex4):

class Point {
public:
    Point(int xCoord, int yCoord): x(xCoord), y(yCoord) {}
    ~Point() {};
private:
    int x, y;
};

std::cout << "Size of Point: " << sizeof(Point) << std::endl; // output: Size of Point: 8

上述 Point 类的大小,显而易见占 8 个字节,因为包含两个整型成员变量各占 4 个字节。在这种情况下,这样一个 Point 对象还可以存入一个 64-bit 缓存器,甚至作为一个 64-bit 的量直接提供给其他语言 C 或 FORTRAN 使用,具有良好的移植性。

当我们不加思考地将其析构函数声明为 virtual 时,如下所示:

class PointWithVitural {
public:
    PointWithVitural(int xCoord, int yCoord): x(xCoord), y(yCoord) {}
    virtual ~PointWithVitural() {};
private:
    int x, y;
};

std::cout << "Size of PointWithVirtual: " << sizeof(PointWithVitural) << std::endl; // output: Size of PointWithVirtual: 16

可以发现,该变量的内存占用变成了 16个字节,占用多了一倍!并且其不再能够放入 64-bit 缓存器;并且该类也不再和 C (或其他语言)相同声明的类中拥有相同的结构(因为 C 中不存在 vtpr),因而失去了移植性。(我们必须手动添加偏置以补偿 vptr)。

那么我们应该在什么时候将析构函数声明为 virtual 呢?我们通常认为只有当类中至少包含一个除析构函数外的 virtual 函数时才将析构函数声明为 virtual,因为如果该类不包含 vitrual 函数,那么通常来说我们并不会打算将其用作基类。同理,任何类只要带有 virtual 函数也几乎确定应该有一个 virtual 析构函数。

利用 pure virtual 析构函数构造抽象类

有时候我们会希望某一个类是一个抽象类(abstract class),使得用户不能直接实体化他。然而该类中并不包含 pure virtual 函数,这个时候我们想到,基类无论何时都应该有一个 virtual 析构函数,而 pure virtual 函数又能将该类变成抽象类。因此不能想到我们可以使该类的析构函数声明为 pure virtual,如下所示(代码见 Ex5):

class AWOV {
public:
    virtual ~AWOV() = 0;
};

当然,考虑到析构函数的调用顺序(派生类析构函数->基类析构函数),我们还需要提供基类中的析构函数实现。也正是这个原因,我们不用思考去将哪个额外的 virtual 函数声明为 pure virtual 函数(因为将其声明为 pure virtual 之后基类的的该函数便不再会被调用,相当于被“覆盖”了),如下所示:

class AWOV {
public:
    virtual ~AWOV() = 0;
};

AWOV::~AWOV() {
    std::cout << "Destory AWOV" << std::endl;
}

class Derived: public AWOV {
public:
    ~Derived() {
        std::cout << "Destory Derived" << std::endl;
    }
};

//...
Derived* d = new Derived();
delete d;

输出:

Destory Derived
Destory AWOV

因此,我们可以在不影响基类的析构函数的情况下将基类构造为抽象类。

不是所有的类(基类)都带有多态性质

需要知道的是,所有 STL 的容器如(std::string, std::vector, std::list 等等)均不包含 virtual 析构函数。因此我们要注意不要错误地将其作为基类以涉及派生类,否则很容易出现上述中资源泄露的问题,如下所示(运行代码见 Ex3)。这一点和 Java (提供了 final class) 和 C# (提供了 sealed class) 不同,需要格外注意:

class SpecialString : public std::string {

    ~SpecialString() {
        std::cout << "Destory SpecialString" << std::endl;
    }
};

// ...
std::string* ps = new SpecialString();
delete ps;  // 不会调用 SpecialString 的析构函数!

因此,结合上一部分的内容(不要为不准备作为基类的类提供 virtual 析构函数)。这里可以概括为:我们只应该为适用于带多态性质的(polymorphic)的基类声明一个 virtual 析构函数。这种类一般是设计来通过基类的接口(指针,引用)来处理派生类例如本文开篇时提及的 TimeKeeper。而在某些情况中该类是不适用的,例如:

  • STL 中的标准容器,他们甚至不被设计用来基类使用(理由如上),更别提带有多态性质了
  • 某些类虽然是设计来作为基类,但是不带有多态性质,例如 Uncopyable 和标准库中的 input_iterator_tag 这些我们会在后面的条款中提及。这些类并非设计用来通过基类的借口调用派生类的,所有也不需要 virtual 析构函数。

结论

  • 带多态性质的(polymorphic)的基类应该将析构函数声明为 virtual。或:如果类中带有任何 virtual 函数,那么它的析构函数也应该是 virtual 的。
  • 如果类的设计目的不是用于基类,或者不是为了具备多态性(polymorphically),就不应该声明 virtual 析构函数。

完整可运行代码地址:Effective-Cpp-Reading-Note

Built with Hugo
Theme Stack designed by Jimmy