当前位置: 首页 > 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.gsyq.cn/news/127864.html

相关文章:

  • Item5--了解 C++ 默默编写并调用了哪些函数
  • 日记1217
  • 本地私有知识库新选择:访答软件真实体验分享
  • 日记12,15
  • string_view
  • 比话降AI靠谱吗?比话能降知网AI率吗? - 还在做实验的师兄
  • 好用做老房换新实用门窗品牌精选指南的机构
  • 快去尝试单尺作图内接正257边形吧
  • nginx安装步骤详解 - 教程
  • C020基于博途西门子1200PLC鸡饲料生产线控制系统仿真
  • 第9章 顺序容器
  • 基于SpringBoot+Vue的乡镇农村建设用地管理系统的设计与实现
  • Git 与 SVN 区别 - 详解
  • 第6章 函数
  • C++新特性
  • 亲测十大灵活用工平台复盘
  • 目录---behaviac
  • 20251220
  • 线索二叉树
  • 第3章 字符串向量数组
  • 曲线的极坐标方程输入法 | Desmos 玩法系列 02
  • AVL
  • 鸿蒙系统
  • STM32学习——编码器接口测速
  • BST
  • 卡帕西年度预测:大模型只释放10%潜力,2025年AI发展6大趋势
  • 学Simulink——基础MPPT控制场景实例:基于Simulink的电导增量法(INC)光伏MPPT仿真
  • 模板模式
  • 模板和策略模式的区别
  • 学Simulink——机器人控制场景实例:基于Simulink的SCARA机械臂关节空间PD控制仿真