MAKE · WRITE · SHARE

DevTool-C++虚函数与对象布局

C++ 的运行时多态让调用者通过基类接口使用不同实现。语言规定的是调用应该落到哪个覆盖函数,而不是要求所有编译器都使用同一张表、同一个偏移。

虚表和虚指针是理解常见实现的工具。讨论对象内存时,应始终区分标准语义与具体 ABI(应用程序二进制接口),尤其不要把普通继承、虚函数和虚继承混在一起。

基类接口调用按照动态类型选择覆盖函数,布局图只表示常见机制

1. 先确认调用结果

下面是一个可独立编译的 C++17 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <memory>
struct Base {
virtual void foo() { std::cout << "Base::foo\n"; }
virtual void bar() { std::cout << "Base::bar\n"; }
virtual ~Base() = default;
};
struct Derived : Base {
void foo() override { std::cout << "Derived::foo\n"; }
void baz() { std::cout << "Derived::baz\n"; }
};
int main() {
std::unique_ptr<Base> p = std::make_unique<Derived>();
p->foo(); // Derived::foo
p->bar(); // Base::bar
}

foo 被覆盖,bar 沿用基类实现。baz 是普通成员函数,不能通过 Base 接口直接访问,也不因为 Derived 里有其他虚函数就自动变成虚函数。

2. 常见实现如何找到函数

可以把简单单继承对象概念化为“基类子对象中的虚指针→该动态类型的虚表→目标函数入口”。派生类继承 bar 时,其对应入口仍指向 Base 的实现。

但虚表还可能包含运行时类型信息、偏移或不同析构入口,实际布局依 ABI 而异。笔记中的“三行函数地址表”适合说明覆盖关系,不适合据此硬编码槽位或解引用对象内存。

编译器若能证明动态类型,还可能去虚拟化并内联调用;语言层面虚函数调用不保证一定产生一次实际间接跳转。

3. 为什么基类析构函数常需要 virtual

通过基类指针删除派生对象时,基类通常应提供虚析构函数,使销毁遵循正确的动态类型。智能指针也不会自动修复所有错误接口设计,特别是以 Base 类型默认删除器持有对象的情形。

若类明确禁止通过基类删除,也可以使用相应的受保护析构设计。应让所有权规则清楚,而不是看到继承就无条件堆功能。

4. 虚继承解决的是另一种问题

菱形继承中,两个中间类都继承同一基类。普通继承产生两个基类子对象;把这两条相关路径改为虚继承,才会共享一个虚基类子对象。

1
2
3
4
struct Root { int value = 0; };
struct Left : virtual Root {};
struct Right : virtual Root {};
struct Leaf : Left, Right {};

这与 Root 是否有虚函数无关。原笔记前面的 Derived : Base 没有 virtual 继承,不能在后续解释里声称它因此拥有 vbptr。

MSVC 的某些布局使用虚基类表指针,其他 ABI 可采用不同方式。完整 Leaf 对象还包含基类子对象的实现数据,不能简单说“最派生对象没有 vbptr”就把内部结构忽略。

普通菱形有两个 Root 子对象,虚继承可共享一个 Root

图中只表达子对象的共享关系,不指定内存偏移和指针布局。具体 ABI 对虚基类的访问方式应使用编译器布局报告或调试器验证。

5. 多继承还可能调整 this

Base 指针可能指向完整对象中的一个子对象,而不是完整对象开头。跨基类转换、虚调用和协变返回等情况,可能需要 this 调整或跳板。

这也是不应把“vptr 总在整个对象第一个字节”“一个对象只有一个 vptr”当作通用规则的原因。对象大小还受成员、对齐、空基类优化等影响。

6. 怎样验证自己的编译目标

先用实际调用确认语义,再用调试器、反汇编或编译器布局报告观察实现。记录编译器版本、目标架构、优化选项和 ABI;同一源码在 Debug 与 Release 中也可能生成不同调用形式。

性能判断应关注调用频率、类型分布和优化机会。频繁虚调用可能影响内联与局部性,但“一个间接调用一定很慢”也不是有效分析。

总结

虚函数决定动态派发,虚继承决定基类子对象共享,虚表与虚基类表属于实现机制。先根据语言语义判断结果,再使用具体编译器证据解释布局,才不会把示意图误当成标准。

参考资料