【c++面向对象编程】第17篇:多态(四):虚析构函数——删除派生类对象时避免内存泄漏
目录
一、一个会泄漏内存的程序
先看这段代码,猜猜会发生什么:
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*删除对象时:
-
从对象中取出vptr
-
从vtable中取第0个函数(析构函数地址)
-
调用该函数——由于派生类覆盖了这个槽位,调用的是
Derived::~Derived() -
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++支持一个类继承多个基类。但当两个基类有同名成员,或者出现“菱形继承”时,会出现二义性问题。虚拟继承是解决方案——但它也有自己的复杂性。下篇开始讲多继承的坑与解法。
更多推荐
所有评论(0)