虚派发、虚表与 ABI:C++ 多态背后的对象布局
C++ 多态看起来像语法层面的 virtual。
底层却牵涉对象布局、虚表指针、调用约定、RTTI、析构顺序和 ABI。
理解虚派发不是为了手写虚表,而是为了知道多态接口什么时候稳定、什么时候昂贵、什么时候会在二进制边界制造风险。
静态类型和动态类型不是一回事
静态类型由源码表达式决定。 动态类型是对象运行时真实类型。 虚函数调用会根据动态类型选择最终函数。
struct Shape {
virtual ~Shape() = default;
virtual double area() const = 0;
};
struct Circle : Shape {
double r;
double area() const override { return 3.14 * r * r; }
};
Shape* p = new Circle; 中,p 的静态类型是 Shape*。
*p 的动态类型是 Circle。
调用 p->area() 时发生虚派发。
虚表是常见实现策略
标准不强制实现必须用虚表。 但主流 ABI 通常使用 vptr + vtable。 对象中保存一个隐藏指针,指向该动态类型的虚表。 虚表中保存虚函数入口。
Circle object
├── vptr ───────┐
├── r │
└── padding │
v
Circle vtable
├── destructor
├── area
└── RTTI
虚调用大致过程:
读取对象 vptr
-> 读取 vtable 中对应槽位
-> 间接调用函数指针
这比普通非虚调用多一次间接访问。 更重要的是,它限制了一些内联优化。
虚析构是基类删除的安全边界
如果通过基类指针删除派生对象,基类析构函数必须是 virtual。
struct Base {
virtual ~Base() = default;
};
struct Derived : Base {
std::vector<int> data;
};
如果 Base 析构不是 virtual,delete Base* 指向 Derived 对象时行为不正确。
派生类资源可能无法释放。
这会变成资源释放和审计问题。
当类 intended to be polymorphic,析构函数要么 public virtual,要么 protected non-virtual 并禁止通过基类删除。
构造和析构期间虚派发受限
构造基类时,派生部分尚未构造。 析构基类时,派生部分已经析构。 因此构造和析构期间调用虚函数,不会派发到尚未可用或已经销毁的派生部分。
struct Base {
Base() { init(); }
virtual void init();
};
struct Derived : Base {
std::string name;
void init() override;
};
Base 构造函数中调用 init 不会调用 Derived::init。
依赖派生状态的初始化应该放到工厂函数或显式启动函数中。
对象切片会丢掉动态类型
把派生对象按值赋给基类对象,会发生切片。 基类子对象被复制,派生部分丢失。
Circle c;
Shape& ref = c; // 保留动态类型
Shape copy = c; // 如果 Shape 可实例化,会切片
多态对象通常不应按值传递基类。 使用引用、指针、智能指针或类型擦除包装。
多继承让布局变复杂
多继承可能让对象包含多个基类子对象。 每个多态基类子对象可能有自己的 vptr。 指针转换可能需要调整地址。
struct A { virtual void a(); };
struct B { virtual void b(); };
struct C : A, B { void a() override; void b() override; };
C* 转成 B* 时,地址可能不是对象起始地址。
这就是为什么 C++ 对象不能随意拿给 C ABI 当普通内存块处理。
虚继承解决共享基类但增加成本
菱形继承中,虚继承可以让公共基类只保留一份。 代价是对象布局和访问路径更复杂。
struct Root { int id; };
struct Left : virtual Root {};
struct Right : virtual Root {};
struct Leaf : Left, Right {};
虚继承需要额外元数据定位共享基类。 这适合少数需要表达真实层级关系的框架。 普通业务抽象更应该优先组合和接口拆分。
RTTI 是运行时类型信息
dynamic_cast 和 typeid 依赖 RTTI。
RTTI 可以用于安全向下转型,但过度使用通常说明抽象边界不清。
if (auto* circle = dynamic_cast<Circle*>(shape)) {
draw_circle(*circle);
}
这段代码需要运行时检查。 如果分支越来越多,应考虑把行为放回虚函数,或使用 visitor、variant、策略对象。
devirtualization 是优化器的反向推理
如果优化器能证明动态类型唯一,虚调用可以被优化成直接调用甚至内联。
void draw(Circle& c) {
c.area(); // 静态类型已知,通常不需要虚派发
}
LTO 能跨翻译单元看到更多类型信息。
final 也能帮助编译器判断不再有派生覆写。
struct FinalCircle final : Shape {
double area() const override;
};
性能优化要基于 profile。 不要因为虚调用存在就盲目移除多态。
ABI 边界不适合暴露 C++ 多态类
跨动态库暴露虚类会把 vtable 布局、RTTI、异常、分配器和编译器 ABI 都暴露出去。 不同编译器、不同标准库、不同编译选项都可能破坏兼容。
更稳妥的做法:
- 边界暴露 C ABI。
- 内部使用 C++ 多态。
- 对外返回不透明句柄。
- 用函数表表达插件能力。
- 错误通过状态码或结构体返回。
typedef struct RendererApi {
uint32_t version;
void* context;
int (*draw)(void* context);
void (*destroy)(void* context);
} RendererApi;
这种写法不优雅,但 ABI 清晰。
虚函数不是默认抽象工具
虚函数适合运行时可替换行为。
模板适合编译期多态。
std::variant 适合封闭集合。
函数对象适合注入策略。
接口对象适合插件和边界。
| 技术 | 多态时间 | 适合场景 | 风险 |
|---|---|---|---|
| virtual | 运行时 | 开放层级、插件内部 | ABI、间接调用 |
| template | 编译期 | 性能敏感泛型 | 编译时间、错误复杂 |
| variant | 运行时封闭集合 | 状态机、AST | 分支维护 |
| function object | 运行时或编译期 | 策略注入 | 捕获生命周期 |
选择抽象前先问集合是否开放、边界是否跨库、性能是否受间接调用影响。
调试虚派发要看对象和符号
虚调用问题通常需要结合源码和二进制证据。
nm -C libshape.so | grep vtable
objdump -Cd libshape.so | grep area
调试时关注:
- 对象是否仍在生命周期内。
- vptr 是否被越界写破坏。
- 基类析构是否 virtual。
- 动态库中是否存在多个类型定义。
- 编译器和标准库 ABI 是否一致。
- 插件是否跨边界删除对象。
vptr 被破坏常常是更早的内存越界结果。 不要只修崩溃点。
工程清单
- 多态基类提供 virtual 析构或禁止基类删除。
- 构造和析构中不调用依赖派生状态的虚函数。
- 不按值传递多态基类。
- ABI 边界不暴露 C++ 虚类。
- 多继承和虚继承需要明确理由。
- 使用
override防止签名误差。 - 使用
final表达不再派生的边界。 - 用 profile 判断虚调用成本。
- 对 vptr 破坏优先排查越界和生命周期。
- 对插件边界设计降级和版本审计。
小结
C++ 多态不是“函数表技巧”这么简单。 它把对象布局、动态类型、析构规则和 ABI 连接在一起。 在模块内部,虚函数能表达清晰的运行时抽象。 在二进制边界,虚函数会扩大兼容风险。 理解虚派发的底层机制,才能在抽象能力和工程稳定性之间做出可靠取舍。