C++继承多态底层原理:内存布局、虚函数表与动态绑定全解析 有次带组里新人复盘代码一个小伙子把基类析构函数漏了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)是 168 字节的vptr加上两个int。哪怕你什么都不写只要类里有虚函数对象大小就一定会被撑大多出来的就是那个vptr。这个现象能解释很多问题。比如为什么“空类”一旦有虚函数就不再是 1 字节而是至少 8 字节为什么继承了带虚函数的基类之后派生类对象会突然变大为什么把派生类对象往基类变量里一赋值派生类那部分就被“切掉”了。归根结底都是布局决定的。1.3 public、protected、private 继承分别影响什么继承方式经常被忽略面试和实际代码里出现频率都不低。它的本质不是“成员有没有继承下来”而是“继承下来的成员在派生类里对外是什么访问级别”。我把结论整理成一张表继承方式基类 public 成员在派生类中的级别基类 protected 成员在派生类中的级别基类 private 成员在派生类中的级别典型用途public继承publicprotected不可访问is-a最常用protected继承protectedprotected不可访问只给更下层的派生类看实际很少用private继承privateprivate不可访问实现继承 / 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是 C11 引入的关键字它不改变程序行为它是写给编译器和读代码的人看的。如果写了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::vectorBase存放派生类对象时每个对象放进容器之前就已经被切片了容器里存的全是Base对象多态荡然无存。正确做法是存指针或者用智能指针包装比如std::vectorstd::unique_ptrBase。还有一个容易被忽略的点拷贝构造和拷贝赋值都是非虚函数。所以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都继承AD再同时继承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(多态类)为什么比成员加起来大对象里多了隐藏的vptrx64 下占 8 字节override和final有什么作用override让编译器检查覆盖是否正确final禁止进一步覆盖重载、隐藏、覆盖的区别重载看同一作用域不同参数隐藏靠名字查找遮蔽基类覆盖看虚函数签名运行期决议私有继承和组合怎么选优先组合私有继承表达实现继承不表达 is-a这些问题的答案如果都能用自己的话讲出来并且能画出对象内存布局那说明这一章原理是真正吃透了。我个人带了几年新人最大的感受是继承和多态相关的 bug十个里有八个不是语法问题而是脑子里没有内存布局图。所以建议你也别光看找个项目里现成的多态类用-fdump-class-hierarchy或者-fdump-record-layouts把它的虚表和布局打出来再结合对象的大小和地址去验证一遍。把这个动作做过一次以后vptr、虚表、切片、动态决议这些东西就再也不会是背下来又容易忘的八股了。下一篇我会接着写多继承和虚继承的完整布局把 vbptr、虚基类偏移表和构造顺序的细节全部铺开。到时候你会看到菱形继承那点事底层处理起来远比想象中有意思。