第7条:将多态基类的析构函数声明为虚函数

本文详细解析了C++类中虚析构器的重要性及其使用场景,包括多态基类的虚析构器声明、避免析构问题的方法、纯虚析构器的使用以及不同类型类(如字符串、容器)的析构规则。通过实例讲解,帮助开发者正确理解和运用虚析构器,避免常见错误。
考虑一个计时器,我们首先创建一个TimeKeeper 基类,然后在它的基础上创建各种派生类,用于不同方式的计时。
 
class TimeKeeper {
public:
 TimeKeeper();
 ~TimeKeeper();
  ...
};  
 

class AtomicClock: public TimeKeeper { ...}; //原子钟
class WaterClock: public TimeKeeper { ...};  //水钟
class WristWatch: public TimeKeeper { ...};  //腕表

许多程序员希望在访问时间的同时,不用关心时间计算的细节。这时我们设计一个factory(工厂)函数来返回一个指向计时器对象的指针。该函数返回的是一个基类指针,指向一个新创建的派生类对象:
 
TimeKeeper* getTimeKeeper();
// 返回一个指针,指向TimeKeeper 派生类动态分配的对象

为了不破坏工厂函数的惯例,getTimeKeeper() 返回的对象位于堆(heap)中。所以为了避免内存或者其他资源发生泄漏,须将函数返回的每个对象适当的delete掉:
TimeKeeper *ptk = getTimeKeeper();//从 TimeKeeper 继承体系获得一个动态分配的对象
... //使用对象
delete ptk; //释放它,以防资源泄漏

把delete工作推给客户不是一个好的做法, 第 13 条 中解释了这一点。关于如何修改工厂函数接口从而防止常见的客户端错误发生,请参见第 18 条。但是这些议题在此都不是主要的,这一条中我们主要讨论的是上述代码中存在着更为基本的弱点:即使客户端程序员把每一件事都做对了,仍无法预知程序会产生怎样的行为。
 
现在的问题是:getTimeKeeper 返回一个指向其派生类对象的指针(比如说 AtomicClock ),而这个对象却通过一个基类的指针得到删除(比如说一个 TimeKeeper* 指针),而基类( TimeKeeper )有一个非虚拟的析构器。
这里埋藏着隐患,因为 C++规定:当一个派生类对象通过基类的指针来删除,并且这一基类有一个non-virtual的析构器,此时的结果是不可预知的------通常情况下在运行时,派生类中新派生出的部分得不到销毁。如果getTimeKeeper 返回了一个指向AtomicClock 对象的指针,这一对象中派生出的部分(也就是 AtomicClock 类的数据成员)有可能没被销毁, AtomicClock 的析构器也可能不会得到运行。然而,这一对象中base class成分(也就是 TimeKeeper 这一部分)通常会被销毁,这样便会产生一个“局部销毁”对象。用这种方法会造成泄漏资源、破坏数据结构、浪费调试时间等后果。
 
排除这一问题的方法很简单:给基类一个虚拟的析构器。这时删除一个派生类对象,程序就会精确地按你的需要运行了。整个对象都会得到销毁,包括所有新派生的部分:
 
class TimeKeeper {
public:
 TimeKeeper();
 virtual ~TimeKeeper();
  ...
};
TimeKeeper *ptk = getTimeKeeper();
...
delete ptk; //现在,程序正常运行


通常,像TimeKeeper 这样的基类会包含除析构器以外的虚函数,这是因为虚函数的目的是允许派生类实现它们的个性化(参见第 34 条)。比如说,对于TimeKeeper 类中的getCurrentTime 函数来说,它在不同的派生类中有可能有不同的实现方式,必须要将其声明为虚函数。任何有虚函数的类几乎都要包含一个虚析构器
 
如果一个类不包含虚函数,通常情况下意味着它将不作为基类使用。当一个类不作为基类时,将它的析构其声明为虚拟的通常是个坏主意。请下示例,这个类代表的是二维空间中的点:

class Point {  //2D 的点
public:
 Point(int xCoord, int yCoord);
 ~Point();  
private:
  intx, y;
};

如果一个 int 占用 32 位,一个 Point 对象便适合置入一个 64 位的寄存器中。而且,这样的一个 Point 对象可以以一 个 64 位数值的形式传给其他语言编写的函数,比如 C 或者 FORTRAN 。然而如果 Point 的析构器是虚拟的,那么就是另一种情况了。
 
虚函数的实现需要它所在的对象包含额外的信息,主要用来确定本对象运行时需要调用哪个虚函数。通常,这一信息采取一个指针的形式,这个指针被称为“ vptr ”(“vitualtable pointer”)。 vptr 指向一个同函数指针组成的数组,这一数组称为“ vtbl ”(“virtual table”),每个包含虚函数的类都有一个与之相关的 vtbl 。当一个虚函数被一个对象调用时,就用到了该对象的 vptr 所指向的 vtbl ,在 vtbl 中查找一个合适的函数指针,然后调用相应的实函数。
 
虚函数实现细节并不重要。重要的是,如果 Point 类包含一个虚函数,这一类型的对象将会变大。在一个 32 位的架构中, Point 对象将会由 64 位 (两个 int 大小)增长至 96 位(两个 int 加一个 vptr(指针为32位) );在 64 位架构中, Point 对象将由 64 位 增长至 128 位 。这是因为指向 64 位架构的指针有 64 位 大小。因此为 Point 添加一个 vptr 将会使其变 大 50-100% !这样一个 64 位的 寄存器便容不下一个 Point 对象。此外, C++ 中的 Point 对象也不再与其它语言(比 如 C 语 言)有同样的结构。(其它语言很可能没有 vptr)。除非你显式增加一个vptr 的等价物---但这是属于实现细节,且不具备可移植性,否则 Point 对象便无法与其它语言编写的函数互通。

 
综上,无故将所有的析构器声明为虚拟的,与从未声明它们为虚函数一样糟糕,这一点最为重要。许多人总结出一条解决途径:只有当类中至少包含一个虚函数时,要声明一个虚析构器。
 
甚至在完全没有虚函数的类里,也可能会被non-virtual的构造器所困惑。比如说,标准的 string 类不包含虚函数,但是有程序员错误地将其作为基类:

 
class SpecialString: public std::string {
//这不是个好主意!
//std::string 有一个非虚拟的析构器
  ...
};
 
乍一看上面代码似乎没问题,但是如果在应用时,无意间将一个指向 SpecialString 的指针转型为指向 string 的指针,然后你又对这个 string 指针使用了 delete ,你的程序会立刻陷入无法预知的状态:
 
SpecialString *pss = newSpecialString("Impending Doom");
std::string *ps;
...  
ps = pss;       //SpecialString*-> std::string*
...
delete ps;
//未定义!实际上 *ps 的SpecialString这一部分资源会泄漏,
//因为 SpecialString 的析构器没有被调用


相同的分析适用于任何不带virtual析构函数的 class,包括所有的 STL 容器类型(如 vector 、 list 、 set 、 tr1::unordered_map (参见第 54 条),等等)。如果你试图继承一个标准容器或者其他任何包含非虚析构器的类,请打消念头。(遗憾的是, C++ 没有提供类似 Java 中的 final 类或 C# 中的 sealed 类那种“禁止派生”机制)
 
有时候令class带一个纯虚析构器(pure virtual)十分方便的。我们可以回忆一下,纯虚函数会使所在的类变成抽象类——这种类不可以实例化(也就是说,你无法创建这种类型的对象)。然而有时你希望一个类成为抽象类,但是你有没有任何纯虚函数,这时候要怎么办呢?因为抽象类应该作为基类来使用,而基类应该有虚析构器,又因为纯虚函数可以造就一个抽象类,那么解决方案就显而易见了:如果你希望一个类成为一个抽象类,那么在其中声明一个纯虚析构器。下边是示例:

 
class AWOV {    //AWOV = "Abstract w/o Virtuals"
public:
 virtual ~AWOV() = 0;  //声明纯虚析构器
};
 
这个类有一个纯虚析函数,所以它是一个抽象类,同时它拥有一个虚析构器,所以你不需要担心析构器出现问题。然而这里有个窍门:必须为纯虚析构器提供一个定义
 
AWOV::~AWOV() {}    //纯虚析构器的定义
 
析构器的运作方式:首先调用最后派生出的类的析构器,然后依次调用上一层基类的析构器。由于当调用一个 AWOV 的派生类的析构器时,编译器会自动调用 ~AWOV ,因此你必须为 ~AWOV 提供一个函数体。否则连接器将会报错。
 
“为基类提供虚析构器”的原则仅对多态基类(这种基类的设计目的是为了用来“通过基类接口处理派生类对象”)有效。我们说TimeKeeper 是一个多态基类,这是由于即使我们手头只有 TimeKeeper 指向它们的指针,我们仍可以对 AtomicClock 和 WaterClock 进行操控。
 
并非所有基类的设计目的都是为了多态性。标准 string 类型、 STL 容器都不用作基类,因此它们都不具备多态性。另外有一些类是设计用作基类的,但是它们并未被设计成多态类。这些类(例如第 6 条中的 Uncopyable 和标准类中的 input_iterator_tag (第 47 条))不允许通过其接口来操控它的派生类。因此,它们并不需要虚析构器。
 
需要记住的
  1.应该为多态基类声明虚析构器。如果一个class带有任何virtual函数,它就应该包含一个虚析构器。
  2.如果一个类不用作基类或者不需具有多态性,便不应为它声明虚析构器
<think>好的,我现在需要帮助用户理解为什么基类析构函数必须声明虚函数,以及它在多态和内存管理中的作用。用户已经提供了一些引用内容,我需要结合这些引用来组织一个清晰且详细的回答。 首先,我要回忆一下用户之前的问题和我的回答。用户之前询问了虚函数表的位置、多态的底层逻辑,以及模板的使用。现在他们聚焦在析构函数虚函数声明上,特别是基类的情况。根据引用,用户提供的资料提到了几个关键点: 1. 虚析构函数确保通过基类指针删除派生类对象时,正确调用派生类的析构函数,避免资源泄漏。 2. 构造函数不能是虚函数,而析构函数可以且应该声明虚函数。 3. 纯虚析构函数需要有定义,否则可能导致运行时错误。 接下来,我需要将这些点整合成一个逻辑连贯的回答。首先,解释为什么基类析构函数需要是虚函数。这里的关键是多态中的对象销毁问题。当使用基类指针指向派生类对象时,如果基类析构函数不是虚的,那么删除该指针只会调用基类析构函数,而不会调用派生类的,导致派生类分配的资源未被释放,引发内存泄漏。 然后,需要说明内存泄漏的具体例子。比如,基类可能有动态分配的资源,如果析构函数不是虚的,派生类中的资源不会被正确清理。这里可以举一个具体的代码示例,对比虚构和非虚构的不同行为。 接着,讨论纯虚析构函数的情况。根据引用[3],纯虚析构函数必须要有定义,否则会导致链接错误或运行时崩溃。这是因为即使析构函数是纯虚的,派生类构时仍需要调用基类析构函数,所以必须提供实现。 然后,引用[4]提到构造函数不能是虚函数,因为对象构造的顺序是从基类到派生类,虚函数机制在对象构造期间尚未建立。这可以作为一个对比点,帮助用户理解析构函数和构造函数在虚函数机制中的不同。 最后,总结基类析构函数声明为虚的重要性,强调其在多态中的必要性,以及如何正确使用虚析构函数来确保资源安全释放。同时,可以提到一些设计原则,比如如果一个类被设计为基类(尤其是多态基类),其析构函数必须为虚函数,即使它没有其他虚函数。 在组织回答时,需要确保结构清晰,分点说明,每个部分都结合引用中的信息,并给出代码示例和对比,帮助用户直观理解。同时,要注意使用用户提到的引用编号,如[^1]、[^3]等,以增强回答的可信度。 可能还需要考虑用户的知识水平,避免过于技术化的术语,或者适当解释术语,比如虚函数表、多态、内存泄漏等,确保用户能够理解。此外,用户可能对纯虚析构函数的实现感到困惑,需要详细说明为什么纯虚析构函数需要定义,而其他纯虚函数不需要。 最后,检查是否有遗漏的关键点,比如多重继承下的析构函数处理,但根据用户的问题和引用内容,这可能不是当前的重点,可以暂时不涉及,保持回答的针对性。</think>### 基类析构函数必须声明虚函数的原因及重要性 #### 一、核心问题:多态对象销毁时的资源泄漏 当通过**基类指针**删除**派生类对象**时,若基类析构函数非虚,会导致**仅调用基类析构函数**,而**派生类的析构函数不会执行**,进而引发资源泄漏。这是多态内存管理的核心陷阱[^1][^4]。 **关键验证示例**: ```cpp class Base { public: ~Base() { cout << "~Base"; } // 非虚析构函数 }; class Derived : public Base { public: int* buffer = new int[1024]; // 动态分配资源 ~Derived() { delete[] buffer; // 资源释放 cout << "~Derived"; } }; int main() { Base* obj = new Derived(); delete obj; // 仅输出 ~Base → Derived的buffer泄漏! } ``` 输出结果:`~Base` **后果**:Derived类中的`buffer`未被释放,造成内存泄漏[^1][^3] --- #### 二、解决方案:虚析构函数的作用 将基类析构函数声明为`virtual`,确保**动态绑定**正确发生: ```cpp class Base { public: virtual ~Base() { ... } // 虚析构函数 }; ``` 此时执行`delete obj`的完整过程: 1. 通过虚表定位到`Derived::~Derived()` 2. 执行派生类析构函数 → 释放`buffer` 3. **自动调用基类析构函数**(通过析构函数链式调用机制) 输出结果:`~Derived ~Base` 资源得到完整释放[^1][^4] --- #### 三、技术原理深度解 1. **虚函数调用机制** - 虚析构函数与其他虚函数共享**虚函数表**机制 - 构时通过虚表指针(vptr)找到正确的析构函数实现[^3] 2. **析构函数链式调用** ```cpp // 编译器自动生成的构调用顺序 Derived::~Derived() { // 用户编写的构代码 delete[] buffer; // 编译器插入基类构调用 Base::~Base(); } ``` **关键特性**:即使基类析构函数是纯虚的,也必须提供实现(否则链接错误)[^3] --- #### 四、设计原则与最佳实践 1. **黄金法则** - 任何**可能被继承的类**(尤其是多态基类),必须声明析构函数[^4] - 即使基类没有其他虚函数,只要可能通过基类指针删除对象,就必须遵守此规则 2. **纯虚析构函数特殊情况** ```cpp class AbstractBase { public: virtual ~AbstractBase() = 0; // 纯虚声明 }; // 必须提供实现(否则派生类构时会崩溃) AbstractBase::~AbstractBase() {} ``` **意义**:强制要求派生类实现构逻辑,同时保证基类构可执行 3. **错误场景对比** | 基类构类型 | 行为表现 | 内存安全 | |--------------|----------------------------|----------| | 非虚 | 仅调用基类构 | ❌ 泄漏 | | 虚 | 完整调用派生→基类构 | ✅ 安全 | | 纯虚未实现 | 编译/链接错误或运行时崩溃 | ❌ 危险 | --- #### 五、底层实现视角 1. **虚析构函数的特殊处理** - 编译器会为虚析构函数生成**两个版本**: - `Derived::~Derived()`(实际执行构) - `Derived::_delete()`(处理operator delete的包装函数) 2. **对象销毁过程** ```mermaid graph TD A[delete基类指针] --> B[通过虚表定位派生类析构函数] B --> C[执行派生类析构函数体] C --> D[自动调用基类析构函数] D --> E[释放对象内存] ``` --- ### 总结:必须使用虚析构函数的场景 1. **多态基类**:任何通过基类指针操作派生类对象的场景 2. **资源管理类**:涉及文件句柄、网络连接、内存管理等资源的类层次结构 3. **接口类**:包含纯虚函数的抽象基类(需同时声明纯虚构并提供实现) **最终建议**:在C++核心指南中,明确建议将多态基类析构函数声明为`virtual`,这是编写安全面向对象程序的基础要求[^1][^4]。 ### 相关问题 1. 如何检测程序中是否存在非虚析构函数导致的内存泄漏? 2. 多重继承场景下虚析构函数的工作机制有何不同? 3. C++中的`= default`语法对虚析构函数有什么影响? 4. 智能指针(如`std::unique_ptr`)如何与虚析构函数配合使用?
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值