Effective C++ 条款07:为多态基类声明 virtual 析构函数
在 C++ 的多态体系中,基类指针指向派生类对象是一种常见的设计模式。但如果基类的析构函数不是 virtual 的,删除这个指针时可能会引发灾难性的后果。今天我们来深入剖析这个问题。
一、问题的引入
假设我们有一个表示时间记录的基类:
classTimeKeeper{public:TimeKeeper(){}~TimeKeeper(){std::cout<<"TimeKeeper destructor\n";}virtualvoidrecordTime()=0;};classAtomicClock:publicTimeKeeper{public:AtomicClock(){data_=newchar[1024];}~AtomicClock(){delete[]data_;std::cout<<"AtomicClock destructor\n";}voidrecordTime()override{/* ... */}private:char*data_;};现在,我们通过工厂函数获取一个对象:
TimeKeeper*getTimeKeeper(){returnnewAtomicClock();}然后在使用完毕后删除它:
TimeKeeper*ptk=getTimeKeeper();// 使用 ptk...deleteptk;// 危险!会发生什么?
输出:TimeKeeper destructor注意:只有基类的析构函数被调用了!派生类AtomicClock的析构函数完全没有执行,导致data_指向的内存泄漏了。
二、原理分析:为什么非 virtual 析构函数会导致局部销毁?
2.1 虚函数与动态绑定
C++ 的多态性依赖于**虚函数表(vtable)**机制。当一个类声明了 virtual 函数时:
| 特性 | 说明 |
|---|---|
| 虚函数表 | 编译器为该类生成一个 vtable,存储所有虚函数的地址 |
| 虚指针 | 每个对象包含一个隐藏的 vptr 指针,指向对应的 vtable |
| 动态绑定 | 通过 vptr 在运行时确定调用哪个函数版本 |
2.2 析构函数的调用链
当析构函数是 virtual 时:
classTimeKeeper{public:virtual~TimeKeeper(){/* ... */}// 注意 virtual};delete ptk的执行过程:
- 通过
ptk的 vptr 找到 vtable - vtable 中指向
~AtomicClock() - 执行
~AtomicClock()—— 释放data_ - 自动调用
~TimeKeeper()—— 释放基类部分
2.3 非 virtual 析构函数的静态绑定
如果析构函数不是 virtual:
- 编译器根据指针的静态类型(
TimeKeeper*)决定调用哪个析构函数 - 直接调用
TimeKeeper::~TimeKeeper() AtomicClock::~AtomicClock()永远不会被调用
内存布局示意: [ AtomicClock 对象 ] +------------------+ | TimeKeeper 部分 | <-- ptk 指向这里 +------------------+ | AtomicClock 数据 | <-- 这部分永远不会被析构! | (data_ 等) | +------------------+三、解决方案:virtual 析构函数
将基类的析构函数声明为 virtual:
classTimeKeeper{public:TimeKeeper(){}virtual~TimeKeeper(){std::cout<<"TimeKeeper destructor\n";}virtualvoidrecordTime()=0;};现在重新运行:
TimeKeeper*ptk=getTimeKeeper();deleteptk;输出:
AtomicClock destructor TimeKeeper destructor完美!派生类的资源被正确释放,然后基类部分也被正确释放。
四、规则与例外
4.1 核心规则
如果 class 带有任何 virtual 函数,它就应该拥有一个 virtual 析构函数。
原因很直接:
- 带有 virtual 函数的类,设计意图就是被当作基类使用
- 被当作基类使用,就意味着可能通过基类指针删除派生类对象
- 因此必须保证析构时的多态行为
4.2 反面规则
如果 class 不含 virtual 函数,通常并不意图被用来做 base class,那么就不要声明 virtual 析构函数。
为什么?因为 virtual 析构函数有代价:
| 代价 | 说明 |
|---|---|
| 额外内存开销 | 每个对象需要存储 vptr(通常 4 或 8 字节) |
| 无法内联优化 | 析构调用需要通过 vtable 间接寻址 |
| 无法与其他语言互操作 | 如 C 语言无法直接使用带 vptr 的对象 |
4.3 一个常见的陷阱:std::string 和 STL 容器
classSpecialString:publicstd::string{// ...};std::string*ps=newSpecialString("Hello");deleteps;// 未定义行为!std::string 的析构函数不是 virtualSTL 容器类(string、vector、list 等)的析构函数都不是 virtual 的,因此绝不应该继承它们!
如果你需要扩展 STL 容器的功能,应该使用**组合(composition)**而不是继承:
classSpecialString{private:std::string data_;// 组合,而非继承public:// 提供你需要的额外接口};五、纯虚析构函数:抽象基类的技巧
有时候,你需要一个纯抽象基类(所有函数都是纯虚函数),但仍然希望它有 virtual 析构函数。这时可以使用纯虚析构函数:
classAWOV{// Abstract WithOut Virtual (non-pure virtual functions)public:virtualvoidinterface()=0;virtual~AWOV()=0;// 纯虚析构函数};// 必须提供定义!AWOV::~AWOV(){}为什么纯虚析构函数需要定义?
因为析构函数的调用链中,派生类析构完成后会自动调用基类析构函数。如果基类析构函数没有定义,链接器会报错。
classDerived:publicAWOV{public:voidinterface()override{}~Derived(){/* ... */}};// Derived 析构时:// 1. 执行 ~Derived()// 2. 自动调用 ~AWOV() <-- 必须有定义!六、实际应用场景
6.1 插件系统中的接口基类
classIPlugin{public:virtualvoidinitialize()=0;virtualvoidexecute()=0;virtualvoidshutdown()=0;virtual~IPlugin()=default;// virtual 析构函数!};classImageProcessor:publicIPlugin{public:voidinitialize()override{buffer_=newchar[4096];}voidexecute()override{/* ... */}voidshutdown()override{/* ... */}~ImageProcessor(){delete[]buffer_;}private:char*buffer_;};// 插件管理器classPluginManager{std::vector<IPlugin*>plugins_;public:voidunloadAll(){for(auto*p:plugins_){deletep;// 安全!会正确调用派生类析构函数}plugins_.clear();}};6.2 游戏引擎中的组件系统
classComponent{public:virtualvoidupdate(floatdeltaTime)=0;virtualvoidrender()=0;virtual~Component()=default;};classPhysicsComponent:publicComponent{public:voidupdate(floatdeltaTime)override{/* ... */}voidrender()override{}~PhysicsComponent(){// 清理物理引擎中的刚体引用PhysicsEngine::removeBody(body_);}private:Body*body_;};classGameObject{std::vector<Component*>components_;public:~GameObject(){for(auto*c:components_){deletec;// 正确析构每个组件}}};6.3 工厂模式中的产品基类
classProduct{public:virtualvoiduse()=0;virtual~Product()=default;};classConcreteProductA:publicProduct{public:voiduse()override{/* ... */}~ConcreteProductA(){/* 清理资源 A */}};classFactory{public:staticProduct*createProduct(conststd::string&type){if(type=="A")returnnewConcreteProductA();// ...returnnullptr;}};// 使用Product*p=Factory::createProduct("A");// ...deletep;// 安全七、C++11 及以后的补充
7.1 override 关键字
C++11 引入的override关键字可以帮助我们发现虚函数相关的错误:
classBase{public:virtual~Base()=default;virtualvoidfoo(){}};classDerived:publicBase{public:voidfoo()override{}// 明确标记这是重写// void bar() override; // 编译错误!Base 中没有 bar()};7.2 final 关键字
如果你不希望某个类被继承,可以使用final:
classFinalClassfinal{// 禁止继承public:~FinalClass()=default;// 不需要 virtual};// class Derived : public FinalClass {}; // 编译错误!这样就不需要担心析构函数是否应该是 virtual 的了。
八、总结
| 场景 | 析构函数建议 | 原因 |
|---|---|---|
| 类有 virtual 函数,意图作为基类 | 必须 virtual | 通过基类指针删除时保证完整析构 |
| 类没有 virtual 函数,不意图作为基类 | 不要 virtual | 避免 vptr 开销 |
| 纯抽象基类 | 纯虚析构函数 | 既保持抽象性,又保证正确析构 |
| 类不希望被继承 | 使用 final | C++11 最佳实践 |
请记住:
- 带有多态性质的基类应该声明 virtual 析构函数。
- 如果 class 带有任何 virtual 函数,它就应该拥有一个 virtual 析构函数。
- 如果 class 不是设计来做基类的,就不要声明 virtual 析构函数。
- 不要继承没有 virtual 析构函数的类(如 STL 容器)。
一个virtual关键字的缺失,可能导致内存泄漏、资源未释放,甚至程序崩溃。在多态设计中,virtual 析构函数不是可选项,而是必选项。
参考阅读:
- 《Effective C++》第三版,Scott Meyers
- 《C++ Primer》第五版,关于虚函数和动态绑定的章节
- C++ Core Guidelines: C.35