05 · 继承多态与虚函数
定位:OOP 八股的核心,虚函数表机制、菱形继承、虚继承是必考且最容易讲不清的部分。 学完标准:能手画虚表结构图;能解释“为什么基类析构要虚”;能讲清菱形继承问题和虚继承解法。
1. 多态是什么
Section titled “1. 多态是什么”- 静态多态:编译期决定——函数重载、模板。
- 动态多态:运行期决定——虚函数 + 继承 + 基类指针/引用。程序运行到调用点才根据对象实际类型决定调用哪个版本。
class Animal {public: virtual void speak() { std::cout << "动物叫\n"; } virtual ~Animal() = default; // 基类析构必须虚(见 5 节)};
class Dog : public Animal {public: void speak() override { std::cout << "汪汪\n"; }};
void makeSpeak(Animal& a) { a.speak(); } // 动态绑定:运行期才知道
int main() { Dog d; Animal& a = d; a.speak(); // 输出"汪汪"(实际对象是 Dog) makeSpeak(d); // 输出"汪汪"}2. 虚函数表(vtable)机制(重点中的重点,必须会画)
Section titled “2. 虚函数表(vtable)机制(重点中的重点,必须会画)”2.1 基本结构
Section titled “2.1 基本结构”- 每个含有虚函数的类(或继承自含虚函数类的类)都有一张虚函数表(vtable),存放该类所有虚函数的地址。
- 每个对象内部有一个虚指针(vptr),指向它所属类的虚表。
- 调用虚函数 = 通过 vptr → vtable → 找到真实函数地址,间接跳转。
Dog 对象 d 的内存:┌───────────────┐│ vptr ─────────┼──→ ┌────────────────────┐│ 其他成员 │ │ Dog 的虚表(vtable) │└───────────────┘ │ &Animal::speak 的覆盖版│ ← Dog::speak │ &Animal::~Animal 版 │ ← Dog::~Dog └────────────────────┘- 虚表本身存在哪?只读数据段(.rodata),全程序一份,所有同类型对象共享。
- vptr 存在哪?对象内存的起始位置(偏移 0)(单继承下;多继承时每个基类子对象各带一个 vptr)。
2.2 继承时虚表怎么组织
Section titled “2.2 继承时虚表怎么组织”- 派生类没重写 → 虚表里对应项仍是基类版本地址;
- 派生类重写了 → 该项替换为派生类版本地址;
- 派生类新增虚函数 → 追加到虚表末尾。
2.3 对象切片(slicing)——为什么必须用指针/引用
Section titled “2.3 对象切片(slicing)——为什么必须用指针/引用”void f(Animal a) { a.speak(); } // 按值传 → 拷贝构造 Animal,切掉 Dog 部分 → 调 Animal::speakDog d;f(d); // 输出"动物叫"!多态失效
Animal* p = &d; p->speak(); // 指针 → 多态生效Animal& r = d; r.speak(); // 引用 → 多态生效结论:多态只对“指针/引用”生效,按值传递会切片。
2.4 静态绑定 vs 动态绑定(必背)
Section titled “2.4 静态绑定 vs 动态绑定(必背)”| 场景 | 绑定方式 |
|---|---|
| 非虚函数 | 静态绑定(编译期) |
| 通过值传递调用虚函数 | 静态绑定(切片后调基类) |
| 通过指针/引用调用虚函数 | 动态绑定(运行期查虚表) |
| 构造函数/析构函数里调用虚函数 | 静态绑定(走当前类的版本) |
| 直接调用(编译器可确定对象类型) | 静态绑定(devirtualization,可能内联) |
3. 构造/析构里调用虚函数(必考)
Section titled “3. 构造/析构里调用虚函数(必考)”class Base {public: Base() { foo(); } // 基类构造里调虚函数 virtual void foo() { puts("Base::foo"); }};class Derived : public Base {public: void foo() override { puts("Derived::foo"); }};int main(){ Derived d; } // 输出:Base::foo为什么? 构造 Derived 时,先执行 Base 的构造,此时 Derived 还没构造完,对象“还只是 Base”,vptr 指向的还是 Base 的虚表 → 调 Base 版本。
结论:构造/析构期间虚函数不发生动态绑定,别在构造/析构里依赖多态。析构时同理(先析构 Derived 再析构 Base,Base 析构时 vptr 已指回 Base 表)。
4. 虚函数相关关键字与细节
Section titled “4. 虚函数相关关键字与细节”4.1 override / final
Section titled “4.1 override / final”struct Base { virtual void f(int); };struct D1 : Base { void f(int) override; // 明确这是重写;签名写错 → 编译报错(防坑)};struct D2 : Base { void f(double); // 没写 override → 这是【名字隐藏】,不是重写!静默 bug}; // 基类 f 被隐藏:`D2 d; d.f(1);` 会走 f(double);想都可见需 `using Base::f;`struct D3 : Base { void f(int) final; }; // 禁止后续派生类再重写struct D4 : D3 { void f(int); }; // 编译错误:final金句:override 的作用是让“重写失败”在编译期暴露,是现代 C++ 规范必写。
【答疑】不写 override 会报错吗?——不会(常见误解)
Section titled “【答疑】不写 override 会报错吗?——不会(常见误解)”- 不写
override不报错:编译器会自己去匹配基类虚函数,匹配上就是重写,程序行为完全一样。 - 写了
override但签名不匹配才报错:报错的是override这个说明符,不是“重写失败”本身。 - 所以
override的真实价值是:把“本意是重写、实际没重写”这类静默 bug 提前到编译期。 典型踩坑——想重写virtual void f(int),却写成void f(double)/void f(int) const/void f(int) &:- 不写
override:编译通过,新函数隐藏了基类版本,多态悄悄失效(最难查的一类 bug); - 写了
override:编译直接报“没有可重写的成员函数”。
- 不写
配套细节(面试可加分的精确性):
| 写法 | 结果 |
|---|---|
基类 virtual + 派生类签名完全相同,不写 virtual 不写 override |
合法,仍然是重写(虚性自动继承) |
派生类写了 override,签名差一点(参数类型/const/引用限定符/返回类型不兼容) |
编译错误 |
派生类的 override 函数上加 final |
合法,禁止再下一层重写 |
virtual 写在类外定义处(void D::f() { }) |
不能,virtual/override 只能写在类内声明上 |
- 重写对返回类型的要求:必须相同,或基类返回指针/引用时派生类返回协变类型(如基类返回
Base*,派生类返回Derived*)。
4.2 虚函数能是内联的吗?什么是去虚拟化(devirtualization)?
Section titled “4.2 虚函数能是内联的吗?什么是去虚拟化(devirtualization)?”先拆两个容易混淆的概念:
inline关键字:对虚函数没有意义也没有用。虚函数按需调用,编译器不靠inline决定要不要展开函数体(类内定义本来就是隐式 inline)。- 函数体内联(把调用点替换成函数代码):这是优化,和
inline关键字无关。
结论:
- 虚函数体本身可以被内联——内联的是“函数体”,不是“调用”。
- 通过基类指针/引用调用虚函数时无法内联:编译期根本不知道会调到哪个版本,只能生成“取 vptr → 查表 → 间接跳转(call [rax])”,这一跳省不掉。
- 能确定具体类型时就能内联——这个“确定”的过程就叫去虚拟化(devirtualization):编译器把“查虚表的间接调用”改写成“直接调用某个确定的函数”,改写之后这次调用才谈得上内联。
编译器能去虚拟化的常见场景(= 自测第 9 题的答案):
| 场景 | 例子 / 原因 |
|---|---|
| 直接对对象调用 | Dog d; d.speak(); —— 静态类型就是 Dog,还调用什么虚表 |
类型是 final 类 |
class Dog final : public Animal —— 不可能再有更派生的类型 |
函数是 final |
虚表项不可能被更下层改写 |
| 构造/析构函数内部 | 此时动态类型就是当前正在构造/析构的类 |
| 显式类名限定调用 | d.Animal::speak() |
| 类内调用自己 | this->speak() 且类是 final/匿名命名空间 |
| LTO 全程序优化 | 链接期看到全部派生类,证明“只有一个实现” |
- 进阶:编译器还会做推测性去虚拟化(先比较 vptr 是否等于某个已知表,是就直接调、否则回退到查表)。所以
final不只是给编译器看的注释,它真的能提升优化上限。 - 工程含义:游戏热路径批量处理对象时,把叶子类标
final、把“是否要虚”想清楚,是常见的性能优化手段(对应 11 章“虚函数开销”)。
4.3 纯虚函数 / 抽象类 / 接口
Section titled “4.3 纯虚函数 / 抽象类 / 接口”class Shape { // 抽象类:含至少一个纯虚函数public: virtual double area() const = 0; // 纯虚函数:=0 强制派生类实现;基类也可以提供实现 virtual ~Shape() = default;};// Shape s; // 错误:不能实例化抽象类
class Circle : public Shape {public: double area() const override { return 3.14 * r * r; }};- 纯虚函数 = 声明后面加
= 0,强制派生类实现才能实例化;基类可以提供实现,派生类可用Base::f()显式调用。 - 抽象类:含纯虚函数的类,不能实例化,用于定义“接口/契约”。
- 派生类若没实现所有纯虚函数 → 仍是抽象类。
- 接口类在 C++ 里就是“全是纯虚函数的抽象类”(对应 Java 的 interface)。
【答疑】既然抽象类不能实例化,那给纯虚函数写实现有什么用?
Section titled “【答疑】既然抽象类不能实例化,那给纯虚函数写实现有什么用?”不能实例化 ≠ 不能调用。纯虚函数的实现只能通过显式限定调用到达:
class Base {public: virtual void f() = 0; // 纯虚 virtual void g() { log(); } // 普通虚函数,调虚函数 f virtual ~Base() = default;};
void Base::f() { std::puts("默认实现"); } // 纯虚函数也能有函数体(注意:不能在类内写 = 0 又给体)
class D1 : public Base {public: void f() override { Base::f(); } // ← 显式调用基类版本,最典型的用法};class D2 : public Base {public: void f() override { std::puts("自己实现"); }};能用到基类实现的三种场合:
- 派生类里显式调用:
Base::f();—— 语法上直接指出函数,静态绑定,不查虚表。常见于“派生类只想在默认行为上补一点”。 - 基类其他函数内部调用:
g()里调f(),如果对象已经构造完成,就会正常动态绑定到派生类版本;这属于模板方法模式(基类定骨架、派生类填细节)。 - 纯虚析构函数必须给实现:这是标准硬性要求——
class Base { public: virtual ~Base() = 0; };Base::~Base() {} // 必须写!否则派生类析构时链接错误(LNK2019 / undefined reference)
class D : public Base { }; // D 不是抽象类,可实例化// Base b; // 错误:Base 仍是抽象类派生类析构一定会隐式调用基类析构,这是静态绑定的调用,所以函数体必须存在。
面试陷阱题:
Base::f()是纯虚函数且有实现,但在基类构造/析构函数里调用f()(不加限定)→ 仍然是未定义行为(此时基类版本是纯虚的,多数实现会调用__cxa_pure_virtual直接终止程序)。总结一句:纯虚函数 = “必须重写”,不是”没有实现“。两者是独立的属性。
5. 为什么基类析构必须 virtual(必考)
Section titled “5. 为什么基类析构必须 virtual(必考)”class Base { public: ~Base() { puts("~Base"); } }; // 非虚析构!class Derived : public Base { int* p = new int[10]; ~Derived(){ delete[] p; puts("~Derived"); } };
Base* b = new Derived;delete b; // 未定义行为!常见表现:只调 ~Base,Derived 的 p 泄漏,~Derived 没执行原因:delete 通过基类指针释放派生对象时,若基类析构非虚,标准规定是未定义行为;常见实现按静态绑定只调 ~Base,导致派生类析构不执行、资源泄漏。
结论:多态基类(可能通过基类指针 delete 派生对象)析构必须 virtual;非多态基类(如 CRTP、私有继承、明确不会用基类指针 delete)不必。
【答疑】子类析构函数需要写 override 吗?
Section titled “【答疑】子类析构函数需要写 override 吗?”不需要,但推荐写 override(现代规范)。
class Base { public: virtual ~Base() = default; };class D : public Base {public: ~D() override { } // 合法;overrides Base::~Base // ~D() { } // 也合法,行为完全一样(隐式就是重写) // ~D() override final; // 也可以加 final};三条要记住的:
- 基类析构是
virtual时,派生类析构自动成为重写,无论写不写virtual、写不写override。 - 析构函数比较特殊:没有参数、没有返回类型,所以“签名写错”的空间几乎为零 ——
override在这里的防错价值远小于普通函数,但仍能防一类错误:基类析构忘了写virtual时,编译器会直接报 “no suitable member to override”,等于顺手把第 5 节那个 bug 也拦下来了。 - 析构是唯一可以隐式
override却不需要写返回类型的成员函数;析构函数还默认是noexcept,抛异常会std::terminate。
补充(常被追问):纯虚析构可以写
virtual ~Base() = 0;,但必须给函数体(见 4.3 答疑);delete基类指针时先跑派生类析构,再自动跑基类析构,整条链由虚表查到的那一个入口驱动。
追问:虚析构怎么保证正确释放?→ 虚表里存的是 Derived 的析构地址,运行期查到后先执行 Derived 析构体,再自动执行成员/基类析构,整棵析构链完整。
6. 多重继承与菱形继承
Section titled “6. 多重继承与菱形继承”6.1 多重继承
Section titled “6.1 多重继承”class A { public: int a; };class B { public: int b; };class C : public A, public B { }; // C 含 a 和 b- C++ 支持多继承;构造顺序:按继承列表顺序(先 A 后 B)。
- 多继承下每个含虚函数的基类子对象各带一个 vptr,所以对象里有多个 vptr;它们分散在各个基类子对象的开头(中间隔着各基类自己的成员),只有主基类的 vptr 在偏移 0 —— 详见第 8 章 Q2-补 与那张布局图。
- 多继承的实际代价:指针转换要调偏移(
this调整)、每个虚调用要先用正确的 vptr 找表、布局依赖实现 —— 所以工程上更推荐“单继承 + 接口/组合”。
6.2 菱形继承问题(Diamond)
Section titled “6.2 菱形继承问题(Diamond)” A / \ B C \ / Dclass A { public: int data; };class B : public A { };class C : public A { };class D : public B, public C { };
D d;// d.data // 编译错误:二义性(B::A::data 还是 C::A::data)d.B::data = 1; // 勉强可用但很丑问题 1:二义性(哪个 data?)。问题 2:A 的子对象被复制了两份,D 里有两个 A 子对象 → 内存浪费 + 语义混乱(比如 A 是“怪兽血量”,B/C 各持一份,改 B 的不影响 C 的)。
6.3 虚继承解决(Virtual Inheritance)
Section titled “6.3 虚继承解决(Virtual Inheritance)”class A { public: int data; };class B : virtual public A { }; // 虚继承class C : virtual public A { };class D : public B, public C { };
D d;d.data = 5; // 唯一一份 A,无二义性原理:
- 虚继承让 D 中只保留一份共享的 A 子对象。
- 实现(常见):虚基类子对象放在对象内存末尾/独立区域,通过虚基类表/偏移定位(B/C 里存指针/偏移);具体布局是实现定义的。
- 代价:多一次间接访问,性能稍差;但语义正确、省内存。
面试常问:虚继承有性能开销吗?→ 有,访问虚基类成员要多一次间接寻址(通过 vbase offset 表)。所以只在确实需要时用虚继承。
【精讲】虚继承到底虚在哪(一次讲透)
Section titled “【精讲】虚继承到底虚在哪(一次讲透)”一句话:virtual public A 的意思是——“A 这个基类子对象,整个对象里最多只留一份,由最派生的类统一负责”。
对比两种写法,看 D 里到底存了几份 A:
| 写法 | D 里的 A 子对象 | d.data |
sizeof(D) 示意(x86-64,实现定义) |
|---|---|---|---|
B : public A / C : public A / D : B, C |
2 份(B 里一份、C 里一份) | 编译错误:二义性 | 8 |
B : virtual public A / C : virtual public A / D : B, C |
1 份,且位置由 D 决定 | 合法 | 24 |
- 注意最后一行:虚继承反而更占内存(8 → 24)。虚继承省下的是“重复的 A 子对象”(4 字节),却要为 B、C 各加一个记录偏移的指针(各 8 字节)+ 对齐填充。
- 所以
8 → 24这个对比很值得记:虚继承的收益是“语义正确(无二义性 + 状态唯一)”,代价是两次 8 字节偏移指针 + 对齐填充。只有当虚基类本身数据够大(比如 A 是个含多个成员的大基类),省下的重复子对象才抵得过这笔开销 —— 判据是sizeof(A) > 16左右才划算。
普通继承(两份 A) 虚继承(一份 A)┌──────────────┐ ┌──────────────┐│ B::A::data │ │ B 的部分 │ ← B 里不再内嵌 A│ C::A::data │ │ C 的部分 │ ← C 里也不再内嵌 A└──────────────┘ │ A::data │ ← D 自己放的那唯一一份(虚基类子对象) └──────────────┘机制 1:谁负责构造虚基类子对象?——最派生类(most-derived class)
- 构造
D时,顺序是:A → B → C → D。A 的构造只发生一次,而且是由 D 的构造函数在 B、C 之前触发的。 - 关键规则:B、C 的构造函数里对虚基类 A 的初始化会被忽略。也就是说
B() : A(1) {}中的A(1)只在“B 作为最派生类被单独创建”时才生效。 - 这就是“虚基类的初始化必须由最派生类负责”这条规则的由来,也是虚继承最反直觉的地方。
- 副作用:D 的构造函数如果要给 A 传参,必须自己写
D() : A(x), B(), C() {},否则 A 走默认构造 —— 所以虚基类最好提供默认构造函数。
struct A { A(int) {} }; // 没有默认构造struct B : virtual A { B() : A(1) {} }; // ← 这个 A(1) 在 D 里会被忽略struct C : virtual A { C() : A(2) {} };struct D : B, C { D() : A(3), B(), C() {} // 必须在这里初始化 A;写成 D(){} 会编译错误(A 无默认构造)};机制 2:访问虚基类成员为什么要“多一次间接”?
B* pb = new D; // 或 B* pb = &d;pb->data; // data 在哪儿?- 编译器在编译
pb->data时只知道”pb指向某个 B 子对象”,不知道pb究竟指向单独的 B 还是 D 里的那个 B —— 两种情况里 A 子对象的偏移量不同(前者紧邻 B,后者由 D 排布)。 - 所以不能写死编译期偏移,必须运行期查表拿偏移:编译器在虚函数表里预留
vbase_offset槽(Itanium ABI 放在虚表-3项,MSVC 用独立的vbtable),先读偏移、加上去、再访问。 - 代价 = 两次内存访问(读偏移表 + 读数据),编译器无法把它优化成一条指令。这就是“虚继承性能稍差”的全部含义;它是访问虚基类成员时的代价,不是“继承本身”的代价。
机制 3:vptr 会变多
- 虚继承下 B、C 各自需要记录“到虚基类的偏移”,所以 B、C 子对象各自带一套 vptr/vbtable 指针,D 的对象里因此有多个 vptr(对应自测第 4 题“有几个虚表”的进阶版)。
什么时候真用虚继承?
- 经典场景就是菱形:
iostream(basic_istream/basic_ostream都虚继承basic_ios)、某些框架里的Object公共根。 - 游戏/引擎里反而少见,因为主流做法是接口类不含数据、不共享数据根(纯虚接口 + 组合优先),压根不会形成菱形。
- 面试标准答法:“虚继承解决菱形继承的重复子对象与二义性,代价是访问虚基类成员多一次间接寻址、构造规则更复杂,因此能用组合/接口设计规避就不用它。”
7. 运行时类型识别 RTTI
Section titled “7. 运行时类型识别 RTTI”class A { virtual ~A() = default; }; // RTTI 只对多态类型可用class B : public A { };
A* p = new B;B* b = dynamic_cast<B*>(p); // 安全下行转换:失败返回 nullptrif (b) { /* 成功 */ }
// typeid 拿类型信息const std::type_info& t = typeid(*p); // t.name() 是实现定义的:MSVC 可能是 "class B",GCC 通常是修饰名dynamic_cast:运行期安全的类型转换(基于虚表和 RTTI),失败返回 nullptr(指针)/抛异常(引用)。typeid:运行期查类型。- RTTI 存在哪:多态类型的
type_info信息就挂在虚函数表上(Itanium ABI 把指向type_info的指针放在虚表的前置槽,如-1项)。这就是”dynamic_cast/typeid只对含虚函数的类可用“的原因——没有 vptr 就没地方存类型信息。 - 开销:走 RTTI 相当于一次运行期类型查询 + 失败分支,热路径少用;游戏里更常用
static_cast(相信类型)+ 自己的类型标签(如 UE 的 UCLASS/反射系统)。static_cast下行转换不做检查,错了是 UB。
【答疑】“热路径”是什么意思?为什么少用?——对,就是“执行得最频繁的那条路径”
Section titled “【答疑】“热路径”是什么意思?为什么少用?——对,就是“执行得最频繁的那条路径””- 热路径(hot path)= 程序里被反复执行、占用大部分 CPU 时间的代码。反面是冷路径(cold path,如初始化、报错处理)。
- 游戏例子:
Tick/Update、每帧遍历所有实体的组件更新、渲染提交、碰撞检测的宽相 —— 一秒跑 60~144 次,一次处理几千个对象 → 绝对热路径。 - 反面:关卡加载、启动时资源初始化、
try/catch里的错误分支 → 冷路径,随便用什么都行。
- 游戏例子:
dynamic_cast在热路径少用的原因不只是“慢”,而是两点叠加:- 它是一次运行期库调用(
__dynamic_cast),要遍历继承图判断类型,无法内联、无法被编译期优化; - 循环里对每个对象都做一次,开销乘以对象数量,同时打断流水线、增加分支预测失败。
- 它是一次运行期库调用(
- 更本质的理由:
dynamic_cast用得越多,说明类型判断散落在业务代码里,设计上通常在往“反模式”走(该用虚函数/visitor/组件查询(如 UE 的Cast<>、ECS 的组件表)来替代)。性能只是压死它的最后一根稻草。 - 对比记忆:虚函数调用也是运行期,但它只是一次查表跳转,而且有去虚拟化、内联的机会;
dynamic_cast连这个机会都没有。所以“热路径避免 RTTI”比“热路径避免虚函数”更硬性。
8. 高频面试题 Q&A
Section titled “8. 高频面试题 Q&A”Q1:多态的实现原理? 虚函数表:每个多态类一张 vtable 存虚函数地址,对象带 vptr;运行期通过 vptr→vtable 间接调用实际类型版本。
Q2:虚表存哪?vptr 存哪? vtable 在只读数据段(每类一份);vptr 在对象内存头部(单继承下)。
Q2-补:多继承时 vptr 怎么排?(原文档只写了单继承,这里补全)
- 每个“有虚函数的基类子对象”各带一个 vptr;所以“该对象有几个虚表”≈“它有几个多态基类”。
- 主基类(primary base,继承列表里第一个动态类)在偏移 0,派生类与它共享同一个 vptr;派生类新增的虚函数、以及该基类链上的重写项,都追加/替换在这一张主表里。
- 次基类不在偏移 0:每个次基类会另起一个完整的子对象(按 dsize 对齐),该子对象的第一个字段就是它自己的 vptr,然后是它自己的成员。
易错点(我上一版写错了,这里更正):多个 vptr 通常不是紧挨着的。因为 vptr 是“子对象的第一个字段”,而每个子对象后面还跟着它自己的成员变量:
class A { public: virtual void fa(); int a; };class B { public: virtual void fb(); int b; };class C : public A, public B { public: int cc; };C 对象在 x86-64 / Itanium ABI 下的布局(逐字段推演):
偏移 内容 为什么在这 +0 A 的 vptr 主基类子对象以 vptr 打头,且与 C 共享 +8 A::a主基类的成员紧跟自己的 vptr +12 padding 4B 让 B 子对象对齐到 8 +16 B 的 vptr 次基类子对象从 +16 开始,vptr 打头 +24 B::b次基类的成员 +28 C::cc派生类自己的成员放最后 +32 padding 8B 整体补到 8 的倍数 → sizeof(C) = 40所以两个 vptr 之间隔着
A::a+ 4 字节填充(16 字节)。只有当次基类没有数据成员时(例如纯虚接口),两个 vptr 才可能真正相邻 —— 这也是“接口类不占空间”的原因之一。 虚基类(虚继承)更远:虚基类子对象统一放在对象末尾,偏移量存在虚表里。
MSVC 实测确认(本机 VS2022 14.44,
cl /d1 reportAllClassLayout):class C size(40):+---0 | +--- (base class A)0 | | {vfptr} ← A 的 vptr 在偏移 08 | | a| | <alignment member> (size=4)| +---16 | +--- (base class B)16 | | {vfptr} ← B 的 vptr 在偏移 16:不在 8,也不紧挨 A 的 vptr24 | | b| | <alignment member> (size=4)| +---32 | cc| <alignment member> (size=4)+---运行期实测(同一份程序打印地址):
pc = 0000007C5AD7F8B0pa = 0000007C5AD7F8B0 (pc → pa 调整 = 0)pb = 0000007C5AD7F8C0 (pc → pb 调整 = +16)offsetof(C,a)=8 offsetof(C,b)=24 offsetof(C,cc)=32 sizeof(C)=40接口类多继承(无数据成员):sizeof(Impl)=24,ia → ib 调整 = 8 ← 此时两个 vptr 才相邻注意报告里的
<alignment member> (size=4)是编译器为对齐显式插入的填充,不是数据成员。
Q2-补:为什么这个细节重要?——this 指针调整
class A { public: virtual void fa(); int a; };class B { public: virtual void fb(); int b; };class C : public A, public B { };
C c;C* pc = &c;A* pa = pc; // 地址与 pc 相同(A 是主基类,偏移 0)B* pb = pc; // 地址 = pc + 16!指针被"调整"到同一个对象的内部先把“一个对象几个地址”想清楚:C 对象在内存里是一整块,A 的部分在前、B 的部分在后。“子对象”就是这一整块里的各个区段。所以一个 C 对象同时有三个合法但数值不同的指针:
C 对象(40 字节)+0 ┌──────────────┐ ← pc 指向这里 │ A 的 vptr │ ← pa 也指向这里(A 在偏移 0)+8 │ A::a │+12 │ padding │+16 │ B 的 vptr │ ← pb 指向这里(同一个对象的中间!)+24 │ B::b │+28 │ C::cc │+32 │ padding │+40 └──────────────┘为什么必须是 +16:B 的虚函数要用 B 自己的 vptr 才能查到 B 的虚表;pb->b 也要以 B 子对象为基准。如果编译器直接把 pc 当 pb 用,pb->fb() 会读到 A 的 vptr、查到 A 的虚表、调到错误的函数。所以这个加法是正确性的一部分,不是装饰。
三种“看起来矛盾”的结果(面试最容易被绕进去的地方)—— 以下是 MSVC 实测输出:
| 表达式 | 结果 | 为什么 |
|---|---|---|
(void*)pa == (void*)pb |
false(0) | 转 void* 就是比数值,两者差 16 |
(void*)pa == (void*)pc |
true(1) | A 在偏移 0,转换不调整 |
pa == pb |
编译错误(MSVC C2446:B* 无法转成 A*) |
A* 和 B* 在不同继承分支、互不相关,语言层面就没有这个比较 |
static_cast<C*>(pa) == static_cast<C*>(pb) |
true(1) | 想比较“是不是同一个对象”,要先把两边转成同一个类型(这里都转回 C*,各自动一次指针调整)再比 |
实测输出:
(void*)pa == (void*)pb -> 0(void*)pa == (void*)pc -> 1static_cast<C*>(pa) == static_cast<C*>(pb) -> 1static_cast<C*>(pb) - pc = 0 // 反方向调整:从 B* 转回 C* 是减去 16
记忆口诀:“转换看数值(会加/减偏移),比较要同类型(先转成同一个指针类型再比)”。 我上一版说“不同分支指针比较会先自动转回共同派生类再比”——这台编译器不接受,标准里那条规定只适用于“其中一个是另一个的基类”的情形,不是任意两个兄弟分支。跨分支要比较,就自己显式转成同一个类型。
工程结论:
- 多继承下不要假设
(void*)pa == (void*)pb(这是数值比较),也不要自己手算偏移 —— 布局是实现定义的(虚继承下甚至要运行期查表)。 - 跨继承分支一律用
static_cast(相信类型)或dynamic_cast(运行期检查),让编译器替你调整this。 - 想眼见为实:
std::printf("%td\n", (char*)pb - (char*)pc);典型输出16;从B*转回C*则是-16。 - 完整的实测程序(可直接编译运行,Windows MSVC 与 GCC 都行)见本章配套探针:
html/_tools/vptr_probe.cpp,编译运行方式写在文件头注释里。
Q3:为什么基类析构要 virtual? 否则通过基类指针 delete 派生类对象时只析构基类部分,派生类资源泄漏。
Q4:构造/析构函数里调用虚函数会怎样? 不发生动态绑定,调用的是当前正在构造/析构的类的版本(此时 vptr 指向该类的表)。
Q5:按值传参为什么多态失效? 对象切片:拷贝构造基类部分,实际类型信息丢失。
Q6:菱形继承有什么问题?虚继承怎么解决? 二义性 + 基类子对象重复;虚继承共享一份虚基类子对象,通过偏移表定位,代价是多一次间接访问。
Q7:纯虚函数和虚函数的区别?
纯虚函数(=0)强制派生类实现,使类成为抽象类不能实例化,但基类仍可提供实现供显式调用;普通虚函数有实现,重写是可选的。
Q8:override 的作用? 让重写意图显式化,签名不匹配时编译报错,防止“想重写却变成名字隐藏/新函数”。
Q9:dynamic_cast 和 static_cast 区别? dynamic_cast 运行期检查(多态类型),失败返回 null/抛异常,有开销;static_cast 编译期决定、不检查、无开销。
Q10:虚函数为什么不能是 static? static 成员函数无 this 指针,无 vptr 可用,无法动态绑定。
Q11:空基类优化(EBO)是什么?
- 前置规则:C++ 里任何对象/子对象都必须有唯一地址,所以空类
struct E {};的sizeof(E) == 1(不是 0,否则两个空对象会共用地址)。 - EBO(Empty Base Optimization):当空类作为基类时,那 1 个字节允许被“塞进”派生类的填充字节里,不额外占空间 ——
struct Empty {}; // sizeof == 1struct WithMember { Empty e; int x; }; // sizeof == 8:成员 e 占 1 字节 + 3 字节填充struct WithBase : Empty { int x; }; // sizeof == 4!空基类被优化掉,只占 int 的 4 字节- 三条边界(面试爱追问):
- 空类作为成员不能优化(
Empty e;仍占 1 字节),只有作为基类才行;C++20 的[[no_unique_address]]才把这条路补上。 - 两个同类型的空基类不能共用同一地址,必有一个至少占 1 字节(地址必须唯一)。
- 不同类型的空基类可以重叠在同一个偏移上(地址可以相同,因为是不同类型,不冲突)。
- 空类作为成员不能优化(
- 标准库/引擎怎么用:
std::vector<T, Alloc>让Alloc当基类(实现上常用_Vector_base/_Vector_impl继承分配器),当分配器是无状态的空类时,sizeof(std::vector<T>)不会因它多出 1 字节 —— 这就是”无状态策略不付出空间代价“的技巧。游戏引擎里的 ECS/组件管理器、std::function的小对象缓冲也大量依赖这个技巧。 - 一句话答法:EBO = 空类当基类时,那 1 个占位字节可以被合并掉,使“无状态策略”零空间开销。
Q12:devirtualization(去虚拟化)是什么?什么时候能发生?
编译器把“查虚表的间接调用”改写成“直接调用某个确定函数”的优化。前提是编译期能证明动态类型:直接对象调用、final 类/final 函数、构造/析构内部、显式类名限定、LTO 全程序优化(详见 4.2)。
Q13:析构函数里调用了虚函数,会调用到派生类的吗? 不会——析构从派生到基类,析构到基类阶段 vptr 已切回基类表,调的是基类版本。
Q14:虚继承有性能开销吗?
有:访问虚基类成员要先查 vbase_offset 表拿到偏移,多一次间接寻址,编译器无法优化成固定偏移;此外虚基类的构造由最派生类负责,规则更复杂(详见 6.3 精讲)。
Q15:抽象类/接口类在工程里怎么用? 接口类 = 全是纯虚函数 + 虚析构的抽象类(模拟 Java 的 interface);游戏引擎里组件接口大量用它做“契约”,好处是解耦、可替换实现、便于跨模块(甚至跨语言绑定)——代价是每次调用都走一次虚表跳转,热路径要权衡。
9. 本章自测(10 题)
Section titled “9. 本章自测(10 题)”1. 画出一个含虚函数类的对象内存布局(含 vptr 和虚表指向)。
我的原答:我会画但是这里画不下
2. `Animal a = dog; a.speak();` 会输出什么?为什么?
我的原答:动物叫,拷贝构造会发生切片
3. 基类构造里调用纯虚函数会怎样?(链接错误/崩溃?
我的原答:基类构造时虚表还是基类的,若该纯虚函数在基类没有实现,行为未定义甚至崩溃
4. 多继承下对象有几个 vptr?
我的原答:虚表的组织?有几个继承算几个
5. 虚继承和普通继承的存储区别?
我的原答:一个在rodata段,一个在代码段
6. 为什么说"多态是运行期的"?
我的原答:静态多态和动态多态各举一例。多态在运行时确定当前的类并查找虚函数表进行调用,void foo(int)与void foo(double)、virtual void A::foo(int)与 void B::foo(int) override;
7. `final` 用在类上(`class D final : B`)和用在函数上各限制什么?
我的原答:前者是子类所有函数都不能override,后者是特定函数。
8. 抽象类能当参数类型吗(`void f(Shape&)`)?
我的原答:能 new 吗?能,不能new
9. 什么情况下编译器能对虚函数做"去虚拟化"(devirtualization)?不会
10. 解释"对象切片"和"为什么用智能指针存多态对象(unique_ptr
我的原答:对象切片发生在父类用子类进行拷贝构造的时候,只保留父类的成员;自动析构
📖 答案提示(先自己答完再展开)
- 见 2.1 图。
- “动物叫”(值拷贝切片)。
- 基类构造时调基类自己的(未实现的)纯虚函数 → 未定义行为/崩溃。
- 每个有虚函数的基类子对象各一个 vptr;各自虚表里继承链合并。
- 虚继承共享虚基类子对象 + 通过偏移表访问(多一次间接)。
- 模板重载 = 静态多态;虚函数 = 动态多态。
- 类 final 禁止继承;函数 final 禁止重写。
- 能当引用/指针参数(多态);不能 new 抽象类。
- 编译器确定对象静态类型时(直接对象、final 类、显式类型)。
- 多态对象必须通过指针/引用持有,用
unique_ptr<Animal>存派生对象并自动调正确析构。
9.1 本次作答精批(2026 记录)
Section titled “9.1 本次作答精批(2026 记录)”总评:10 题里 8 题方向正确,2 题有知识性错误(第 5、9 题),2 题需要补完整(第 7、10 题)。
| 题 | 你的作答 | 判定 | 说明 |
|---|---|---|---|
| 1 | 会画但画不下 | ✅ | 面试要求就是“现场手画”,找个纸练两遍 |
| 2 | 动物叫,值拷贝切片 | ✅ | 正确 |
| 3 | 基类构造时虚表还是基类的,若纯虚无实现 → UB/崩溃 | ✅ | 表述准确 |
| 4 | 有几个继承算几个 | 🟡 | 改成”有几个含虚函数的基类子对象就有几个 vptr“;纯数据基类不带 vptr |
| 5 | “一个在 rodata 段,一个在代码段” | ❌ | 错。rodata 段那句说的是虚表;虚继承的区别在对象布局与访问方式,不是内存段 |
| 6 | 运行期确定类型查虚表;重载/重写举例 | 🟡 | 第 2 个例子应举纯虚函数重写,你举的正是“继承+重写”,方向对;补一句“模板/重载=静态,虚函数=动态”更稳 |
| 7 | “前者是子类所有函数都不能 override” | ❌ | 说反了 + 概念错:class D final 是禁止任何人继承 D(D 是最终类),不是“D 不能重写别人”;另外 final 的类不能再被虚继承/普通继承 |
| 8 | 能,不能 new | ✅ | 正确;补一句“能定义 Shape*、Shape& 参数与成员,只是不能创建实例” |
| 9 | “不会” | ❌ | 空白点,答案见新版 4.2 表格(直接对象 / final / 构造析构内 / 显式限定 / LTO) |
| 10 | 切片解释对;“自动析构” | 🟡 | 漏了关键点:unique_ptr<Animal> 还依赖基类虚析构,否则 delete 基类指针只析构基类部分 |
| 第 5 题正确答法(背这个): |
普通继承:基类子对象直接内嵌在派生类对象里,成员的地址靠编译期固定偏移算出来。 虚继承:D 里只有一份共享的虚基类子对象(位置由最派生类决定),而且同一个
B*可能指向“独立的 B”或“D 里的 B”,偏移不固定 → 必须运行期查虚基类偏移表(Itanium ABI 放在虚表-3项 / MSVC 的 vbtable)再做一次间接寻址。代价是多一次内存访问,与“存在哪个段”无关。 第 7 题正确答法:class D final : public B→ D 不能被继承(任何类都不能再派生自 D);副作用是编译器能对 D 做去虚拟化。void f() final→ 该虚函数不能被更下层重写。 两者都只是“禁止进一步覆盖”,和虚继承无关;虚基类本身可以是 final 类(会编译错误的情况是:想继承一个 final 类)。 第 9 题正确答法(去虚拟化触发条件):见 4.2 表格,记 5 条:直接对象 / final 类或 final 函数 / 构造析构函数内部 / 显式类名限定 / LTO 全程序优化。 第 10 题补充:对象切片只解释了一半。用unique_ptr<Animal>存Dog的另一半理由是——多态基类必须有虚析构,这样delete(unique_ptr的默认删除器就是这么做的)通过基类指针销毁时,能沿虚表找到~Dog并跑完整条析构链;否则派生类资源泄漏、UB。同时智能指针还保证异常安全(提前 return / 抛异常都不会漏 delete)。
- 虚函数表在单继承/多继承/虚继承三种情况下分别怎么排布?(微软 vc 布局 vs Itanium ABI)——对应 Q2-补 与 6.3 精讲
- 为什么多继承下
reinterpret_cast出来的指针地址会不一样?→this指针调整(Q2-补) typeid和dynamic_cast的实现依赖什么(RTTI 信息存在哪)?- 接口类设计:为什么游戏引擎里组件接口大量用纯虚函数?
- C++ 里没有 interface 关键字,怎么模拟 Java 接口?多继承 + 纯虚函数 + 虚析构。