加载中...
大厂题第1辑——虚函数七题精讲之5:虚析构(virtual destructor)
第1节:C++的“友元”是否会破坏类的封装?
第2节:大厂题第1辑——虚函数七题精讲之1:虚函数的作用
第3节:大厂题第1辑——虚函数七题精讲之2:虚函数的作用机制
第4节:大厂题第1辑——虚函数七题精讲之3:纯虚函数
第5节:大厂题第1辑——虚函数七题精讲之4:override 的作用
第6节:大厂题第1辑——虚函数七题精讲之5:虚析构(virtual destructor)
第7节:大厂题第1辑——虚函数七题精讲之6:构造、析构、虚函数
课文封面

析构函数本质也是类的一个成员函数,因此 “虚”的析构函数,本质也是虚函数,目的也是为了实现“多态”。面试官之所以喜欢问这个问题,只是因为,如果 析构函数没做多态处理,容易带来比较隐秘而可怕的后果:内存或资源泄漏。

题10-析构函数在什么情况下,应为虚函数?

1.1 问题与分析

一个对象指针,如果其声明类型是基类,但实际指向派生类对象,比如:

class Flyable() {}; class Bird : public Flyable {}; Flyable* f = new Bird;

注意指针 f ,它的声明类型是 “Flyable *”,但实际 创建(new) 出来的是 Bird 类型 —— 这种情况下,delete f 会发生什么?

delete f; // 发生什么?

答:f 要被释放。释放又最少可分为两大步骤:首先是调用析构函数,然后才是收回对象自身所占用的内存。 重点 来了:在本例中,第一步所调用的析构函数,是基类Flyable的析构,而不是派生类 Bird 的析构函数。

说得更直一点:一个实质的派生类的对象,“死”的时候却只调用了基类的析构函数——没有多态。

正确的情况应该怎样?答:正确的情况应该是,释放派生类对象时(哪怕它的声明类型的是基类),也应该先调用派生类自己的析构函数,然后再调用直接基类的析构。如果直接基类还有基类,那就一层层往上走。

我们给个更完整的例子,请大家对比。参与对比的两个指针是:

  • pa ,声明类型和实际指向对象类型一致,都是 Bird;
  • pb ,声明类型是 Flyable,实际指向对象类型 是 Bird。
#include <iostream> using namespace std; class Flyable { public: ~Flyable() { cout << "~Flyable\n"; } }; class Bird : public Flyable { public: ~Bird() { cout << "~Bird\n"; } }; int main() { Bird* pa = new Bird; // 名实一致 Flyable* pb = new Bird; // 名实不一 delete pb; cout << "---------------------\n"; delete pa; }

在线运行: 例1-非虚析构情况下,名实一致 vs. 名实不一

运行输出:

~Flyable --------------------- ~Bird ~Flyable

横线上孤零零的 ~Flyabledelete pb 的输出,虽然,pb 实际是一只鸟,但它却死得像一个 “Flyable”。

横线下两行,先是派生类的析构:~Bird,然后是基类的析构: ~Flyable。这才是一个派生类对象正常的死法,也是析构函数特殊的地方:从当前类的析构开始,然后会自动调用直接基类的析构,如果基类还有基类,就一层一层地往上调基类的析构。

1.2 解决之道

我们希望在 delete pb时,能够调用 pb 的实质类型 (派生类)的 析构函数;说得简单点,就是(不管声明类型是什么),基类有基数的 “死法”,不同的派生类又有不同“死法”。各种 “死法” 千姿百态,这不就是 “虚函数” 应该派上场的时候吗?

这次,我们把 Flyable 中的析构函数,声明为虚的;然后在派生类 Bird 中,使用 “override” 关键字(见上一节 “override 的作用”),明确表明要覆盖(override)基类的原有“死法”,给出自己的“死法”:

#include <iostream> using namespace std; class Flyable { public: virtual /*虚的哦*/ ~Flyable() { cout << "~Flyable\n"; } }; class Bird : public Flyable { public: ~Bird() override /* 好习惯 */ { cout << "~Bird\n"; } }; int main() { Bird* pa = new Bird; // 名实一致 Flyable* pb = new Bird; // 名实不一 delete pb; cout << "---------------------\n"; delete pa; }

在线运行: 例2:虚析构情况下, 名实一致 vs 名实不一

运行输出:

~Bird ~Flyable --------------------- ~Bird ~Flyable

完美,在 “名实不一”的情况下,析构也实现了:先调派生类,再自动调基类析构。

1.3 内存泄漏实例

我们给的例子,创建对象时,对象内部都没有做什么实际操作。假设我们希望 Bird 在构建时,需要准备好个鸟巢,为了简化实现,我们就假设需要申请 100个字节的 byte (unsigned char) 内存:

class Bird : public Flyable { unsigned char * nest; // 鸟巢 public: Bird() { nest = new unsigned char [100]; // 建筑鸟巢需要的100个字节 } ~Bird() { cout << "~Bird\n"; delete [] nest; // 释放鸟巢占用的内存 } };

这情况下,例1中 delete pb; (名实不一) 时,尽管派生类的析构很自觉地释放了 nest,可是,挡不住此时派生类的析构函数根本没有被调用啊……所以,100字节就此泄漏。

请结合上面带鸟巢的例子,动手写出正确代码的完整实现吧。