C++中为多态基类声明虚析构函数_干货


1.何谓析构函数?

它是用来释放对象所占用的资源。当对象的使用周期结束后(例如:当某对象的范围结束时或动态分配的对象被delete关键字销毁时),对象的析构函数会被自动调用,对象所占用的资源就会被释放。像文章C++类中默认生成的函数中所述,假如在你的类中不声明析构函数,编译器也会为你自动生成一个。

2.何谓多态基类?

多态(polymorphism)是C++面向对象的基本思想(封装,继承,多态)之一。如果我们希望仅仅通过基类指针就能操作它所有的子类对象,这时候就需要用到多态啦。如下例所示:
 1#include <iostream>
 2
 3// 多态基类
 4class TimeKeeper{
 5public:
 6    TimeKeeper();
 7    ~TimeKeeper();
 8    // ...
 9};
10
11// 子类
12class AtomicClok: public TimeKeeper{  // 原子钟
13    // ...
14};
15
16class WaterClock: public TimeKeeper{   // 水钟
17    // ...
18};
19
20class WristWatch: public TimeKeeper{  // 手表
21    // ...
22};
23
24
25TimeKeeper* getTimeKeeper(){  // 用来返回一个基类指针
26    // ...
27}
28
29TimeKeeper* ptk = getTimeKeeper();  
30....      // 使用这个基类指针ptk指向它的子类对象
31delete ptk;  // 使用完毕,释放资源
上例中代码存在的问题:getTimeKeeper()返回的指针ptk指向一个子类对象(例如WristWatch),而这个子类对象却通过一个基类指针ptk被删除。同时,基类中的析构函数是non-virtual的。C++中规定:当子类对象通过一个基类指针被删除时,而该基类带有一个非虚的析构函数时,其结果会出现未定义。实际执行过程中,通过发生的情况是子类对象中仅销毁了基类成分,而子类成分并没有被销毁,从而出现一个“局部销毁”对象,造成内存泄漏

解决方法:给基类中的析构函数增加virtual关键字修饰,使其成为虚析构函数。这样子类就允许拥有自己的析构函数,从而保证被占用的所有资源都会被释放。

 1#include <iostream>
 2
 3// 多态基类
 4class TimeKeeper{
 5public:
 6    TimeKeeper();
 7    virtual ~TimeKeeper();  // 虚析构函数,注意这句!
 8    // ...
 9};
10
11// 派生类
12class AtomicClok: public TimeKeeper{  // 原子钟
13    // ...
14};
15
16class WaterClock: public TimeKeeper{   // 水钟
17    // ...
18};
19
20class WristWatch: public TimeKeeper{  // 手表
21    // ...
22};
23
24
25TimeKeeper* getTimeKeeper(){  // 用来返回一个动态分配的基类对象
26    // ...
27}
28
29TimeKeeper* ptk = getTimeKeeper();  
30....      // 使用这个指针操作它的子类
31delete ptk;  // 使用完毕,释放资源
像Timekeeper这样的基类中可能会含有除析构函数外的函数,通常还有其他的虚函数。任何类只要带有虚函数都几乎确定应该也有一个虚析构函数
 

3.不要盲目将析构函数设置为virtual

如果一个类中不含有虚函数,通过表示这个类并不意图被用于一个基类。当类不打算被当作基类时,令其析构函数为virtual往往是一个馊主意。这与虚函数的运行机制有关:如果想实现出虚函数,对象必须携带某些信息,它主要用来在运行期间决定哪一个虚函数应该被调用。这份信息通常由一个vptr(虚函数表指针)指出。vptr指向一个由函数指针构成的数组(即虚函数表vtbl)。每个带有virtual函数的类都有一个对应的虚函数表。当对象调用某个虚函数时,实际被调用的函数取决于该对象的虚函数表指针所指向的那个虚函数表,编译器在其中寻找合适的函数指针。盲目使用虚析构函数很造成问题,如下例所示:
1class Point{
2public:
3    Point(...);
4    ~Point();
5private:
6    int x;
7    int y;
8};

上例中,如果int占用32位,那么Point对象可以放入一个64位缓存器中。甚至,这样一个Point对象可以被当作一个64位的量传给其他语言编写的函数。然而当Point的析构函数是virtual,情况就发生了变化。如果Point类中含有虚函数时,其对象的体积会增加。32位计算机体系结构中将占用64位(为了存放两个int)至96位(两个int加vptr)。在64位计算机体系结构中可能占用64~128位,因为指针在这样的计算机结构中占64位(8字节)。因此,为Point类添加一个vptr会增加其对象大小达50%~100%。Point对象不再能够塞入一个64位的缓存器中,而C++的Point也不再和其他语言内的相同声明有一样的结构(因为其他语言中没有vptr),所以也不可能把它传递到其他语言所写的函数中。

 

4.即使类完全不带虚函数,也会受虚函数影响

标准的string中不含有虚函数,但有时候我们也会错误地把它当做基类。如下例所示:

1class SpecialString : public std::string{...};      // 某个继承自标准字符串的类,std::string有个非虚析构函数
2
3SpecialString* pss = new SpecialString("Hi");
4std::string* ps;
5...
6ps = pss;
7delete ps;                                          //使用完后从基类删除内存

上面的写法中,同样会导致第2节中所讲的内存泄漏问题,因为标准库的字符串并没有把析构函数定义为虚函数,它们并不是用来拿去继承的,所以不能随便继承,包括STL。虽然C++不像java有final和C#有sealed来阻止某些类被继承的机制,我们也要拒绝这种写法。

 

5.抽象类与虚析构函数的完美结合对于抽象类(abstract class),抽象类是包含至少一个纯虚函数的类(pure virtual function),而且它们不能被实例化,只能通过指针来操作,是纯粹被用来当做多态基类的。它们相比于具体类(concrete class),虽然都可以通过父类指针来操作子类对象,但抽象类有更高一层的抽象,从设计的角度来说,它们能更好的概括某些类的共同特性,比如"狗"相对于"边牧","柴犬","斗牛",把"狗"当做基类显然要比把某个品种当做基类要好。因为多态基类需要有虚析构函数,抽象类又需要有纯虚函数,那么在抽象类中就要把析构函数声明为纯虚函数。如下例所示:
1class AWSL{
2public:
3  virtual ~AWSL() =0;     // 声明纯虚函数
4};
注意:你必须为这个纯虚的析构函数提供一个定义。这是因为析构函数的运行机制是:最深层的子类中的析构函数最先被调用,然后在再调用基类中的析构函数。因此,编译器会在AWSL的子类的析构函数中创建一个对~AWSL的调用动作,所以必须为这个纯虚的析构函数提供一个定义。否则,链接器会报错。
1AWSL::~AWSL(){}                     // 基类的析构函数要有一个空的定义
6.理性对待虚析构函数给一个基类的析构函数声明为virtual,这个规则只适用于带有多态性质的基类身上。这样的基类设计出来的目的是为了通过基类中的接口处理子类的对象。并不是所有基类的设计目的都是为了多态。例如文章如果不想使用编译器默认生成的函数,请明确拒绝它!中,它们并非被设计用来通过基类接口处理子类对象的,因此基类中的析构函数不用声明为virtual。7.总结(1).用来实现多态的基类应该声明为虚(virtual)的析构函数。如果一个基类中含有虚函数,那它就是被用来实现多态的,就需要有一个虚析构函数。(2).某些类不是被用来当做基类的,比如std::string和STL,或者某些不是用来实现多态的基类,比如文章如果不想使用编译器默认生成的函数,请明确拒绝它!中的Uncopyable类,就不需要设置为虚析构函数。