C++ / a working model

102 / 103   ·   C++11   ·   约 10 分钟

构造与析构期间:验证虚调用,不猜虚表布局

先记住这句话

对象模型的实现图有助于理解成本,但不是跨编译器ABI承诺。本课从已读第5章构造语义出发,用间接虚调用记录对象生命周期中的行为,区分语言保证、历史实现模型与尚未初始化的派生成员。

本篇内容
  1. 问谁可以被调用,而不是vptr在哪里
  2. 间接调用也遵守同一规则
  3. 不要让观察机制改变安全前提
  4. 运行示例
  5. 动手练习
READING EVIDENCE / 已读完整正文

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即可表达新实验的预期。

继续查证

标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。

回到目录