☰
C++多态原理深度剖析:从虚函数表到对象内存布局
2026/10/13 18:44:47 网站建设 项目流程

C++的多态,说实话是我见过被讨论最多、也最容易讲"虚"的一个主题。我刚工作那会儿,在一个跨平台的渲染模块里写接口,那个项目定义了十几层继承关系,我一开始以为多态嘛,就是会画图的子类重写draw(),直到某天调试一个崩溃,发现对象被当作基类拷贝了、vptr不对了,才意识到自己连vptr和vtable都没真正搞懂。后来我花大力气把汇编、ABI、对象布局整个过了一遍,这东西才算真正"毕业"。

今天这篇文章,我就按自己当年重新学习时的路径,把C++的多态原理从语法层一路拆到内存层:从虚函数和虚表是什么,到构造析构里的坑、多继承下的this调整、CRTP静态多态,再到性能权衡和偏门排查技巧。不管你是在校学生、刚入行的工程师,还是准备跳槽想刷清楚原理的开发者,这篇都能帮你省掉大量查资料的功夫。

1. 先把"多态"这件事说清楚

1.1 多态到底解决了什么问题

很多人对多态的第一印象是"继承+虚函数",但只记住这个,等于没学。多态真正要解决的问题是:让一段代码在编译时不用知道某个变量的具体类型,就能在运行时正确调用属于那个类型的行为。

我举一个特别现实的例子。假设你维护一个游戏引擎的输入系统,手柄、键盘、触屏三套设备都要上报事件。没有多态时,你可能会写一个EventHandler,里面塞满switch:

enum class DeviceType { Keyboard, Mouse, Touch }; void handleEvent(DeviceType t, const Event& e) { if (t == DeviceType::Keyboard) { // 键盘逻辑 } else if (t == DeviceType::Mouse) { // 鼠标逻辑 } else if (t == DeviceType::Touch) { // 触屏逻辑 } }

问题在哪?每加一种设备,你都要钻进这个switch里改代码。多个地方需要分发时,switch会复制粘贴得到处都是。一旦改动出错,影响面是不可控的。

用多态重写之后,核心变成"所有设备共用一套接口":

class InputDevice { public: virtual ~InputDevice() = default; virtual void handleEvent(const Event& e) = 0; }; class KeyboardDevice : public InputDevice { public: void handleEvent(const Event& e) override { ... } };

上层代码只依赖InputDevice。以后新增什么新设备,写一个子类,然后从工厂里注册进去,旧的设备处理代码一个字符都不用动。这就是面向接口编程的核心:调用方只跟抽象类打交道,不要在代码里到处写"具体是什么设备"。

1.2 静态多态和动态多态的分野

聊多态之前,必须先分清两种不同的"多态",因为很多初学者把二者混在一起,导致后面的理解全乱。

  • 编译期多态(静态多态):函数重载、运算符重载、模板。比如std::sort可以排vector<int>,也可以排vector<string>,排序算法本体是一样的,但类型不同编译出来的代码不同。决策发生在编译期。
  • 运行期多态(动态多态):继承 + 虚函数。调用哪个版本的函数,取决于运行期对象到底是什么类型。决策发生在运行期。

这两种不是竞争关系,是互补关系。模板适合"类型在编译期就完全确定"的场景,比如算法库、容器库;虚函数适合"类型在编译期可能变化"的场景,比如插件系统、设备管理、UI框架。后面第6节我还会具体讲CRTP这种用模板模拟出来的静态多态,它和虚函数的关系尤其微妙。

如果用一个生活化类比来感受:静态多态像是开会前就把每个人的座位写在Excel里,编译完成就已经定型;动态多态像是到了现场扫码签到,系统扫完才知道这个人该坐哪一排。前者快但死板,后者灵活但每次都要查一下。

2. 动态多态的地基:虚函数与虚表(vtable)

2.1 虚函数是怎么被编译器调度的

假设有下面这段代码:

#include <iostream> class Base { public: virtual void show() { std::cout << "Base::show\n"; } }; class Derived : public Base { public: void show() override { std::cout << "Derived::show\n"; } }; void callShow(Base& obj) { obj.show(); } int main() { Derived d; callShow(d); }

关键在于callShow接收的是Base&,它编译时完全不知道传入的到底是什么,但最终却打印出了Derived::show。这是怎么做到的?答案就是虚函数表(vtable)和藏在对象里的虚表指针(vptr)。

当一个类里出现第一个虚函数(或者从基类继承了虚函数)时,该类会生成一张vtable。这张表里记录了该类所有虚函数的函数地址。同时,这个类的每一个对象里会多出一个指针——vptr,指向该类的vtable。

在主流平台上(Itanium ABI),对象的内存布局大致是这样:

Derived对象 (栈上/堆上): +---------------------+ | vptr (8 bytes) | --> Derived的vtable +---------------------+ | Base 的成员变量 | +---------------------+ | Derived 的成员变量 | +---------------------+

而vtable大致是这样:

Derived的vtable: +-------------------------------+ | offset-to-top | (用于RTTI计算) +-------------------------------+ | typeinfo pointer | (指向type_info) +-------------------------------+ | show() 的地址 | --> &Derived::show +-------------------------------+

obj.show()这句代码在底层会变成三步:先从obj取vptr,再从vptr指向的vtable里偏移位置取出函数地址,最后间接跳转执行。也就是说,它不是像普通函数那样编译时算好地址直接call,而是必须到运行期查一次表。

注意:vptr放在对象内存的什么位置,C++标准并没有规定,是ABI决定的。在GCC/Clang的Itanium ABI里,vptr通常放在对象开头;在MSVC的ABI里,早期版本习惯放在对象末尾。所以写代码不要假设vptr位置,也不要自己去摸内存里的这个指针,那是ABI的事,不是语言的事。

2.2 对象切片:多态失效的最经典场景

理解了vptr,就能顺手解释一个新手必踩的坑——对象切片(slicing)。

void wrongCall(Base obj) { // 注意是按值传参 obj.show(); // 这里永远调用Base::show } Derived d; wrongCall(d); // 实际上发生了切片

wrongCall(Derived)时,为了让参数变成Base,编译器会把Derived对象里属于Base的部分拷贝出来。这时新对象里被拷贝过来的vptr指向的是Base的vtable,不是Derived的。所以说白了,"切片"这个操作天然就丢掉了动态类型信息,多态失效是必然的。

解决方式也很简单:传引用或指针。

void okCall(Base& obj) { obj.show(); // 动态分发正常 }

还有一种更隐蔽的切片发生在容器里。std::vector<Base>去接收一堆Derived对象时,每个元素都会变成Base子对象。要存多态对象,应该用std::vector<std::unique_ptr<Base>>或std::vector<std::shared_ptr<Base>>。

2.3 vptr在构造和析构过程中的变化

这也是一个极其重要、极其容易被忽视的细节:构造函数和析构函数里调用虚函数,不会发生动态分发。

原因跟vptr的维护时机有关。基类构造函数执行时,对象还只是"基类形态",vptr指向基类的vtable;等基类构造函数执行完,编译器再把vptr改为指向派生类的vtable,派生类自己的构造函数才继续执行。析构则相反——派生类析构先执行,此时vptr还指向派生类vtable;一旦进入基类析构,vptr又被改回基类的vtable。

所以,千万不要在构造函数里调用虚函数指望触发子类逻辑,你调到的永远是当前正在构造层次的那个版本。C++标准把这种行为定为"当前构造/析构层次"的版本,就是为了避免在派生类还没构造好时去访问它不完整的成员变量。这个坑我在第4节会展开细讲。

3. 有了虚函数之后,还差这几个关键词:override、final、纯虚函数

3.1 override不是装饰品

很多老代码在没有override的年代,靠注释标"重写"两个字。问题是,如果子类想重写Base::show(),结果不小心把签名写成void show(int),编译器会怎么处理?它不会报错,而是会把show(int)当成一个全新的函数,同时隐藏掉基类的show()。

这种叫"隐藏(hide)"而不是"重写(override)"。区别在于,通过基类指针调show()时,因为虚函数表里根本没覆盖那个版本,调到的还是基类的实现。等你发现结果不对,往往是线上出了诡异行为之后。

C++11引入override就是干这个的:你告诉编译器"我就是要重写父类虚函数",如果签名对不上,编译直接报错。

class Derived : public Base { public: void show() override { ... } // 编译器检查确实是重写 void show(int x) { ... } // 这是隐藏,但如果没有override,很容易混淆 };

我还见过一个隐藏问题:基类虚函数是void show() const,子类漏写了const,结果变成void show(),重写失败。如果没有override,这段代码编译过了,运行行为却还是基类版本。加了override,编译器第一行就告诉你写错了。

3.2 纯虚函数与抽象类:接口设计的基础

纯虚函数用= 0声明,包含纯虚函数的类叫抽象类,不能直接实例化。它的意义在于:我只能约束"你必须有这个能力",但不负责实现它。

class Renderable { public: virtual ~Renderable() = default; virtual void draw() const = 0; virtual void update(float dt) = 0; };

这样设计的好处是"依赖倒置":高层的渲染循环只依赖Renderable这个抽象概念,不依赖任何一个具体物体。以后加一个ParticleSystem、加一个Terrain,渲染循环不需要跟着改,只需要它们实现draw和update。在C++里没有专门的interface关键字,大家习惯把"只有纯虚函数+虚析构"的类称为接口类。

设计接口类时有几个经验:

  • 析构函数写成虚的。否则delete一个基类指针时,派生类析构不会执行,会导致资源泄漏。
  • 不要给接口类放数据成员。接口应该只描述行为,一旦放数据,就等于强行绑定了实现方式。
  • 考虑给接口类一个protected或空的析构实现,避免为什么基类是=0在delete时连基类析构都调不到(其实只要虚析构有定义就行,这里要提醒:纯虚析构也需要实现,否则链接会失败)。

final的作用则反过来。它告诉编译器:"这个类/虚函数不允许再被继承或重写。"它能给编译器提供去虚拟化(devirtualization)的优化机会,后面讲性能时会提到。

4. 构造析构、虚析构:多态最容易翻车的地方

4.1 为什么在构造/析构里调用虚函数,调用的是当前类版本

先看代码:

class Base { public: Base() { print(); } virtual void print() { std::cout << "Base\n"; } }; class Derived : public Base { public: void print() override { std::cout << "Derived\n"; } }; int main() { Derived d; }

很多人以为会打印Derived,实际打印的是Base。

原因就是前面提到的vptr时序:基类构造开始时,对象内存里还没有Derived的成员,甚至Derived自己的虚函数表地址都还没写进去,所以vptr指向Base的vtable,调用print()只能从这个表里找函数,找到的就是Base::print。如果这时调的是Derived::print,而Derived::print去访问Derived才有的成员变量,那些变量还没构造,就会踩到未初始化内存。这种规则本质上是C++在保护你。

那如果确实需要在构造阶段做"类似动态多态"的事情呢?业内常用的方案是两段式构造或者工厂函数:

class Base { protected: Base() = default; public: static std::unique_ptr<Base> create() { auto obj = std::unique_ptr<Base>(new Derived()); // 此时动态类型已经完整,可以安全调用虚函数 obj->afterConstruct(); return obj; } virtual void afterConstruct() {} };

这种"先构造完,再统一做初始化"的方式,比在构造里调用虚函数干净一百倍。

4.2 基类析构函数为什么必须是虚的

这个问题几乎是每场C++面试的保留节目,也是实战里"内存泄漏不报错"的头号来源。

class Base { public: ~Base() {} // 非虚 }; class Derived : public Base { int* data = new int[1024]; public: ~Derived() { delete[] data; } }; Base* ptr = new Derived; delete ptr; // 惨了

delete ptr时,编译器通过静态类型Base*来查析构函数。如果Base::~Base()不是虚函数,它调用的就是Base的析构,而Derived的析构根本没机会执行,data指向的内存直接泄漏。

如果把Base析构写成virtual ~Base() = default;,情况完全不同:析构通过vptr去vtable里查,找到的是派生类的析构,于是先执行Derived::~Derived释放内部资源,再按析构的反序依次执行基类析构。这条规则是:析构顺序永远是从最派生类往基类走,而虚析构保证了"入口"在最派生类。

我自己的经验是,只要一个类打算被继承,析构就一律写成虚的(哪怕它没有资源也要写),这是最省心的习惯。唯一的例外是那些final类,既然不允许扩展,析构不需要虚。

4.3 析构顺序入门

还要说一下具体的析构顺序,因为很多面试题的变形都从这里出:

  1. 执行Derived自身的析构函数体;
  2. 按声明逆序析构Derived的成员变量;
  3. 继续执行直接基类Base的析构函数体;
  4. 按声明逆序析构Base的成员变量;
  5. 如此递归直到所有基类析构完成。

成员析构在类析构之后,这一点尤其关键。假如派生类析构函数里去访问一个成员变量,正在析构的成员变量本身没问题,但如果你这么写:

~Derived() { helper_.process(); // helper_还活着 }

这是安全的。但如果你在Base析构里调用一个暴露派生类成员的虚函数,那个成员可能已经被析构了,行为就是未定义。这也是"不要在析构里调虚函数"这句话的根本原因。

5. 多继承下的多态:vptr从一个变成一群

5.1 this指针调整与多个vptr

单继承下,每个对象只有一个vptr,逻辑很清楚。一旦进入多继承,事情就开始变得刺激了。

class A { public: virtual void a() {} int x; }; class B { public: virtual void b() {} int y; }; class C : public A, public B { public: void a() override {} void b() override {} };

C的对象内存里,会按继承顺序存放A和B的子对象,每个子对象各自带vptr,所以C对象通常有两个vptr:

C对象: +------------------+ | A子对象: vptr | --> A的vtable(其中的a()被替换为C::a) | A子对象: int x | +------------------+ | B子对象: vptr | --> B的vtable(其中的b()被替换为C::b) | B子对象: int y | +------------------+ | C自己的成员... | +------------------+

这里有一个很微妙的点:如果有一个B*指向C对象,这个指针并不指向C对象内存的开头,而是指向B子对象那个位置。当通过这个B*调用虚函数b()时,实际执行的是C::b(),而C::b()里如果访问了this->y,this必须是C对象的地址,不能是B子对象的地址。所以编译器必须在调用虚函数之前,把this指针从B子对象的位置"调整"回C对象起始位置。

这个调整在ABI层面由一个叫"thunk"的小代码块完成:先按固定偏移调整this,再跳到真正的C::b()。这也是多继承虚函数调用比单继承慢一点的原因之一。

5.2 菱形继承与虚继承:能不用尽量不用

菱形继承是多继承里最经典的问题:

class Base { public: virtual void f() {} int data; }; class Left : public Base {}; class Right : public Base {}; class Bottom : public Left, public Right {};

Bottom里会包含两份Base子对象,于是data变成两个,Base*指向不明确,bottom.f()也产生歧义。哪怕你加using去消除歧义,对象的布局也依然是两份Base,浪费内存,语义还拧巴。

解决菱形继承的标准方案是虚继承:

class Left : virtual public Base {}; class Right : virtual public Base {};

虚继承会让Base子对象在Bottom里只有一份,编译器会通过额外的偏移信息(如vbase offset)找到它。听起来挺好,但代价是布局更复杂、访问虚基类成员更慢,而且虚基类的初始化规则很反直觉:虚基类必须由最派生类负责初始化,不看它在初始化列表里的位置。

我的建议很直接:业务代码里常规的多重继承能不用就不用。像接口类这种没有数据成员的纯抽象类做多重继承,问题不大;一旦多个父类都带状态,麻烦成倍增长。优先用组合,把公共部分做成成员对象,再考虑继承。

5.3 dynamic_cast的底牌

dynamic_cast是多态世界里最依赖运行期信息的转换工具。它的工作方式基于RTTI(Run-Time Type Information)——vtable里那个typeinfo指针对应的信息。转换的本质是:拿着当前对象的动态类型信息,沿继承树去查目标类型,能查到就成功,查不到就失败。

Base* b = getObject(); if (Derived* d = dynamic_cast<Derived*>(b)) { // 类型匹配,d指向转换后的对象 } else { // 类型不匹配,返回nullptr }

这里有几个实操要点:

  • dynamic_cast只对"多态类型"(至少有一个虚函数)管用。没有虚函数的类无法进行RTTI查询,编译会报错。
  • 指针转换失败返回nullptr;引用转换失败会抛std::bad_cast。
  • dynamic_cast<Base*>这种向上转换等价于隐式转换,用静态转换也行。
  • 跨层级转换(侧转、向下转换)才是它的价值所在,但每次都要查运行时类型表,开销明显高于普通转换。性能敏感的热路径,能少用就少用。

顺带提一句,关闭RTTI的编译环境(比如某些嵌入式平台用-fno-rtti)下,dynamic_cast和typeid都没法用。这时如果还想做类型判断,只能自己设计类型ID或者依赖虚函数接口来规避。

6. 静态多态也有春天:CRTP与模板

6.1 CRTP的原理与一个登录校验的例子

CRTP全称Curiously Recurring Template Pattern,中文常叫"奇异递归模板"。它的核心就是把派生类自身作为模板参数传给基类:

template <typename Derived> class ValidatorBase { protected: ~ValidatorBase() = default; public: bool validate(const std::string& input) const { return static_cast<const Derived*>(this)->doValidate(input); } }; class EmailValidator : public ValidatorBase<EmailValidator> { public: bool doValidate(const std::string& input) const { return input.find('@') != std::string::npos; } }; class PhoneValidator : public ValidatorBase<PhoneValidator> { public: bool doValidate(const std::string& input) const { return input.size() >= 11; } };

调用validator.validate(str)时,validate里通过static_cast把this强转成Derived*,然后调用doValidate。这个转换在编译期就确定,最终生成的代码等价于直接调用EmailValidator::doValidate,没有vptr查表,也不会有间接跳转,编译器还能内联。

CRTP最常用的场景是"代码复用得自然而不用付出运行时代价":比如给所有派生类统一提供比较运算符、克隆函数、访问器接口等。它的缺点也很明显:不是真正的运行期多态,你不能用一个ValidatorBase<T>统一持有多种验证器。也就是说,CRTP把多态从"值可变、类型不易变"变成了"类型可变、但必须在编译期定死"。

6.2 动态多态和静态多态到底怎么选

实际工程里,这两者并不是对立的,而是对症下药:

维度动态多态(虚函数)静态多态(CRTP/模板)
决策时机运行期编译期
性能有查表和间接跳转成本可直接内联,通常更快
类型容器可以用基类指针/引用统一持有很难放入同一个容器
可扩展性运行时插件友好需要重新编译才能支持新类型
示例设备驱动、UI框架算法复用、仿函数、编译期策略

我的经验是:凡是类型集合在编译期已知、需要极致性能的代码,优先考虑静态多态;凡是需要运行期加载新类型、跨越动态库边界、强调可插拔性的,老老实实用虚函数。很多团队忽略第一条,一上来全部virtual,最后发现热路径里到处是虚调用,只能靠final去碰运气优化。

7. 多态的性能账:虚函数调用到底贵不贵

7.1 一次虚调用藏了哪些成本

要讨论性能,不能只说"虚函数慢",得把成本拆开:

  • 两次内存读取:一次读对象的vptr,一次从vtable取函数地址。普通函数调用编译期就能确定地址,省掉这两次访存。
  • 间接跳转:虚调用是call [rax]这种间接调用,现代CPU的分支预测器遇到间接跳转比直接跳转更难命中,可能造成流水线停滞。不过在CPU的分支目标缓冲(BTB)加持下,稳定调用同一个虚函数时预测率通常很高,不是每次都会付出惨重代价。
  • 无法内联:编译器在不知道对象具体类型时没法内联虚函数体。对get()这种两三行的小函数,内联带来的收益尤其明显,虚调用直接把这个收益送走。
  • 某些情况下的去虚拟化失败:虽然现代编译器可以对final类或已推导的类型做devirtualization,但一旦对象通过未知来源传入,优化就失效。

需要强调的是,普通业务代码中,虚调用的成本占比很低。真正困扰你的是两个场景:超高频率的热点路径(比如每帧遍历几万个对象做更新),或者虚函数本身极短(比如简单的getter)。这种时候,虚调用反而比函数体内的逻辑还贵,就要考虑优化了。

7.2 几个实打实的优化方向

  1. 利用final:给不再可能被继承的类加final,编译器在能看到final类型的时候,可以把虚调用优化成直接调用甚至内联。
  2. 减少分摊虚调用的粒度:不要设计成"每个元素一个虚函数调用",改成"批量处理时一次性多态再到内部用static分支",把虚调用从N次降到1次。
  3. 把热点小函数改成模板/非虚:如果一个函数被频繁调用且类型在编译期可知,用模板或者把它从虚函数表里拿出去。
  4. 避免dynamic_cast反复使用:大量dynamic_cast下坠是性能黑洞,能改成虚函数接口解决就不要转。
  5. 性能分析先行:不要一上来就优化虚函数。先跑profiler,确认热点确实验在虚调用上,再动手,不然是白忙。

实际开发里,我见过一个比较典型的案例:一个场景管理器每帧对几千个对象做更新,每个更新都走一层虚调用。后来优化方案是把"更新"拆成"预过滤"和"更新"两步,预过滤用type_id做快速分类,只有符合条件的对象才走虚函数更新,帧耗时下降了明显一个档次。道理不难,难的是先发现问题出现在虚调用上。

8. 常见问题与排查技巧实录

8.1 多态相关问题的速查表

现象可能原因处理方法
通过基类指针delete后内存泄漏基类析构不是虚的给基类加virtual ~Base() = default;
调用虚函数调到的却是基类版本参数按值传递发生切片改成传引用/指针
多个虚函数子类想重写却没生效漏写override导致隐藏加上override,编译期检查签名
dynamic_cast总返回nullptr类型确实不匹配,或者目标类有多继承路径歧义检查继承关系,必要时用static_cast并手动验证
构造/析构里调虚函数行为不对vptr指向当前构造层级改用工厂函数或两阶段初始化
出现了莫名多份基类数据钻石继承导致重复继承基类改用虚继承,或者重新设计组合关系

8.2 高频面试题与思路

问:构造函数里调用虚函数为什么不动态分发?

回答思路:基类构造期间,派生类还没有构造完成,vptr此刻指向基类的vtable,因此调用的必然是基类版本。这是标准为了保证安全而规定的行为——如果动态分发到派生类,派生类使用的成员可能还没初始化。

问:重载、隐藏、重写有什么区别?

  • 重载:同名函数在同一个作用域内,参数列表不同;
  • 隐藏:派生类出现与基类同名的函数,即使参数不同,也会让基类同名函数不可见;
  • 重写:虚函数签名匹配(包括const限定、引用限定等),在vtable里覆盖基类版本。

问:基类的虚函数有默认参数,通过派生类对象调用时用的是哪个默认参数?

这是一个偏门但常见的陷阱:虚函数按静态类型绑定默认参数。就是说Base*指向Derived对象,调用虚函数不传参时,使用的默认参数来自Base的声明,而不是Derived的声明。因为默认参数是编译期确定的,不参与动态绑定。实战里我强烈建议不要给虚函数写非平凡的默认参数,容易把人坑到怀疑人生。

问:为什么不能有虚构造函数?

虚函数机制依赖vptr,而对象没构造完成时vptr都不存在,哪来的虚构造函数。构造一个对象必须从最基类开始确定类型,这和运行期动态决定类型是矛盾的。

问:为什么模板成员函数不能是虚函数?

虚函数表在编译期需要固定每个槽位的地址,但模板函数只有在实例化后才存在,编译器无法在编译该类时预知要实例化出哪些版本的虚函数,所以标准禁止"模板虚函数"。

8.3 我踩过的一个多态调试坑

这里分享一个我印象很深的debug经历。某次我在排查一个跨模块传对象的问题,现象是对象在A模块创建,在B模块调用虚函数时行为完全不对,甚至崩溃。起初我以为是虚函数表被写坏了,后来一查发现是跨动态库的虚表不统一:A模块和B模块编译时使用的编译器版本不一致,导致类布局或RTTI结构有差异,两边看到的vtable布局对不上。从那以后,我特别留意跨模块边界的类,要么用接口加稳定的ABI,要么干脆用纯C风格回调函数做边界,避免这种隐形的坑。

还有一次,我在一个老项目里看到一个类从"抽象基类"继承了两层,最下面一层重写虚函数时忘了加override,结果改了一天数据不对的bug,最后发现是基类签名从void handle(const Event&)被改成了void handle(Event&),子类还是旧签名,所有调用都走的是基类的空实现。这类问题现在用override一行代码就能防住,但在老代码里排查起来极其痛苦。

所以多态这条路上,最重要的其实是养成好习惯:该虚析构就虚析构,该override就override,能传引用就别传值,能在构造后初始化就别在构造里搞虚调用。把这些习惯内化之后,C++多态的坑你基本就绕开了一大半。

回头再看"一篇即毕业"这个标题,我觉得C++多态这个知识点本身并不难,难的是把"语法→内存→性能→工程"这条链路打通。很多人卡在只会写虚函数,不知道vptr怎么变;还有人知道vptr,却不知道多继承下会有多个vptr和this调整。把这两层补上,再加上构造/析构时序和静态多态的对比,你在面试和实战里都不会再被问倒。这篇文章写到的所有代码,我建议大家都在自己机器上编译跑一遍,用调试器看一遍对象内存,看到vptr真实地指向那个表,比你看多少篇讲解都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询