当年发生了什么(A)
那会儿大家写 C。
资源都是手动管的。
你开了文件,就得记得关。
你 malloc 了内存,就得记得 free。
你写一个库,别人写一个插件。
接口靠函数指针和约定撑着。
能跑,也挺灵活。
直到某天你发现:对象“怎么销毁”,也必须算进接口里。
不写清楚,出事的位置往往是最无辜的那一行。
比如那行 delete。
没有它之前,我们怎么凑合
很多团队一开始会有个朴素习惯:只要我手里拿的是 Base*,我就 delete 它。
放在“不是多态”的世界里,这个习惯几乎从不出错。
因为 delete 看见什么类型,就按那个类型来收尾。
可一旦你开始写接口类、开始让别人继承它,delete 的那一刻就变成了一个赌局。
你删的到底是“完整对象”,还是只删掉了“基类那一层”,靠肉眼根本看不出来。
而在 C++ 里,资源释放往往就在析构函数里,这两种结果差得非常远。
坑是怎么出现的(先复现)
#include <cstdio>
struct Base {
~Base() { std::puts("~Base"); }
};
struct Derived : Base {
std::FILE* f = std::fopen("a.txt", "w");
~Derived() {
std::puts("~Derived");
if (f) std::fclose(f);
}
};
int main() {
Base* p = new Derived();
delete p;
}
这段代码的“常见现象”是:你只看到了 ~Base,看不到 ~Derived,文件句柄也就没被关上。
更反直觉的是:代码看起来完全合理,你甚至可能在本机跑十次都“没出事”。
但从语言规则上说,这其实是 UB(未定义行为):也就是“C++ 标准不承诺会怎样”。
很多人会误以为这是“只是漏调了派生类析构”,但在跨模块、带自定义内存分配器(人话:new/delete 背后那套分配策略不是默认那套)的工程里,它可能表现得更凶。
当年的人怎么想(把新名词掰开揉碎)
delete p 做两件事:先调用析构函数,再把内存还回去。
真正要命的是第一步:它到底调用哪个析构函数。
如果你通过 Base* 删除对象,而 Base 的析构函数不是 virtual,语言不保证会把调用分发到 Derived 那份收尾逻辑上。
你以为自己是在“销毁对象”,实际可能只是“销毁了它的基类部分”。
virtual 的“人话意思”是:同一个调用点,运行时要根据对象真实类型去找实现。
析构函数你可以把它理解成“对象收尾那段代码”:关文件、释放内存、归还锁,通常都写在这儿。
所以你想通过 Base* 正确销毁一个 Derived,就得让 Base 的析构函数也参与这次分发(也就是常说的动态分发)。
从坑里爬出来:两种修法
struct Base {
virtual ~Base() = default;
};
struct Derived : Base {
~Derived() override {
// 这里的清理逻辑,一定会在 delete Base* 时被执行
}
};
这是最常见、也最稳的一种:只要你可能写出 Base* p = new Derived; delete p;,那 Base 就应该有虚析构。
多数时候写成 virtual ~Base() = default; 就够了:你不用手写任何清理逻辑,但这条销毁路径会走动态分发,最后一定能落到 Derived 的收尾上。
struct Base {
protected:
~Base() = default;
};
这是另一种方向:如果你根本不希望外部代码写 delete base_ptr;,就把析构函数设成 protected,让调用方在编译期就被拦住。
这样“销毁权”只存在于框架/工厂内部,调用方不得不走你提供的释放接口,而不是各自凭感觉 delete。
一句话记住(A)
接口类如果会被通过 Base* 删除,就写 virtual ~Base() = default;。
如果你不允许外部删除,就把析构函数设成 protected。
留一个亮点
很多线上事故表面看是“析构没写 virtual”。
但本质往往是责任边界不清:到底谁拥有对象,谁负责销毁,销毁要不要跨模块。
你在代码里看见的只是一个 delete,背后却是一个所有权协议。
你把析构函数当成接口的一部分之后,类型系统会逼着你把这条协议说清楚。 要么允许外部 delete,并保证链式析构正确;要么禁止外部 delete,把销毁权收回框架。 所以那一行虚析构看起来啰嗦,其实是在替你把约定写进编译器能检查的地方。