当前位置: 首页 > news >正文

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 必须先准备好,才能传给 BaseBuyTransaction(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. 替代方案: 将原本需要通过虚函数获取的信息,改为构造函数参数,由派生类传递给基类。
http://www.jsqmd.com/news/115919/

相关文章:

  • python django flask考研互助交流平台_c62p51fu--论文
  • 日记12.18
  • 离散化遍历
  • Ubuntu上使用VScode创建Maven项目
  • 线程(2)
  • 大规模语言模型的抽象思维与创新能力培养
  • 线程(1)
  • 方达炬〖发明超新技术〗:冰堆技术;冷极冰堆建筑技术;
  • Item6--若不想使用编译器自动生成的函数,就该明确拒绝
  • 我发现LLM解析基因数据优化抗癌药剂量,患者副作用直降40%
  • 日记12.16
  • 论文AIGC查重率高怎么办?6个降AI率工具和技巧,AI率从100%降到3%! - 还在做实验的师兄
  • PCL曲面重建——为一组点云重建凸多边形/凹多边形
  • 信息与关系:涌现的三大核心原则
  • Linux文件权限
  • 28
  • 灵遁者:量子基元理论带来的新观点
  • 【补充】远程连接学校服务器操作说明
  • 本地私有知识库新选择:访答软件真实体验分享
  • 划分dp
  • 花边服饰银发红眸者山间近景
  • 日记12,15
  • Item4--确定对象被使用前已先被初始化
  • string_view
  • 当K3s遇见RustFS:轻量级边缘存储方案的探索与实践
  • 比话降AI靠谱吗?比话能降知网AI率吗? - 还在做实验的师兄
  • 树形背包
  • 八皇后问题
  • 好用做老房换新实用门窗品牌精选指南的机构
  • 基于MinIO Java SDK实现ZIP文件上传的方案与实践