news 2026/5/14 16:26:16

【c++面向对象编程】第17篇:多态(四):虚析构函数——删除派生类对象时避免内存泄漏

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【c++面向对象编程】第17篇:多态(四):虚析构函数——删除派生类对象时避免内存泄漏

目录

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

二、为什么会这样?——静态绑定 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++支持一个类继承多个基类。但当两个基类有同名成员,或者出现“菱形继承”时,会出现二义性问题。虚拟继承是解决方案——但它也有自己的复杂性。下篇开始讲多继承的坑与解法。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/14 16:15:05

在 Taotoken 模型广场中根据任务需求与预算进行模型选型的实践

&#x1f680; 告别海外账号与网络限制&#xff01;稳定直连全球优质大模型&#xff0c;限时半价接入中。 &#x1f449; 点击领取海量免费额度 在 Taotoken 模型广场中根据任务需求与预算进行模型选型的实践 对于开发者而言&#xff0c;面对众多大模型 API&#xff0c;如何选…

作者头像 李华
网站建设 2026/5/14 16:13:10

信创产品测试报告 制作使用全指南

你在处理信创项目提交材料之际&#xff0c;是否还因为正规的测试报告搜寻不到适配规格而发愁呢&#xff1f;依据最新的等保2.0以及信创终端适配需求&#xff0c;全部上架采购清单里的信创产品&#xff0c;都得拿出涵盖核心适配项的合格测试报告&#xff0c;这个材料是项目过审所…

作者头像 李华
网站建设 2026/5/14 16:12:10

电子书标准化:从数据孤岛到开放生态的技术演进与产业思考

1. 从“遥控器乱局”到“电子书混战”&#xff1a;一个老工程师的行业观察作为一名在电子行业摸爬滚打了二十多年的工程师&#xff0c;我书桌的抽屉里至今还躺着七八个不同品牌、不同年代的遥控器。它们曾经各自控制着电视、音响、DVD播放器&#xff0c;每一个都代表着一个封闭…

作者头像 李华
网站建设 2026/5/14 16:12:09

6分钟掌握专业音频分离:Demucs htdemucs_6s实战完全指南

6分钟掌握专业音频分离&#xff1a;Demucs htdemucs_6s实战完全指南 【免费下载链接】demucs Code for the paper Hybrid Spectrogram and Waveform Source Separation 项目地址: https://gitcode.com/gh_mirrors/de/demucs 你是否曾为提取音乐中的人声轨道而烦恼&#…

作者头像 李华
网站建设 2026/5/14 16:12:09

艾尔登法环帧率解锁与增强工具:告别60帧限制的完整方案

艾尔登法环帧率解锁与增强工具&#xff1a;告别60帧限制的完整方案 【免费下载链接】EldenRingFpsUnlockAndMore A small utility to remove frame rate limit, change FOV, add widescreen support and more for Elden Ring 项目地址: https://gitcode.com/gh_mirrors/el/El…

作者头像 李华