Item9–绝不在构造和析构过程中调用虚函数

1.绝不在构造和析构过程中调用虚函数

1. 为什么会有这个规定?(底层原理)

要理解这个问题,必须了解 C++ 对象的构造顺序

  1. 基类构造: 首先调用基类的构造函数。

  2. 派生类成员初始化: 初始化派生类的成员变量。

  3. 派生类构造: 最后执行派生类的构造函数体。

关键点在于第 1 阶段: 当基类构造函数正在执行时,派生类的成员变量还没有被初始化。如果此时 C++ 允许你在基类构造函数中调用派生类的虚函数,而这个派生类函数又去访问它自己的成员变量,就会导致访问未初始化的内存,引发灾难性的未定义行为。

为了防止这种情况,C++ 编译器采取了一种“保护措施”:

在基类构造期间,C++ 视该对象为“基类对象”,而不是“派生类对象”。

这意味着:

  • 运行时类型信息 (RTTI): typeiddynamic_cast 会认为这就是一个基类对象。

  • 虚函数机制: 虚函数表指针 (vptr) 指向基类的虚函数表 (vtable)。因此,调用虚函数时,解析到的是基类的版本,而不是派生类的版本。

2. 底层原理:虚函数表指针(vptr)的“变身”过程

你在文中提到的“关键点”在于vptr(虚函数表指针)在构造过程中的动态变化

当咱们写下 Derived d; 时,内存里发生了这三件事(按时间顺序):

阶段一:进入基类构造函数 (Base::Base())

  1. 内存分配Derived 对象所需的全部内存(基类部分+派生类部分)已经分配好了,但全是生肉(Raw Memory),里面是垃圾值。

  2. vptr 初始化(关键)

    • 编译器会在 Base 构造函数的最开始,悄悄插入代码,将对象的 vptr 指向 Base 的虚函数表(vtable)

  3. 执行代码

    • 此时,如果你调用虚函数 func(),程序通过 vptr 查找,找到的是 Base::func()

    • 此时对象认为自己就是 Base 类型typeiddynamic_cast 都会证实这一点。

阶段二:基类构造结束,准备构造派生类

  • 此时基类的成员变量初始化完毕,基类部分“由生变熟”。

阶段三:进入派生类构造函数 (Derived::Derived())

  1. vptr 更新(关键)

    • 编译器在 Derived 构造函数的最开始,再次悄悄插入代码,将 同一个 vptr 重新指向 Derived 的虚函数表

  2. 成员初始化:初始化 Derived 的成员变量。

  3. 执行代码

    • 此时再调用 func(),通过新的 vptr 查到的就是 Derived::func() 了。

第一阶段:正在执行 Base 的构造函数时

(关键时刻:C++ 编译器强制“降级”身份)

此时,程序刚分配好内存,进入 Base::Base()。虽然我们最终想要的是一个 Derived 对象,但在这一刻,它暂时只是一个 Base 对象。

Plaintext

       [ 对象的内存布局 ]                    [ 全局只读数据区 ]
  +--------------------------+           +---------------------+
  |  vptr (虚表指针)          |---------> |    Base::vtable     |  <-- 重点 1
  +--------------------------+           +---------------------+
  |                          |           |  &Base::func        |
  |  Base 成员变量            |           +---------------------+
  |  (✅ 已初始化)            |
  +--------------------------+
  |                          |
  |  Derived 成员变量         |  <-- 重点 2:危险区域!
  |  (⛔ 未初始化 - 垃圾值)    |      如果此时调 Derived::func 
  |                          |      它去读这里的数据,程序就崩了。
  +--------------------------+
  • 重点 1 (vptr 指向): 编译器会在进入 Base 构造函数的一瞬间,插入代码将 vptr 指向 Base::vtable

  • 后果: 此时如果你调用 func(),程序通过 vptr 只能找到 Base::func

  • 重点 2 (内存状态): Derived 的成员变量还是一块生内存(Raw Memory),里面是随机值。


第二阶段:Base 构造完毕,进入 Derived 的构造函数时

(身份恢复:终于成为了真正的 Derived)

Base 构造完成后,程序流程进入 Derived::Derived() 的初始化列表。

Plaintext

       [ 对象的内存布局 ]                    [ 全局只读数据区 ]
  +--------------------------+           +-----------------------+
  |  vptr (虚表指针)          |---发生了变化-->|   Derived::vtable     |  <-- 重点 3
  +--------------------------+           +-----------------------+
  |                          |           |  &Derived::func       |
  |  Base 成员变量            |           +-----------------------+
  |  (✅ 已初始化)            |
  +--------------------------+
  |                          |
  |  Derived 成员变量         |
  |  (✅ 正在/已初始化)       |  <-- 安全区域
  |                          |      现在可以安全访问了。
  +--------------------------+
  • 重点 3 (vptr 更新): 进入 Derived 构造函数开头,编译器会再次插入隐式代码,将 vptr 重新指向 Derived::vtable

  • 后果: 此时再调用 func(),多态机制生效,解析到的就是 Derived::func

2. 析构函数同理

析构函数的顺序与构造函数相反:

  1. 派生类析构: 派生类析构函数运行(此时派生类成员被销毁)。

  2. 基类析构: 基类析构函数运行。

当进入第 2 阶段(基类析构)时,派生类的数据成员已经被销毁了,它们已经“不复存在”。因此,C++ 再次视该对象为基类对象。如果在基类析构函数中调用虚函数,同样只会调用基类的版本。

3.安全实现代码

#include <iostream>
#include <string>

class Transaction {
public:
    // 基类构造函数:不再去“拉取”数据,而是等着数据“送上门”
    explicit Transaction(const std::string& logInfo) {
        logTransaction(logInfo);
    }

    void logTransaction(const std::string& logInfo) const {
        std::cout << "[Base Log] " << logInfo << std::endl;
    }
};

class BuyTransaction : public Transaction {
public:
    // 构造函数:利用 helper 函数生成参数,传给基类
    // 注意:parameters 必须先准备好,才能传给 Base
    BuyTransaction(int stockID)
        : Transaction(createLogString(stockID)) { 
        std::cout << "BuyTransaction Constructor initialized." << std::endl;
    }

private:
    // 【关键点】静态成员函数
    // 这里的 static 就像一道防火墙
    static std::string createLogString(int stockID) {
        // 在这里,你绝对无法访问 BuyTransaction 的非静态成员
        // 因为 static 函数没有 'this' 指针!
        // 这就物理上杜绝了访问未初始化内存的风险。
        return "Buying Stock ID: " + std::to_string(stockID);
    }
};

int main() {
    BuyTransaction b(9988);
    return 0;
}

1. 现实生活类比

  • 以前的错误做法(虚函数): 父亲(基类)先醒来,眼睛还没睁开,就问儿子(派生类):“你手里拿的是啥股票?” 结果: 儿子还没醒(未初始化),父亲问了个寂寞,或者直接疯了(崩溃)。

  • 现在的正确做法(传参法): 儿子在进门之前,先找个旁观者(static 函数)把股票代码写在一张纸条上。 儿子进门时,直接把纸条递给父亲:“爸,这是我要买的股票。” 父亲拿着纸条念出来,完全不需要问儿子。


2. 代码执行的“慢动作”回放

当程序运行到 main() 里的 BuyTransaction b(9988); 时,计算机内部发生了这几步操作,顺序非常关键

第一步:准备阶段(关键!)

程序准备创建 BuyTransaction 对象。在进入任何构造函数体之前,它必须先处理初始化列表

: Transaction(createLogString(stockID))

这里需要传一个参数给 Transaction,所以计算机问:“参数是从哪来的?” 答案是:调用 createLogString(9988)

第二步:安全员出场(执行 static 函数)

执行 createLogString(9988)

  • 为什么它是安全的? 因为它是个 static(静态)函数。它就像一个外包工具人,它不属于具体的“这个对象”。它根本看不到 BuyTransaction 类里面任何非静态的成员变量。

  • 它只是单纯地把整数 9988 变成了字符串 "Buying Stock ID: 9988" 并返回。

  • 注意: 此时 BuyTransaction 对象还没开始造呢,根本不存在访问未初始化内存的风险。

第三步:基类构造(父亲干活)

拿到了字符串,现在正式调用基类构造函数 Transaction(...)

Transaction(const std::string& logInfo) {
    logTransaction(logInfo); // 打印 "[Base Log] Buying Stock ID: 9988"
}
  • 父亲(基类)顺利完成了日志记录。他不需要调用虚函数,他只是打印了传给他的字符串。

第四步:派生类构造(儿子干活)

基类构造完了,终于轮到 BuyTransaction 自己的构造函数体执行了:

{
    std::cout << "BuyTransaction Constructor initialized." << std::endl;
}

3. 为什么一定要用 static?

你可能会问:“我不用 static,直接写个普通成员函数不行吗?”

绝对不行!

如果你把 createLogString 去掉 static,它就变成了一个普通成员函数。 在 C++ 中,在传递参数给基类构造函数时,派生类对象不仅没初始化,甚至在概念上还不存在。

  • 用 static: 编译器知道这个函数跟具体的对象无关,只是个工具函数,可以在对象出生前随意调用。(安全 ✅)

  • 不用 static: 编译器会认为你在试图让一个“还没出生的对象”去执行动作(因为普通函数隐含了 this 指针),这在 C++ 标准中通常是未定义行为或编译器会直接报错。(危险 ❌)

4. 总结

  1. 绝对禁止: 在构造函数和析构函数中调用 virtual 函数。

  2. 原因: 在基类构造/析构期间,对象仅仅是基类对象,派生类的部分被视为“不存在”。调用虚函数不会下发到派生类。

  3. 替代方案: 将原本需要通过虚函数获取的信息,改为构造函数参数,由派生类传递给基类。

发表评论