25 / 80 · C++11 · 约 9 分钟
虚表 Vtable:语言语义与 ABI 实现
标准规定虚调用应选择哪个最终覆盖者,却不规定虚表数量、虚指针位置或对象内存图。常见 ABI 用虚表、调整桩和 RTTI 元数据实现这些语义;理解它们有助于调试,但不可用未经保证的内存读取验证。
先陈述标准保证的行为
通过基类指针或引用调用虚函数时,通常执行实际对象对应的最终覆盖者;显式限定调用和构造析构阶段有额外规则。标准要求这种可观察行为,并未要求对象头部一定有一个叫 vptr 的指针,也没有要求所有实现使用同一种虚表结构。
因此“有一个虚函数,对象就固定增加一个指针大小”只能作为特定 ABI 下简单情况的经验。对齐、复用主基类结构、多重继承和虚继承都会改变布局;编译器甚至可能在不改变可观察行为的前提下消除虚调用或对象本身。
常见 ABI 如何支持多重继承
以 Itanium C++ ABI 为例,虚表除了函数入口,还可能关联到完整对象的偏移、类型信息和虚基类定位信息。多重继承中的不同基类子对象可能需要不同虚表视图;调用入口还可能经过 thunk,调整 this 后再进入派生实现。
这些细节解释了为什么从不同基类指针出发仍能正确访问派生对象。它们不是供普通程序手工操作的公开接口,更不能假定虚表条目都是能按普通函数指针直接调用的地址。研究布局应使用编译器的布局转储、调试器和对应平台 ABI 文档。
验证行为,不探测未保证的字节
示例从两个基类接口分别调用同一完整对象的实现,并使用合法 dynamic_cast 做交叉转换,验证标准层面的行为。它不读取对象开头的整数,也不把对象地址强转成多级指针后解引用,因此不依赖布局、别名规则或某个编译器的偶然结果。
当编译器能证明动态类型时,可执行去虚化并内联调用。final 有时帮助提供证明,但不会保证某条汇编指令消失。性能判断应基于真实优化构建与实际数据分布,而不是仅凭源代码上出现 virtual 就计算固定开销。
容易答错的地方
- 把对象指针 reinterpret_cast 成虚表指针再读取,不是标准允许的通用反射;实验看似成功也不能证明可移植。
- 一个类一张虚表、一个对象一个虚指针都不是普遍成立的布局规则,多重与虚继承尤其如此。
运行一个例子
最低标准 C++11 · 完整程序 · 下载 .cpp
#include <cassert>
struct Reader {
virtual int read() const = 0;
virtual ~Reader() = default;
};
struct Writer {
virtual void write(int value) = 0;
virtual ~Writer() = default;
};
class Cell final : public Reader, public Writer {
int value_ = 3;
public:
int read() const override { return value_; }
void write(int value) override { value_ = value; }
};
int main() {
Cell cell;
Reader* reader = &cell;
Writer* writer = &cell;
assert(reader->read() == 3);
writer->write(8);
assert(reader->read() == 8);
assert(dynamic_cast<Writer*>(reader) == writer);
assert(dynamic_cast<void*>(reader) == static_cast<void*>(&cell));
}
在本地编译
g++ -std=c++11 -Wall -Wextra -Wpedantic -pthread objects-vtable.cpp -o example && ./example预期结果
预期:正常退出、无输出;所有 assert 通过。
CHECK YOUR UNDERSTANDING
合上答案,试着解释。
能否通过断言 sizeof(Cell) == 2 * sizeof(void*) + sizeof(int) 来验证这里有两个接口?
查看参考答案
不能。接口行为由语言规则保证,sizeof 则还受对齐、填充和具体 ABI 布局影响,给出的等式甚至可能在常见平台上因尾部填充失败。应保留通过两个接口读写以及交叉转换的行为断言;若要研究大小,记录目标平台测量结果并明确它不是跨平台契约。
继续查证
标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。