跳转到内容

05 · 继承多态与虚函数

定位:OOP 八股的核心,虚函数表机制、菱形继承、虚继承是必考且最容易讲不清的部分。 学完标准:能手画虚表结构图;能解释“为什么基类析构要虚”;能讲清菱形继承问题和虚继承解法。


  • 静态多态:编译期决定——函数重载、模板。
  • 动态多态:运行期决定——虚函数 + 继承 + 基类指针/引用。程序运行到调用点才根据对象实际类型决定调用哪个版本。
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)机制(重点中的重点,必须会画)”
  • 每个含有虚函数的类(或继承自含虚函数类的类)都有一张虚函数表(vtable),存放该类所有虚函数的地址。
  • 每个对象内部有一个虚指针(vptr),指向它所属类的虚表。
  • 调用虚函数 = 通过 vptr → vtable → 找到真实函数地址,间接跳转。
Dog 对象 d 的内存:
┌───────────────┐
│ vptr ─────────┼──→ ┌────────────────────┐
│ 其他成员 │ │ Dog 的虚表(vtable) │
└───────────────┘ │ &Animal::speak 的覆盖版│ ← Dog::speak
│ &Animal::~Animal 版 │ ← Dog::~Dog
└────────────────────┘
  • 虚表本身存在哪?只读数据段(.rodata),全程序一份,所有同类型对象共享。
  • vptr 存在哪?对象内存的起始位置(偏移 0)(单继承下;多继承时每个基类子对象各带一个 vptr)。
  • 派生类没重写 → 虚表里对应项仍是基类版本地址;
  • 派生类重写了 → 该项替换为派生类版本地址;
  • 派生类新增虚函数 → 追加到虚表末尾。

2.3 对象切片(slicing)——为什么必须用指针/引用

Section titled “2.3 对象切片(slicing)——为什么必须用指针/引用”
void f(Animal a) { a.speak(); } // 按值传 → 拷贝构造 Animal,切掉 Dog 部分 → 调 Animal::speak
Dog 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 表)。


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)?”

先拆两个容易混淆的概念

  1. inline 关键字:对虚函数没有意义也没有用。虚函数按需调用,编译器不靠 inline 决定要不要展开函数体(类内定义本来就是隐式 inline)。
  2. 函数体内联(把调用点替换成函数代码):这是优化,和 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 章“虚函数开销”)。
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("自己实现"); }
};

能用到基类实现的三种场合

  1. 派生类里显式调用Base::f(); —— 语法上直接指出函数,静态绑定,不查虚表。常见于“派生类只想在默认行为上补一点”。
  2. 基类其他函数内部调用g() 里调 f(),如果对象已经构造完成,就会正常动态绑定到派生类版本;这属于模板方法模式(基类定骨架、派生类填细节)。
  3. 纯虚析构函数必须给实现:这是标准硬性要求——
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
};

三条要记住的:

  1. 基类析构是 virtual 时,派生类析构自动成为重写,无论写不写 virtual、写不写 override
  2. 析构函数比较特殊:没有参数、没有返回类型,所以“签名写错”的空间几乎为零 —— override 在这里的防错价值远小于普通函数,但仍能防一类错误:基类析构忘了写 virtual 时,编译器会直接报 “no suitable member to override”,等于顺手把第 5 节那个 bug 也拦下来了。
  3. 析构是唯一可以隐式 override 却不需要写返回类型的成员函数;析构函数还默认是 noexcept,抛异常会 std::terminate

补充(常被追问):纯虚析构可以写 virtual ~Base() = 0;,但必须给函数体(见 4.3 答疑);delete 基类指针时先跑派生类析构,再自动跑基类析构,整条链由虚表查到的那一个入口驱动。

追问:虚析构怎么保证正确释放?→ 虚表里存的是 Derived 的析构地址,运行期查到后先执行 Derived 析构体,再自动执行成员/基类析构,整棵析构链完整。


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 找表、布局依赖实现 —— 所以工程上更推荐“单继承 + 接口/组合”。
A
/ \
B C
\ /
D
class 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 题“有几个虚表”的进阶版)。

什么时候真用虚继承?

  • 经典场景就是菱形:iostreambasic_istream / basic_ostream 都虚继承 basic_ios)、某些框架里的 Object 公共根。
  • 游戏/引擎里反而少见,因为主流做法是接口类不含数据、不共享数据根(纯虚接口 + 组合优先),压根不会形成菱形。
  • 面试标准答法:“虚继承解决菱形继承的重复子对象与二义性,代价是访问虚基类成员多一次间接寻址、构造规则更复杂,因此能用组合/接口设计规避就不用它。”

class A { virtual ~A() = default; }; // RTTI 只对多态类型可用
class B : public A { };
A* p = new B;
B* b = dynamic_cast<B*>(p); // 安全下行转换:失败返回 nullptr
if (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 在热路径少用的原因不只是“慢”,而是两点叠加
    1. 它是一次运行期库调用__dynamic_cast),要遍历继承图判断类型,无法内联、无法被编译期优化
    2. 循环里对每个对象都做一次,开销乘以对象数量,同时打断流水线、增加分支预测失败
  • 更本质的理由:dynamic_cast 用得越多,说明类型判断散落在业务代码里,设计上通常在往“反模式”走(该用虚函数/visitor/组件查询(如 UE 的 Cast<>、ECS 的组件表)来替代)。性能只是压死它的最后一根稻草。
  • 对比记忆:虚函数调用也是运行期,但它只是一次查表跳转,而且有去虚拟化、内联的机会;dynamic_cast 连这个机会都没有。所以“热路径避免 RTTI”比“热路径避免虚函数”更硬性。

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 在偏移 0
8 | | a
| | <alignment member> (size=4)
| +---
16 | +--- (base class B)
16 | | {vfptr} ← B 的 vptr 在偏移 16:不在 8,也不紧挨 A 的 vptr
24 | | b
| | <alignment member> (size=4)
| +---
32 | cc
| <alignment member> (size=4)
+---

运行期实测(同一份程序打印地址):

pc = 0000007C5AD7F8B0
pa = 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 子对象为基准。如果编译器直接把 pcpb 用,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 C2446B* 无法转成 A* A*B*不同继承分支、互不相关,语言层面就没有这个比较
static_cast<C*>(pa) == static_cast<C*>(pb) true(1) 想比较“是不是同一个对象”,要先把两边转成同一个类型(这里都转回 C*,各自动一次指针调整)再比

实测输出:

(void*)pa == (void*)pb -> 0
(void*)pa == (void*)pc -> 1
static_cast<C*>(pa) == static_cast<C*>(pb) -> 1
static_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 == 1
struct WithMember { Empty e; int x; }; // sizeof == 8:成员 e 占 1 字节 + 3 字节填充
struct WithBase : Empty { int x; }; // sizeof == 4!空基类被优化掉,只占 int 的 4 字节
  • 三条边界(面试爱追问):
    1. 空类作为成员不能优化Empty e; 仍占 1 字节),只有作为基类才行;C++20 的 [[no_unique_address]] 才把这条路补上。
    2. 两个同类型的空基类不能共用同一地址,必有一个至少占 1 字节(地址必须唯一)。
    3. 不同类型的空基类可以重叠在同一个偏移上(地址可以相同,因为是不同类型,不冲突)。
  • 标准库/引擎怎么用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);游戏引擎里组件接口大量用它做“契约”,好处是解耦、可替换实现、便于跨模块(甚至跨语言绑定)——代价是每次调用都走一次虚表跳转,热路径要权衡。


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)"。

我的原答:对象切片发生在父类用子类进行拷贝构造的时候,只保留父类的成员;自动析构

📖 答案提示(先自己答完再展开)
  1. 见 2.1 图。
  2. “动物叫”(值拷贝切片)。
  3. 基类构造时调基类自己的(未实现的)纯虚函数 → 未定义行为/崩溃。
  4. 每个有虚函数的基类子对象各一个 vptr;各自虚表里继承链合并。
  5. 虚继承共享虚基类子对象 + 通过偏移表访问(多一次间接)。
  6. 模板重载 = 静态多态;虚函数 = 动态多态。
  7. 类 final 禁止继承;函数 final 禁止重写。
  8. 能当引用/指针参数(多态);不能 new 抽象类。
  9. 编译器确定对象静态类型时(直接对象、final 类、显式类型)。
  10. 多态对象必须通过指针/引用持有,用 unique_ptr<Animal> 存派生对象并自动调正确析构。

总评: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 BD 不能被继承(任何类都不能再派生自 D);副作用是编译器能对 D 做去虚拟化。 void f() final → 该虚函数不能被更下层重写。 两者都只是“禁止进一步覆盖”,和虚继承无关;虚基类本身可以是 final 类(会编译错误的情况是:想继承一个 final 类)。 第 9 题正确答法(去虚拟化触发条件):见 4.2 表格,记 5 条:直接对象 / final 类或 final 函数 / 构造析构函数内部 / 显式类名限定 / LTO 全程序优化。 第 10 题补充:对象切片只解释了一半。用 unique_ptr<Animal>Dog 的另一半理由是——多态基类必须有虚析构,这样 deleteunique_ptr 的默认删除器就是这么做的)通过基类指针销毁时,能沿虚表找到 ~Dog 并跑完整条析构链;否则派生类资源泄漏、UB。同时智能指针还保证异常安全(提前 return / 抛异常都不会漏 delete)。


## 10. 进阶追问
  • 虚函数表在单继承/多继承/虚继承三种情况下分别怎么排布?(微软 vc 布局 vs Itanium ABI)——对应 Q2-补 与 6.3 精讲
  • 为什么多继承下 reinterpret_cast 出来的指针地址会不一样?→ this 指针调整(Q2-补)
  • typeiddynamic_cast 的实现依赖什么(RTTI 信息存在哪)?
  • 接口类设计:为什么游戏引擎里组件接口大量用纯虚函数?
  • C++ 里没有 interface 关键字,怎么模拟 Java 接口?多继承 + 纯虚函数 + 虚析构。