7、析构函数与虚析构:为什么基类析构函数需要声明为virtual、内存泄漏的避免、虚析构函数的底层实现
好,咱们今天聊一个实战中特别容易踩坑的话题——虚析构函数。
我记得刚入行那会儿,带我的老工程师跟我说过一句话:「你写基类,如果不确定要不要加 virtual 析构,那就加上。」当时我不太理解,觉得这不就是多此一举吗?直到后来有一次,我在一个网络库的项目里,亲眼看着内存一点点被吃掉,排查了整整两天……嗯,从那以后,我再也不敢忽视虚析构了。
7.1 一个经典的「内存泄漏」场景
先看一段代码。你想想看,下面这段程序会有什么问题?
class Base {
public:
~Base() { cout << "Base destructor\n"; }
};
class Derived : public Base {
private:
char* buffer;
public:
Derived() { buffer = new char[1024]; }
~Derived() {
delete[] buffer;
cout << "Derived destructor\n";
}
};
int main() {
Base* p = new Derived();
delete p; // 这里只调用了 Base 的析构!
return 0;
}
运行结果是什么?只打印了 Base destructor。Derived 的析构函数根本没执行。buffer 那块 1024 字节的内存,就这么泄漏了。
为什么会这样?
因为 delete p 的时候,编译器看的是 p 的静态类型——Base*。它不知道 p 实际指向的是 Derived 对象。所以它只调用了 Base::~Base(),跳过了派生类的清理逻辑。
7.2 解决方案:把基类析构函数声明为 virtual
解决办法很简单——加一个 virtual 关键字:
class Base {
public:
virtual ~Base() { cout << "Base destructor\n"; }
};
class Derived : public Base {
private:
char* buffer;
public:
Derived() { buffer = new char[1024]; }
~Derived() override {
delete[] buffer;
cout << "Derived destructor\n";
}
};
int main() {
Base* p = new Derived();
delete p; // 现在正确了!
return 0;
}
输出:
Derived destructor
Base destructor
你看,先调了 Derived 的析构,再调 Base 的析构。资源释放干净了。
我个人习惯是:只要一个类有虚函数,或者它被设计为基类,就立刻把析构函数写成 virtual。哪怕析构函数体是空的,也要加。这不是性能问题,这是正确性问题。
7.3 虚析构函数的底层实现
好,咱们深入一层。虚析构函数到底是怎么工作的?
其实原理和普通虚函数一模一样。每个类有一张虚函数表(vtable),虚析构函数也会在 vtable 里占一个槽位。
但有一个细节值得注意:析构函数在 vtable 里占两个槽位。
为什么是两个?
因为 C++ 编译器需要区分「完整对象析构」和「基类子对象析构」两种场景。前者要调用 delete 释放内存,后者只执行清理逻辑。这两个版本在 vtable 里分别叫 complete object destructor 和 deleting destructor。
当你写 delete p 时,编译器通过 vtable 找到 deleting destructor,它内部会:
- 调用派生类的析构函数体
- 调用基类的析构函数体(按继承链从派生到基类依次调用)
- 调用
operator delete释放内存
这就是为什么虚析构能保证「先派生后基类」的调用顺序,并且能正确释放内存。
delete 操作变成了多态调用。编译器通过 vtable 找到正确的析构函数入口,从而保证整个析构链完整执行。
7.4 什么时候不需要虚析构?
也不是所有类都需要虚析构。我总结了几种情况:
| 场景 | 是否需要虚析构 | 原因 |
|---|---|---|
| 类不打算被继承 | 不需要 | 没有多态删除的需求 |
| 类被继承,但不会通过基类指针删除 | 不需要 | 只要不用 Base* p = new Derived(); delete p; 就没问题 |
| 类有虚函数,且作为基类使用 | 必须加 | 这是最常见的场景,不加就是 bug |
| 标准库容器/智能指针作为成员 | 视情况而定 | 如果类本身是基类,仍然需要虚析构 |
我曾经在一个嵌入式项目里,为了省那一点点 vtable 开销,故意没加虚析构。结果后来需求变更,派生类需要持有动态内存……嗯,那次重构让我深刻理解了「过早优化是万恶之源」。
7.5 避坑指南
这里分享几个我踩过的坑:
- 不要忘记给接口类加虚析构——哪怕接口类里没有数据成员,也要加。因为派生类可能有。
- 不要在构造函数或析构函数中调用虚函数——这时候虚函数机制是失效的,调用的是当前类的版本,不是派生类的。
- 如果用了
final关键字——比如class Derived final,那它的析构函数不需要是虚的,但基类仍然需要。 - 智能指针能帮你省事——用
shared_ptr<Base>管理派生类对象时,即使基类没有虚析构,智能指针也能正确释放。但我不建议依赖这个特性,它容易让人产生错觉。
7.6 知识结构图
下面这张图帮你理清虚析构函数的整个逻辑链条:
说白了,虚析构函数就是给 delete 操作装了一个「动态路由」。编译器不再死板地根据指针类型决定调用哪个析构函数,而是运行时去 vtable 里查。这个机制保证了继承链上的所有析构函数都能被正确调用。
好了,关于虚析构函数,核心就是这些。记住一句话:基类析构不加 virtual,等于给内存泄漏留后门。写代码的时候多留个心眼,能省去很多排查的麻烦。
公众号:蓝海资料掘金营,微信 deep3321