Effective C++ 条款07:为多态基类声明 virtual 析构函数
在 C++ 的多态体系中,基类指针指向派生类对象是一种常见的设计模式。但如果基类的析构函数不是 virtual 的,删除这个指针时可能会引发灾难性的后果。今天我们来深入剖析这个问题。
一、问题的引入
假设我们有一个表示时间记录的基类:
cpp
class TimeKeeper {
public:
TimeKeeper() {}
~TimeKeeper() { std::cout << "TimeKeeper destructor\n"; }
virtual void recordTime() = 0;
};
class AtomicClock : public TimeKeeper {
public:
AtomicClock() { data_ = new char[1024]; }
~AtomicClock() {
delete[] data_;
std::cout << "AtomicClock destructor\n";
}
void recordTime() override { /* ... */ }
private:
char* data_;
};
现在,我们通过工厂函数获取一个对象:
cpp
TimeKeeper* getTimeKeeper() {
return new AtomicClock();
}
然后在使用完毕后删除它:
cpp
TimeKeeper* ptk = getTimeKeeper();
// 使用 ptk...
delete ptk; // 危险!
会发生什么?
输出:TimeKeeper destructor
注意:只有基类的析构函数被调用了! 派生类 AtomicClock 的析构函数完全没有执行,导致 data_ 指向的内存泄漏了。
二、原理分析:为什么非 virtual 析构函数会导致局部销毁?
2.1 虚函数与动态绑定
C++ 的多态性依赖于**虚函数表(vtable)**机制。当一个类声明了 virtual 函数时:
| 特性 | 说明 |
|---|---|
| 虚函数表 | 编译器为该类生成一个 vtable,存储所有虚函数的地址 |
| 虚指针 | 每个对象包含一个隐藏的 vptr 指针,指向对应的 vtable |
| 动态绑定 | 通过 vptr 在运行时确定调用哪个函数版本 |
2.2 析构函数的调用链
当析构函数是 virtual 时:
cpp
class TimeKeeper {
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:
cpp
class TimeKeeper {
public:
TimeKeeper() {}
virtual ~TimeKeeper() { std::cout << "TimeKeeper destructor\n"; }
virtual void recordTime() = 0;
};
现在重新运行:
cpp
TimeKeeper* ptk = getTimeKeeper();
delete ptk;
输出:
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 容器
cpp
class SpecialString : public std::string {
// ...
};
std::string* ps = new SpecialString("Hello");
delete ps; // 未定义行为!std::string 的析构函数不是 virtual
STL 容器类(string、vector、list 等)的析构函数都不是 virtual 的,因此绝不应该继承它们!
如果你需要扩展 STL 容器的功能,应该使用**组合(composition)**而不是继承:
cpp
class SpecialString {
private:
std::string data_; // 组合,而非继承
public:
// 提供你需要的额外接口
};
五、纯虚析构函数:抽象基类的技巧
有时候,你需要一个纯抽象基类 (所有函数都是纯虚函数),但仍然希望它有 virtual 析构函数。这时可以使用纯虚析构函数:
cpp
class AWOV { // Abstract WithOut Virtual (non-pure virtual functions)
public:
virtual void interface() = 0;
virtual ~AWOV() = 0; // 纯虚析构函数
};
// 必须提供定义!
AWOV::~AWOV() {}
为什么纯虚析构函数需要定义?
因为析构函数的调用链中,派生类析构完成后会自动调用基类析构函数。如果基类析构函数没有定义,链接器会报错。
cpp
class Derived : public AWOV {
public:
void interface() override {}
~Derived() { /* ... */ }
};
// Derived 析构时:
// 1. 执行 ~Derived()
// 2. 自动调用 ~AWOV() <-- 必须有定义!
六、实际应用场景
6.1 插件系统中的接口基类
cpp
class IPlugin {
public:
virtual void initialize() = 0;
virtual void execute() = 0;
virtual void shutdown() = 0;
virtual ~IPlugin() = default; // virtual 析构函数!
};
class ImageProcessor : public IPlugin {
public:
void initialize() override { buffer_ = new char[4096]; }
void execute() override { /* ... */ }
void shutdown() override { /* ... */ }
~ImageProcessor() { delete[] buffer_; }
private:
char* buffer_;
};
// 插件管理器
class PluginManager {
std::vector<IPlugin*> plugins_;
public:
void unloadAll() {
for (auto* p : plugins_) {
delete p; // 安全!会正确调用派生类析构函数
}
plugins_.clear();
}
};
6.2 游戏引擎中的组件系统
cpp
class Component {
public:
virtual void update(float deltaTime) = 0;
virtual void render() = 0;
virtual ~Component() = default;
};
class PhysicsComponent : public Component {
public:
void update(float deltaTime) override { /* ... */ }
void render() override {}
~PhysicsComponent() {
// 清理物理引擎中的刚体引用
PhysicsEngine::removeBody(body_);
}
private:
Body* body_;
};
class GameObject {
std::vector<Component*> components_;
public:
~GameObject() {
for (auto* c : components_) {
delete c; // 正确析构每个组件
}
}
};
6.3 工厂模式中的产品基类
cpp
class Product {
public:
virtual void use() = 0;
virtual ~Product() = default;
};
class ConcreteProductA : public Product {
public:
void use() override { /* ... */ }
~ConcreteProductA() { /* 清理资源 A */ }
};
class Factory {
public:
static Product* createProduct(const std::string& type) {
if (type == "A") return new ConcreteProductA();
// ...
return nullptr;
}
};
// 使用
Product* p = Factory::createProduct("A");
// ...
delete p; // 安全
七、C++11 及以后的补充
7.1 override 关键字
C++11 引入的 override 关键字可以帮助我们发现虚函数相关的错误:
cpp
class Base {
public:
virtual ~Base() = default;
virtual void foo() {}
};
class Derived : public Base {
public:
void foo() override {} // 明确标记这是重写
// void bar() override; // 编译错误!Base 中没有 bar()
};
7.2 final 关键字
如果你不希望某个类被继承,可以使用 final:
cpp
class FinalClass final { // 禁止继承
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