目录

一、一个会泄漏内存的程序

二、为什么会这样?——静态绑定 vs 动态绑定

解决方案:把基类析构函数声明为虚函数

三、虚析构函数的原理

虚析构函数也是虚函数

析构函数的执行顺序

四、虚析构函数的开销

1. 对象内存增加一个vptr

2. 调用开销增加一次间接寻址

什么时候不需要虚析构函数?

五、完整例子:虚析构函数救火

六、抽象类中的虚析构函数

七、虚析构函数与“三/五法则”

八、三个常见错误

1. 忘记把析构函数声明为虚函数

2. 把不是基类的类析构函数设为虚函数(浪费)

3. 在析构函数中调用虚函数

九、这一篇的收获


一、一个会泄漏内存的程序

先看这段代码,猜猜会发生什么:

cpp

#include <iostream>
#include <string>
using namespace std;

class Base {
public:
    Base() { cout << "Base构造" << endl; }
    ~Base() { cout << "Base析构" << endl; }   // 注意:不是虚函数
};

class Derived : public Base {
private:
    string* data;
public:
    Derived() {
        data = new string("派生类分配的资源");
        cout << "Derived构造,分配了内存" << endl;
    }
    ~Derived() {
        delete data;
        cout << "Derived析构,释放了内存" << endl;
    }
};

int main() {
    Base* ptr = new Derived();
    delete ptr;   // 这里会发生什么?
    return 0;
}

输出:

text

Base构造
Derived构造,分配了内存
Base析构        ← 只有基类析构被调用!

问题Derived的析构函数没有被调用,data指向的内存永远没有释放——内存泄漏

这个bug非常隐蔽,因为:

  • 程序不一定会崩溃

  • 内存泄漏通常不会立即显现

  • 在大型项目中很难追踪


二、为什么会这样?——静态绑定 vs 动态绑定

回忆第14篇的静态绑定与动态绑定:

  • 非虚函数:调用在编译时决定(静态绑定)

  • 虚函数:调用在运行时决定(动态绑定)

析构函数也是函数。如果基类析构函数不是虚函数,delete ptr时编译器采用静态绑定

cpp

delete ptr;   // ptr的类型是Base*,所以调用Base::~Base()

编译器不知道ptr实际指向的是Derived对象,所以不会去调用Derived的析构函数。

解决方案:把基类析构函数声明为虚函数

cpp

class Base {
public:
    virtual ~Base() { cout << "Base析构" << endl; }   // 加virtual
};

现在输出:

text

Base构造
Derived构造,分配了内存
Derived析构,释放了内存    ← 调用了!
Base析构

原理:析构函数是虚函数,delete ptr时动态绑定,先调用派生类析构,再自动调用基类析构。


三、虚析构函数的原理

虚析构函数也是虚函数

虚析构函数和普通虚函数一样,会进入虚函数表(vtable)。

cpp

class Base {
public:
    virtual ~Base() {}
};

class Derived : public Base {
public:
    ~Derived() override {}   // 重写基类的虚析构函数
};

vtable布局

text

Base_vtable:
  [0] → Base::~Base()

Derived_vtable:
  [0] → Derived::~Derived()   ← 覆盖了基类的槽位

当通过Base*删除对象时:

  1. 从对象中取出vptr

  2. 从vtable中取第0个函数(析构函数地址)

  3. 调用该函数——由于派生类覆盖了这个槽位,调用的是Derived::~Derived()

  4. Derived析构函数执行完毕后,自动调用Base::~Base()

析构函数的执行顺序

无论析构函数是否是虚函数,执行顺序都是先派生类,后基类。区别在于:

  • 非虚:只调用基类析构,跳过了派生类部分

  • :通过动态绑定,确保派生类析构先被调用


四、虚析构函数的开销

虚析构函数带来两个开销:

1. 对象内存增加一个vptr

cpp

class NoVirtual {
public:
    ~NoVirtual() {}
    // 没有其他虚函数
};

class WithVirtual {
public:
    virtual ~WithVirtual() {}
};

cout << sizeof(NoVirtual) << endl;   // 1(空类占1字节)
cout << sizeof(WithVirtual) << endl; // 8(64位系统,vptr占8字节)

如果类本身已经有其他虚函数,vptr已经存在,加虚析构函数不增加额外内存

2. 调用开销增加一次间接寻址

非虚析构:直接调用
虚析构:vptr → vtable → 间接调用(多2次内存访问)

什么时候不需要虚析构函数?

规则:只有当类会作为基类被多态使用时,才需要虚析构函数。

cpp

// 不需要虚析构函数:不会作为基类
class Point {
    int x, y;
public:
    ~Point() {}   // 非虚即可
};

// 需要虚析构函数:会作为基类被多态使用
class Shape {
public:
    virtual void draw() = 0;
    virtual ~Shape() {}   // 必须虚
};

标准库中的例子

  • std::string:没有虚析构函数(不应该被继承)

  • std::vector:没有虚析构函数(不应该被继承)

  • std::exception虚析构函数(可以被继承)


五、完整例子:虚析构函数救火

一个模拟数据库连接池的例子,展示没有虚析构函数的后果:

cpp

#include <iostream>
#include <string>
#include <vector>
using namespace std;

// 资源类:模拟数据库连接
class DBConnection {
private:
    int id;
    static int nextId;
public:
    DBConnection() : id(++nextId) {
        cout << "  数据库连接 " << id << " 已建立" << endl;
    }
    ~DBConnection() {
        cout << "  数据库连接 " << id << " 已释放" << endl;
    }
    void query(const string& sql) {
        cout << "  连接" << id << " 执行: " << sql << endl;
    }
};
int DBConnection::nextId = 0;

// 基类:数据访问层(没有虚析构函数——错误示范)
class BadDataAccess {
protected:
    DBConnection* conn;
public:
    BadDataAccess() {
        conn = new DBConnection();
        cout << "BadDataAccess构造" << endl;
    }
    ~BadDataAccess() {   // ❌ 非虚析构
        delete conn;
        cout << "BadDataAccess析构" << endl;
    }
    virtual void execute(const string& sql) {
        conn->query(sql);
    }
};

// 派生类:用户数据访问
class BadUserDataAccess : public BadDataAccess {
private:
    string* cache;
public:
    BadUserDataAccess() : BadDataAccess() {
        cache = new string("用户数据缓存");
        cout << "BadUserDataAccess构造,分配了缓存" << endl;
    }
    ~BadUserDataAccess() {
        delete cache;
        cout << "BadUserDataAccess析构,释放了缓存" << endl;
    }
    void execute(const string& sql) override {
        cout << "【使用缓存】" << endl;
        conn->query(sql);
    }
};

// 正确版本:基类有虚析构函数
class GoodDataAccess {
protected:
    DBConnection* conn;
public:
    GoodDataAccess() {
        conn = new DBConnection();
        cout << "GoodDataAccess构造" << endl;
    }
    virtual ~GoodDataAccess() {   // ✅ 虚析构
        delete conn;
        cout << "GoodDataAccess析构" << endl;
    }
    virtual void execute(const string& sql) {
        conn->query(sql);
    }
};

class GoodUserDataAccess : public GoodDataAccess {
private:
    string* cache;
public:
    GoodUserDataAccess() : GoodDataAccess() {
        cache = new string("用户数据缓存");
        cout << "GoodUserDataAccess构造,分配了缓存" << endl;
    }
    ~GoodUserDataAccess() override {
        delete cache;
        cout << "GoodUserDataAccess析构,释放了缓存" << endl;
    }
    void execute(const string& sql) override {
        cout << "【使用缓存】" << endl;
        conn->query(sql);
    }
};

int main() {
    cout << "=== 错误示范:基类析构函数非虚 ===" << endl;
    BadDataAccess* badPtr = new BadUserDataAccess();
    badPtr->execute("SELECT * FROM users");
    delete badPtr;   // 只调用 ~BadDataAccess(),缓存泄漏!
    cout << "\n=== 正确示范:基类析构函数虚 ===" << endl;
    GoodDataAccess* goodPtr = new GoodUserDataAccess();
    goodPtr->execute("SELECT * FROM users");
    delete goodPtr;  // 先 ~GoodUserDataAccess(),再 ~GoodDataAccess()
    return 0;
}

输出:

text

=== 错误示范:基类析构函数非虚 ===
  数据库连接 1 已建立
BadDataAccess构造
BadUserDataAccess构造,分配了缓存
【使用缓存】
  连接1 执行: SELECT * FROM users
BadDataAccess析构
  数据库连接 1 已释放
                                                    ← 注意:没有释放cache!

=== 正确示范:基类析构函数虚 ===
  数据库连接 2 已建立
GoodDataAccess构造
GoodUserDataAccess构造,分配了缓存
【使用缓存】
  连接2 执行: SELECT * FROM users
GoodUserDataAccess析构,释放了缓存     ← 正确释放了!
GoodDataAccess析构
  数据库连接 2 已释放

六、抽象类中的虚析构函数

抽象类(有纯虚函数)必须提供虚析构函数,即使它是空的:

cpp

class Shape {
public:
    virtual double getArea() = 0;
    virtual ~Shape() {}   // 即使是空的,也必须写
};

为什么?

  • 派生类对象通过Shape*删除时,需要调用正确的析构函数

  • 如果不写,编译器生成的析构函数是非虚的,导致上述问题

最佳实践:任何作为基类使用的类,都应该有虚析构函数。


七、虚析构函数与“三/五法则”

回顾第4篇的三法则:如果类需要自定义析构函数,通常也需要自定义拷贝构造和拷贝赋值。

对于多态基类,这条规则有一个例外:

cpp

class PolymorphicBase {
public:
    virtual ~PolymorphicBase() = default;  // 虚析构,但使用默认实现
    // 不需要自定义拷贝构造/赋值,因为不管理资源
};

现代C++建议

  • 如果类是作为基类使用:virtual ~ClassName() = default;

  • 如果类管理资源:遵循五法则(析构、拷贝构造、拷贝赋值、移动构造、移动赋值)

  • 如果不作为基类且不管理资源:让编译器生成默认析构函数


八、三个常见错误

1. 忘记把析构函数声明为虚函数

cpp

class Base {
public:
    ~Base() {}   // 非虚
};

Base* p = new Derived();
delete p;   // 泄漏

2. 把不是基类的类析构函数设为虚函数(浪费)

cpp

class Point {   // 不会被继承
public:
    virtual ~Point() {}   // 不必要的vptr开销
    int x, y;
};
// 每个Point对象多8字节,浪费内存

3. 在析构函数中调用虚函数

cpp

class Base {
public:
    virtual ~Base() { cleanup(); }   // 调用的是Base::cleanup,不是派生类版本
    virtual void cleanup() {}
};

和构造函数一样,析构函数中虚函数不产生多态行为——派生类部分已经先析构了。


九、这一篇的收获

你现在应该理解:

  • 如果基类析构函数不是虚函数,delete基类指针时只调用基类析构,派生类资源不会被释放

  • 规则:任何作为基类多态使用的类,都应该有虚析构函数

  • 虚析构函数会引入vptr,带来8字节内存开销(但如果已有其他虚函数,没有额外开销)

  • 抽象类必须提供虚析构函数(即使是空的)

  • 虚析构函数遵循动态绑定,先调用派生类析构,再调用基类析构

💡 小作业:写一个Logger基类,有虚函数log(const string&),以及虚析构函数。实现FileLogger(写入文件)和ConsoleLogger(输出到控制台),每个派生类在析构时关闭各自的资源。通过基类指针删除对象,验证析构顺序正确。


下一篇预告:第18篇《多继承与菱形继承(一):二义性问题与虚拟继承》——C++支持一个类继承多个基类。但当两个基类有同名成员,或者出现“菱形继承”时,会出现二义性问题。虚拟继承是解决方案——但它也有自己的复杂性。下篇开始讲多继承的坑与解法。

更多推荐