102 / 103 · C++11 · 约 10 分钟
构造与析构期间:验证虚调用,不猜虚表布局
对象模型的实现图有助于理解成本,但不是跨编译器ABI承诺。本课从已读第5章构造语义出发,用间接虚调用记录对象生命周期中的行为,区分语言保证、历史实现模型与尚未初始化的派生成员。
Inside the C++ Object Model
公开182页重排本的全部7章正文、代码、脚注与表格已逐段读完:第1章PDF12–31,第2章31–54,第3章54–82,第4章82–111,第5章111–137,第6章137–160,第7章160–182;全部17幅编号图(子图分计)已直接查看,包括最后的图4.3/PDF100。full仅指这份文件的全部实质章节,不声称已逐页校勘304页纸本或读到此文件未收录的纸本索引。PDF页码不是印刷页码。
查看版本、实际阅读范围与原文入口 →问谁可以被调用,而不是vptr在哪里
第5章用设置vptr的顺序解释构造时的虚调用,这是理解常见实现的一种模型,不是要求程序去修改虚表。标准并不指定对象必须包含某个固定偏移的虚表指针。我们真正能够移植验证的,是构造或析构期间对当前对象发起的虚调用选择哪一个覆写函数。
基类构造阶段,派生部分尚未准备好,所以不能调用派生覆写来读取它的成员。派生构造函数体开始执行时,基类和成员已经完成初始化,此时在派生类内的虚调用可以选择派生覆写。这里的边界由构造阶段决定,不是根据最外层那条声明最终写了哪个类型。
间接调用也遵守同一规则
例子在Base构造函数中调用非虚成员sample,再由sample调用虚函数kind。这层间接关系不会让调用跳到尚未构造的Derived;记录结果仍为1。Derived构造函数体记录2,完整对象经Base引用调用也得到2。
销毁按相反方向进行:Derived析构函数体中记录2,随后Base析构函数体中记录1。日志使用外部固定大小数组,没有在析构时增长容器或分配内存;记录器的寿命覆盖被观察对象。输出展示的是可观察的调用顺序,不包含地址、对象大小或编译器专属细节。
不要让观察机制改变安全前提
这里每个被调用的虚函数都有正常定义,且不读取未初始化的数据。不要为了展示边界而在基类构造函数里调用纯虚函数,也不要让观察指针超出日志寿命。Base提供虚析构函数,使其作为多态接口的销毁约定清楚,虽然本例实际使用自动存储期对象。
C++20仍遵循这些阶段规则。成员初始化表达式中调用虚函数并非一概禁止,但要先满足基类已初始化等前提,还必须保证函数不读取尚未初始化的成员;本例只在构造函数体内观察,以免混入另一组问题。书中的隐藏参数与vptr赋值是解释性伪代码,不能由本次输出推出任何固定布局。
容易答错的地方
- 构造函数经非虚辅助成员间接调用虚函数,仍受当前构造阶段约束;不会因此调用到更派生的覆写。
- 不要把某个编译器的vptr位置当成标准要求,也不要通过reinterpret_cast读写虚表来证明语言语义。
运行一个例子
最低标准 C++11 · 完整程序 · 下载 .cpp
#include <array>
#include <cassert>
#include <cstddef>
#include <iostream>
struct Trace {
std::array<int, 5> values{};
std::size_t used = 0;
void add(int value) noexcept {
assert(used < values.size());
values[used++] = value;
}
};
class Base {
protected:
Trace& trace;
void sample() { trace.add(kind()); }
public:
explicit Base(Trace& t) : trace(t) { sample(); }
virtual ~Base() { sample(); }
virtual int kind() const noexcept { return 1; }
};
class Derived final : public Base {
public:
explicit Derived(Trace& t) : Base(t) { sample(); }
~Derived() override { sample(); }
int kind() const noexcept override { return 2; }
};
int main() {
Trace trace;
{
Derived object(trace);
const Base& view = object;
trace.add(view.kind());
}
const std::array<int, 5> expected{{1, 2, 2, 2, 1}};
assert(trace.used == expected.size());
assert(trace.values == expected);
for (std::size_t i = 0; i < trace.used; ++i) {
if (i != 0) std::cout << ' ';
std::cout << trace.values[i];
}
std::cout << '\n';
}
在本地编译
g++ -std=c++11 -Wall -Wextra -Wpedantic -pthread books-inside-cpp-object-model.cpp -o example && ./example预期结果
1 2 2 2 1
CHECK YOUR UNDERSTANDING
合上答案,试着解释。
把正常使用阶段的view.kind()改成object.Base::kind(),其余不变,日志是什么?
查看参考答案
日志变为1 2 1 2 1。显式限定Base::kind()抑制这一次调用的虚分派,直接选择基类实现;它不会改变对象的动态类型,也不会影响前后构造、析构中的调用规则。将expected中间元素改为1即可表达新实验的预期。
继续查证
- Inside the C++ Object Model §5.2, reflow PDF pp.124–127
- C++ working draft: construction and destruction [class.cdtor]
- C++ working draft: virtual functions [class.virtual]
标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。