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 destructordeleting destructor

当你写 delete p 时,编译器通过 vtable 找到 deleting destructor,它内部会:

  1. 调用派生类的析构函数体
  2. 调用基类的析构函数体(按继承链从派生到基类依次调用)
  3. 调用 operator delete 释放内存

这就是为什么虚析构能保证「先派生后基类」的调用顺序,并且能正确释放内存。

💡 核心要点: 虚析构函数不是「让析构变成虚函数」这么简单,它实际上让 delete 操作变成了多态调用。编译器通过 vtable 找到正确的析构函数入口,从而保证整个析构链完整执行。

7.4 什么时候不需要虚析构?

也不是所有类都需要虚析构。我总结了几种情况:

场景 是否需要虚析构 原因
类不打算被继承 不需要 没有多态删除的需求
类被继承,但不会通过基类指针删除 不需要 只要不用 Base* p = new Derived(); delete p; 就没问题
类有虚函数,且作为基类使用 必须加 这是最常见的场景,不加就是 bug
标准库容器/智能指针作为成员 视情况而定 如果类本身是基类,仍然需要虚析构

我曾经在一个嵌入式项目里,为了省那一点点 vtable 开销,故意没加虚析构。结果后来需求变更,派生类需要持有动态内存……嗯,那次重构让我深刻理解了「过早优化是万恶之源」。

7.5 避坑指南

这里分享几个我踩过的坑:

  • 不要忘记给接口类加虚析构——哪怕接口类里没有数据成员,也要加。因为派生类可能有。
  • 不要在构造函数或析构函数中调用虚函数——这时候虚函数机制是失效的,调用的是当前类的版本,不是派生类的。
  • 如果用了 final 关键字——比如 class Derived final,那它的析构函数不需要是虚的,但基类仍然需要。
  • 智能指针能帮你省事——用 shared_ptr<Base> 管理派生类对象时,即使基类没有虚析构,智能指针也能正确释放。但我不建议依赖这个特性,它容易让人产生错觉。
📌 我的建议: 写类的时候,先问自己三个问题——这个类会被继承吗?派生类会申请资源吗?会通过基类指针删除吗?只要有一个「是」,就加 virtual 析构。别犹豫。

7.6 知识结构图

下面这张图帮你理清虚析构函数的整个逻辑链条:

虚析构函数核心逻辑 delete 基类指针 有虚析构? 查 vtable → 调用派生类析构 → 再调基类析构 → 释放内存 只调基类析构 派生类资源泄漏! 总结 基类有虚析构 → 多态删除正确释放资源 基类无虚析构 → 派生类析构被跳过 → 内存泄漏

说白了,虚析构函数就是给 delete 操作装了一个「动态路由」。编译器不再死板地根据指针类型决定调用哪个析构函数,而是运行时去 vtable 里查。这个机制保证了继承链上的所有析构函数都能被正确调用。

好了,关于虚析构函数,核心就是这些。记住一句话:基类析构不加 virtual,等于给内存泄漏留后门。写代码的时候多留个心眼,能省去很多排查的麻烦。


公众号:蓝海资料掘金营,微信 deep3321