有次带组里新人复盘代码,一个小伙子把基类析构函数漏了virtual,程序跑起来看似正常,一压测内存就往上飙。我当时问了他一句:“你知道delete一个基类指针时,编译器是怎么决定调哪个析构函数的吗?”他愣了几秒,说“不是按指针类型调吗”。这就是典型的“语法会写,底层原理没通”。C++ 的继承和多态,如果只停留在背语法层面,写出来的代码将来一定会在内存布局、析构链、虚函数调用这些地方翻车。这篇文章是“继承多态原理”系列的第一篇“01”,我把最核心的那套东西——继承到底继承了什么、虚函数表怎么工作、多态调用为什么必须靠指针或引用、构造函数里为什么不能调虚函数——一次讲透,初学的能照着理解,准备面试的能拿来做八股速答,写了两年代码但说不清原理的同学,也能在这里补上最后一环。
1. 继承到底继承了什么东西:先从内存布局看起
1.1 为什么原理必须先看内存布局
很多人学继承,上来就背“派生类拥有基类的成员”,但“拥有”这个词太模糊了。它到底是在对象里复制了一份?还是只保存了一个引用?都不对。真相是:当你写class Derived : public Base之后,任何一个Derived对象的开头,都完整地躺着一个Base子对象,后面再跟着Derived自己新增的成员。这个“子对象”不是指针、不是引用,它就是把Base的成员按照Base的布局,原封不动地嵌进了Derived的内存里。
按照主流的 x86-64 平台上的 ABI 实现(Itanium ABI 和 MSVC ABI 都遵循这个基本思路),单继承下的布局顺序是:先放基类子对象,再放派生类新增成员。如果类里有虚函数,那么对象的最前面还会有一个隐藏的vptr(虚指针),存在对象起始地址处。标准本身没有强制规定这个顺序,但主流的编译器和平台全都这么干,所以你在日常开发里遇到的就是这套布局。
1.2 用代码和地址来验证布局
不看理论,直接写一段代码把地址打出来,你会看得非常清楚:
#include <iostream> class Base { public: virtual ~Base() = default; int baseVal = 1; }; class Derived : public Base { public: int derivedVal = 2; }; int main() { Derived d; std::cout << "&d = " << &d << std::endl; std::cout << "&d.baseVal = " << &(d.baseVal) << std::endl; std::cout << "&d.derivedVal = " << &(d.derivedVal) << std::endl; std::cout << "sizeof(Derived) = " << sizeof(Derived) << std::endl; return 0; }在 x64 平台上,用 g++ 或 clang++ 编译运行,你会看到&d和&d.baseVal是完全相同的地址,而&d.derivedVal比&d.baseVal大 4 字节。这是因为Base子对象在偏移量为 0 的位置,它里面有两个东西:排在对象开头、占用 8 字节的vptr,以及后边的int baseVal。随后derivedVal紧接着baseVal存放。于是sizeof(Derived)是 16:8 字节的vptr加上两个int。哪怕你什么都不写,只要类里有虚函数,对象大小就一定会被撑大,多出来的就是那个vptr。
这个现象能解释很多问题。比如为什么“空类”一旦有虚函数就不再是 1 字节,而是至少 8 字节;为什么继承了带虚函数的基类之后,派生类对象会突然变大;为什么把派生类对象往基类变量里一赋值,派生类那部分就被“切掉”了。归根结底,都是布局决定的。
1.3 public、protected、private 继承分别影响什么
继承方式经常被忽略,面试和实际代码里出现频率都不低。它的本质不是“成员有没有继承下来”,而是“继承下来的成员,在派生类里对外是什么访问级别”。我把结论整理成一张表:
| 继承方式 | 基类 public 成员在派生类中的级别 | 基类 protected 成员在派生类中的级别 | 基类 private 成员在派生类中的级别 | 典型用途 |
|---|---|---|---|---|
public继承 | public | protected | 不可访问 | is-a,最常用 |
protected继承 | protected | protected | 不可访问 | 只给更下层的派生类看,实际很少用 |
private继承 | private | private | 不可访问 | 实现继承 / has-a,优先考虑组合 |
注意一个很容易混淆的点:继承方式不影响“派生类自己的成员函数能不能访问基类的 private 成员”。能不能访问,只由基类里那个成员的访问级别决定。基类的 private 成员物理上肯定在派生类对象里躺着,但派生类的成员函数没有权限碰它。继承方式改变的是“派生类对象对外部使用者暴露了什么样的接口”。public继承表达的是严格的 is-a 关系,private继承更多是“我要借用基类的实现”,如果一个场景用组合能写,就不要用 private 继承。
2. 虚函数表:多态的真正地基
2.1 vptr 与 vtbl 到底是什么
多态的实现核心是两个东西:虚函数表(vtbl)和虚函数表指针(vptr)。每个“拥有虚函数的类”在编译期都会生成一张虚函数表,表里按一定顺序存放着这个类所有虚函数的函数指针。每个该类的对象里,会有一个隐藏的指针(也就是上面说的vptr)指向这张表。
可以这样理解:每个多态类都有一张自己的“菜单”,虚函数表就是菜单内容,vptr就是对象手里那个指向菜单的“手指”。Base对象的手指导向Base的菜单,Derived对象的手指导向Derived的菜单。调用虚函数时,程序不是直接跳到一个写死的函数地址,而是先通过对象的vptr找到菜单,再从菜单里取出对应项的地址,然后跳过去执行。菜单是编译期就填好的,查找发生在运行期——这就是多态能“按实际对象类型走不同实现”的底层原因。
vptr在对象里的位置、虚表的排列顺序,标准都没有强制规定,但主流编译器的做法高度一致:vptr在多态对象的最前面,虚表里存的是函数指针数组。了解这一点之后,很多抽象概念就落地了。
2.2 覆盖(override)到底覆盖了什么
你可能会以为,Derived覆盖Base::f()之后,基类里的f()就不存在了。实际完全不是。基类的虚函数还在基类自己的虚表里,原封不动。所谓“覆盖”,本质是:编译期给Derived生成虚表时,把f这个槽位填成了Derived::f的地址。基类的虚表不动,派生类的虚表里对应位置替换成了自己的实现。
看这个例子:
#include <iostream> class Base { public: virtual void hello() { std::cout << "Base::hello" << std::endl; } virtual ~Base() = default; }; class Derived : public Base { public: void hello() override { std::cout << "Derived::hello" << std::endl; } }; int main() { Derived d; Base& ref = d; ref.hello(); // 输出 Derived::hello Base b = d; b.hello(); // 输出 Base::hello }ref的类型虽然是Base&,但它指向的对象是Derived,对象的vptr指向的是Derived的虚表,所以查表查出来的是Derived::hello。而Base b = d这种赋值,会把d里的Derived部分整个丢掉,b就是一个纯粹的Base对象,它的vptr当然指向Base的虚表,调用结果就是Base::hello。这个对比清楚了,覆盖的底层含义就清楚了。
override是 C++11 引入的关键字,它不改变程序行为,它是写给编译器和读代码的人看的。如果写了override但实际上并没有覆盖任何基类虚函数,编译器会直接报错。养成写override的习惯,能帮你拦截大量拼错函数名或者参数类型不一致的问题。
2.3 怎么亲眼看到虚表
纸上谈兵终究有点虚,推荐两个实操手段亲眼看看虚表长什么样。用 g++ 的话,在编译时加一个 dump 参数:
g++ -fdump-class-hierarchy test.cpp -o /dev/null运行后目录里会生成类似test.cpp.002t.class的文件,里面能清楚地看到每个类的Vtable里有几个槽位、每个槽位的函数名,Derived的虚表里对应槽位指向的是Derived::hello()。用 clang++ 则这样看对象布局:
clang++ -Xclang -fdump-record-layouts -c test.cpp输出里会明确标出vptr在偏移 0 的位置,然后各个成员按顺序排列。我第一次跑完这个命令之后,对“虚函数到底存在哪里”这件事就再也没有疑问了。建议你拿到代码后都跑一次,亲眼看到的东西比背一百遍概念都牢。
3. 动态绑定:多态调用到底怎么发生的
3.1 编译期决议与运行期决议
普通函数调用,编译器在编译时看到调用点就知道该跳转到哪个函数,这叫静态决议。虚函数调用不一样,代码里写的是p->hello(),编译器在编译时无法确定p到底指向Base还是Derived。所以它会生成一段“间接调用”的代码:先从对象的vptr里读出虚表地址,再根据虚函数在表中的偏移取出函数指针,最后间接跳转。这个流程发生在运行期,所以叫动态决议,也叫动态绑定。
这两种决议方式可以放在一起对比:
| 决议类型 | 发生时机 | 依据 | 典型场景 |
|---|---|---|---|
| 静态决议 | 编译期 | 表达式的静态类型 | 普通函数、重载、非虚成员函数 |
| 动态决议 | 运行期 | 对象实际类型 + 虚表查找 | 通过指针/引用调虚函数 |
这也解释了为什么虚函数调用会比普通函数调用慢一点点:它多了“读 vptr、查虚表、间接跳转”这几步间接寻址。在性能敏感的场景里,用final关键字或把虚函数改为非虚,有时能帮助优化器做去虚拟化,但那是优化层面的细节。理解动态决议才是更重要的基础。
3.2 为什么必须是指针或引用
你没法通过“传值”实现多态。原因就是 1.2 里看到的布局:派生类对象比基类对象大,当把一个Derived对象赋值给Base对象时,赋值操作只拷贝Base子对象那部分,derivedVal和Derived的vptr都被丢掉了,这个过程叫对象切片(slicing)。切片后得到的b是一个完整的Base对象,不是“看起来像 Base 的 Derived”,它已经没有Derived的任何痕迹了。
所以在接口设计里,需要多态的地方一律传指针或引用。传引用还有一个好处:不会触发拷贝,避免了切片的隐患和大对象的复制开销。常见的错误写法是函数参数写成void foo(Base b),然后指望传个Derived进去有多态行为,结果必然事与愿违。正确写法是void foo(Base& b)或者void foo(Base* b)。
3.3 虚析构函数的必要性
当类里有虚函数、并且你可能通过基类指针来管理派生类对象时,析构函数必须是虚的。原因和虚函数调用完全一致:如果析构函数不是虚的,那么delete ptr时编译器按静态类型Base*来做析构决议,只会调用Base::~Base(),Derived里申请的资源永远不会被释放。
标准里说这种行为是未定义行为,实践中最常见的表现有两种:一种是内存泄漏,Derived构造函数里 new 出来的堆内存没人释放;另一种更加隐蔽,析构函数里如果有对Derived成员的释放操作,可能导致重复释放或者踩踏已经析构的内存。所以一条经验法则就是:只要这个类设计出来是要被继承的,而且你有可能用基类指针或引用去操作派生类对象,就一定要把析构函数写成虚析构。什么也不做的话,可以写成virtual ~Base() = default;。反过来,如果一个类不打算被继承,就不要加虚析构函数,没虚函数也就没有vptr,可以省掉那 8 个字节。
4. 原理背后最常见的坑
4.1 构造函数里调用虚函数为什么不会多态
这是面试里出现频率极高的问题:构造函数里调用虚函数,会不会调用到派生类的覆盖版本?答案是不会。而且不是“碰巧不会”,是“必然不会”。原因在于vptr的初始化时机:构造一个Derived对象时,编译器会先执行Base的构造函数,此时对象还处于“我是一个 Base”的阶段,vptr指向的是Base的虚表。等Base构造函数执行完,进入Derived的构造函数体之前,编译器才会把vptr改指向Derived的虚表。
我在实际代码里见过这个坑的变种:有人在基类构造函数里调用了一个虚函数做初始化,期望派生类“重写”这个函数来定制初始化逻辑。结果程序跑起来,派生类的定制逻辑完全没有生效,排查了很久才发现是构造顺序导致的。这个问题的正确解法是:不要在构造和析构期间调用虚函数,如果确实需要定制初始化,用工厂函数模式,构造完对象后再统一处理。
4.2 基类指针删除子类对象时,编译器到底做了什么
这个坑本质上是 3.3 的延伸,但值得单独拿出来说,因为很多人只记住了结论“要加 virtual”,却不知道不加 virtual 时编译器具体会做什么。delete p;这行代码展开后有两步:先调用析构函数,再释放内存。析构函数的调用是否可以动态决议,取决于析构函数本身是不是虚的。非虚时,不管p实际指向什么对象,都只调Base::~Base();虚时,则通过vptr查到Derived的析构函数地址,先执行Derived::~Derived(),再在这个函数内部自动向上调用Base::~Base(),形成正确的析构链。
最危险的场景不是内存泄漏,而是当析构函数里逻辑较多时,基类早就释放掉某个子系统的资源,派生类析构又去碰那些资源,程序可能在完全想不到的地方崩溃。所以排查这类问题,第一反应应该是检查“这个类的析构函数是不是虚的”。
4.3 切片问题与三/五法则的碰撞
切片问题不只发生在函数参数传值,还会发生在容器里。std::vector<Base>存放派生类对象时,每个对象放进容器之前就已经被切片了,容器里存的全是Base对象,多态荡然无存。正确做法是存指针,或者用智能指针包装,比如std::vector<std::unique_ptr<Base>>。
还有一个容易被忽略的点:拷贝构造和拷贝赋值都是非虚函数。所以Base b = d调用的不是多态机制,只是普通地调用了Base的拷贝构造函数,把Derived对象里的Base子对象部分拷贝过去。对多态类来说,如果你不想让别人复制对象造成切片或二义性,可以考虑把拷贝构造和拷贝赋值删除(= delete),只保留虚析构函数。这也是 C++ 里三/五法则和多态碰撞时最常见的处理方式:要么完整实现拷贝语义,要么彻底禁止拷贝。
5. 面试八股高频题:一口气讲清
5.1 重载、隐藏、覆盖三兄弟到底啥区别
很多人被这三个概念绕晕,其实只要抓住“作用域”和“决议时机”两条线索就清楚了:
| 概念 | 作用域 | 条件 | 决议时机 |
|---|---|---|---|
| 重载 | 同一个作用域 | 函数名相同、参数不同 | 编译期 |
| 隐藏 | 基类与派生类,跨作用域 | 派生类有同名函数,不管参数是否相同 | 编译期名字查找 |
| 覆盖 | 基类与派生类,跨作用域 | 基类是虚函数,派生类函数签名(含 const)与返回类型兼容 | 运行期 |
隐藏最容易踩坑。看这段代码:
class Base { public: void f() {} void f(int) {} virtual void g() {} }; class Derived : public Base { public: void f(double) {} // 隐藏了 Base 里的所有 f,而不是重载 void g() override {} }; int main() { Derived d; // d.f(123); // 编译报错:Derived 里根本找不到 f(int) d.Base::f(123); // 必须这样指定基类作用域 }Derived里写了一个f(double),就会把基类里所有的f都藏起来。C++ 的名字查找规则是:先在当前作用域找,找到了就不再往上找。哪怕d.f(123)中的 123 能隐式转成 double,编译器也不会去Base里找f(int)。如果想保留基类的f版本可见,可以在派生类里加一句using Base::f;。这是很多老手都会偶尔记岔的细节,面试只要考隐藏,十有八九会出一道d.f(123)能不能编译的问题。
5.2 菱形继承和虚继承,这篇先点透
菱形继承的典型结构是:B和C都继承A,D再同时继承B和C。这种情况下,D的对象里会有两份A子对象,访问A的成员时会产生歧义,sizeof(D)也会比预期的更大。解决办法是虚继承,让B和C都以virtual public A的方式继承,这样D里只有一份A子对象。
虚继承的底层实现比普通继承复杂,因为A子对象的位置被挪到了派生类对象的后面,编译器需要额外的信息(通常是vbptr虚基类指针和虚基类偏移表)才能在高层的派生类里找到那个唯一的一份A子对象。这是“继承多态原理”系列后面几篇的重点,这篇先记住结论:菱形继承有歧义,虚继承解决重复子对象问题,但代价是布局更复杂、访问更慢。实际项目中,能用组合就尽量不要把继承层次叠得又深又宽。
5.3 高频问题速查表
把这一篇涉及的知识点整理成速查表,面试前翻一眼能省很多事:
| 高频问题 | 核心要点 |
|---|---|
| 析构函数为什么要声明为虚函数 | 保证通过基类指针删除派生类对象时,能动态决议到派生类析构函数,避免资源泄漏或 UB |
| 构造函数里调用虚函数会多态吗 | 不会,调用的是当前正在构造的类的版本 |
Base b = d会发生什么 | 对象切片,d的派生类部分被丢弃,b是完整 Base 对象 |
| 为什么多态必须用指针或引用 | 传值触发切片,vptr被替换成基类虚表 |
sizeof(多态类)为什么比成员加起来大 | 对象里多了隐藏的vptr,x64 下占 8 字节 |
override和final有什么作用 | override让编译器检查覆盖是否正确;final禁止进一步覆盖 |
| 重载、隐藏、覆盖的区别 | 重载看同一作用域不同参数;隐藏靠名字查找遮蔽基类;覆盖看虚函数签名,运行期决议 |
| 私有继承和组合怎么选 | 优先组合;私有继承表达实现继承,不表达 is-a |
这些问题的答案,如果都能用自己的话讲出来,并且能画出对象内存布局,那说明这一章原理是真正吃透了。
我个人带了几年新人,最大的感受是:继承和多态相关的 bug,十个里有八个不是语法问题,而是脑子里没有内存布局图。所以建议你也别光看,找个项目里现成的多态类,用-fdump-class-hierarchy或者-fdump-record-layouts把它的虚表和布局打出来,再结合对象的大小和地址去验证一遍。把这个动作做过一次以后,vptr、虚表、切片、动态决议这些东西,就再也不会是背下来又容易忘的八股了。
下一篇我会接着写多继承和虚继承的完整布局,把 vbptr、虚基类偏移表和构造顺序的细节全部铺开。到时候你会看到,菱形继承那点事,底层处理起来远比想象中有意思。