
前几天群里聊 C 多态三句话没说完就吵起来了。有人背出标准说法多态是同一操作作用于不同对象产生不同行为。也有人追问一句底层是不是查虚函数表然后就安静了。这种状态挺典型——语法用过面试题背过可真把 vptr 和 vtable 说清楚的没几个。这篇就把 C 多态从语法规则一路拆到底层内存布局再用一套可以自己跑起来的验证代码把动态绑定的过程看得明明白白。适合正在背 C 八股、准备面试的人也适合写了两三年业务代码但想补底层课的同学。文章不会绕弯子全是能直接拿去跑、拿去讲的东西。1. 多态到底在解决什么问题从没有多态的代码说起1.1 如果没有多态新增一个类型会牵动多少地方先做一个最简单的事写一个图形系统形状有圆、矩形、三角形都要算面积。没有多态的时候常见做法是用一个类型标记区分enum class ShapeType { Circle, Rectangle, Triangle }; struct Shape { ShapeType type; double w; // 圆当半径用矩形当宽用 double h; // 矩形当高用 }; double calcArea(const Shape s) { if (s.type ShapeType::Circle) { return 3.14159 * s.w * s.w; } else if (s.type ShapeType::Rectangle) { return s.w * s.h; } else if (s.type ShapeType::Triangle) { return s.w * s.h / 2; } return 0; }这段代码能跑但问题很明显将来加一个梯形你要改ShapeType要改结构体要改calcArea所有围着Shape做逻辑的地方都可能要跟着动。更麻烦的是调用方被迫知道每一种形状的内部差异业务代码会被一堆分支填满。多态解决的正是这个问题把“每种类型怎么算面积”这个变化点下沉到各个派生类里。调用方只依赖一个抽象接口不用关心对象到底是谁。新增类型时原有调用代码一行不用改。这也就是开闭原则说的“对扩展开放对修改关闭”。1.2 编译期多态和运行期多态是两个赛道很多人一说多态就只想到虚函数其实 C 里多态分两类面试经常混淆。编译期多态函数重载、运算符重载、模板。绑定发生在编译期编译器根据实参类型直接确定调用哪个函数没有 vtable。运行期多态通过继承加虚函数配合基类指针或引用绑定发生在运行期依据对象的真实类型决定调用哪一个版本。两者的取舍很直观编译期多态更快能内联但类型必须在编译期就确定运行期多态灵活能应对运行时才知道类型的场景代价是多一次间接寻址和虚表指针占的内存。做一个简单对比维度编译期多态运行期多态实现方式模板、重载虚函数、继承、基类指针/引用绑定时机编译期运行期性能可内联几乎没有额外开销多一次间接调用可能有 cache miss灵活性类型必须编译期确定支持运行时动态扩展典型场景泛型容器、算法插件系统、框架回调、策略模式面试时先说清楚这个边界再往虚函数表切基本不会跑偏。2. 单词规则背后的多态契约virtual、override、final 与隐藏重载2.1 virtual给编译器一个“迟绑定”的信号virtual关键字本身不抽象、不纯虚它的作用是把函数标记为“可能被派生类重写”暗示编译器调用这个函数时不要按指针或引用的静态类型去做编译期绑定要留到运行期根据对象真实类型去确定函数地址。看个最基础的结构class Shape { public: virtual ~Shape() default; virtual double area() const 0; }; class Circle : public Shape { public: explicit Circle(double r) : r_(r) {} double area() const override { return 3.14159 * r_ * r_; } private: double r_; };Shape::area后面跟了 0这是纯虚函数。含有纯虚函数的类不能再被实例化只能做基类。这相当于给接口定了一个强约束派生类必须自己实现area否则它仍然是个抽象类照样不能创建对象。纯虚函数也允许有函数体只是那样比较少见。在有函数体的情况下派生类可以显式调用Shape::area()比如在实现里先复用基类逻辑再叠加自己的逻辑。2.2 override 和 final一个替你在编译期抓错一个替你把路堵死override是 C11 引入的检查性标识符。它不改变运行行为但强制编译器检查当前函数到底是不是真的在重写基类的某个虚函数。最典型的好处是防手滑class Base { public: virtual void draw() const; }; class Derived : public Base { public: void draw(); // 忘了写const隐藏了基类函数编译不报错 };如果派生类里写成void draw() override;编译器会直接报错因为基类里没有签名完全匹配的可重写虚函数。这个成本几乎为零强烈建议所有重写函数都加上。final的含义更直接这个虚函数到此为止后面的派生类不能再重写也可以给整个类加final表示这个类不能被继承。修饰一个成员函数时编译器有时候还能把虚调用优化成直接调用属于一种性能上的额外收益。2.3 重写、重载、隐藏三兄弟必须分清楚这是 C 八股里最常被拿来坑人的点。重载 overload同一个作用域里函数名相同参数列表不同。重写 override派生类重新实现基类的虚函数签名的返回类型一般相同函数名、参数、const 限定都要匹配。隐藏 hide派生类定义一个与基类同名的函数不管参数和返回类型怎样都会把基类的同名函数隐藏掉。举个例子class Base { public: void f(int) {} virtual void g() {} }; class Derived : public Base { public: void f(double) {} // 隐藏 Base::f(int)不是重载 void g() override {} // 重写 Base::g };在Derived对象上调用d.f(1)实际匹配到的是Derived::f(double)Base::f(int)被隐藏。想让Base::f(int)重新可见需要在派生类里加一句using Base::f;。用表格总结一下关键字绑定方式签名要求关键字要求重载编译期参数不同无重写运行期基类函数必须是 virtual通常加 override隐藏编译期同名即可参数不管无这组概念是理解多态语法的基础。很多人写着写着发现“函数调错了”多半不是多态失效而是把隐藏当成了重写。3. 底层真相一vptr 和虚函数表在内存里怎么摆放3.1 对象里那个看不见的指针只要一个类含有虚函数包括继承来的虚函数编译器就会在这个类的每一个对象内存里插入一个隐藏指针习惯上叫 vptr。这个指针指向一个类的虚函数表也就是 vtable。vtable 是一个由函数指针组成的数组数组的每个元素指向该类的某一个虚函数实现。重要的是vtable 是类级别的不是对象级别的。一万个Circle对象共享同一个Circle虚表每个对象只各自存一个 vptr。因此多态的内存代价是每个对象多一个指针再加上每次虚调用多一次间接寻址。内存大致是这样Circle 对象 --------------------- | vptr -- Circle vtable | | 成员变量 r_ | --------------------- Circle vtable -------------------- | slot 0: Circle::area() | | slot 1: Circle::~Circle() | --------------------注意不同编译器在 vtable 里放内容的顺序不同比如有些会先放析构函数有些会先放第一个虚函数但“对象首地址附近存着一个指向函数指针数组的指针”这个模型在主流 ABI 上是一致的。3.2 用代码直接看 vptr 和虚表内容说了半天不如直接把虚表地址打印出来。下面这段代码不算标准行为但在 GCC、Clang、MSVC 的常见 ABI 下都能用来观察对象布局适合学习验证#include iostream class Base { public: virtual void func1() { std::cout Base::func1\n; } virtual void func2() { std::cout Base::func2\n; } }; class Derived : public Base { public: void func1() override { std::cout Derived::func1\n; } void func2() override { std::cout Derived::func2\n; } }; using Func void (*)(); void dumpVtable(const char* name, void* objPtr) { // objPtr 指向对象首地址对象里第一个内存单元就是 vptr void** vt *reinterpret_castvoid***(objPtr); Func* slots reinterpret_castFunc*(vt); std::cout name vptr vt , slot[0] reinterpret_castvoid*(slots[0]) , slot[1] reinterpret_castvoid*(slots[1]) \n; } int main() { Base b; Derived d; dumpVtable(Base , b); dumpVtable(Derived, d); Base* p d; p-func1(); p-func2(); return 0; }这段代码把对象首地址强转成void***第一层解引用拿到 vptr再解引用拿到虚函数表数组。运行之后你会看到 Base 和 Derived 的 vptr 是不同地址而且 Derived 的 slot[0]、slot[1] 指向的也是Derived::func1和Derived::func2。最后通过Base*调用func1、func2实际执行的是 Derived 的版本。明白这个后回答“多态底层是什么”就有底气了每个含虚函数的对象带一个 vptrvptr 指向当前类的虚函数表虚函数调用运行时先取对象的 vptr再从虚表对应 slot 取函数地址最后跳转执行。3.3 一次虚调用在 CPU 层面发生了什么假设一个类有两个虚函数调用p-func1()编译器生成的大致思路是从对象地址加载 vptr。根据func1在虚表中的索引取函数指针。把对象地址作为 this 传进去间接调用。对应到 x86 指令可能就是几条mov加一条call。听起来不多但它打破了 CPU 的分支预测和内联优化。普通函数调用通常可以内联虚函数调用不行。这也是为什么性能敏感代码里不能到处滥用多态。4. 底层真相二多继承和虚继承让“找表”变复杂4.1 多继承下对象里有多个虚表指针单继承的结构很干净一个对象通常只有一个 vptr。但进入多继承后问题就来了。看这段struct A { virtual void fa() {} }; struct B { virtual void fb() {} }; struct C : A, B { void fa() override {} void fb() override {} };C类对象内存里会有两个 vptr一个属于 A 子对象一个属于 B 子对象。大致布局是C 对象 ---------------- | A 子对象的 vptr | | B 子对象的 vptr | | C 自己的成员 | ----------------当代码写B* p c;时编译器不是简单地把地址原样赋过去而是把指针向后调整到 B 子对象的起始位置。这样才能让p在自己的 vptr 上找到对应的虚表。表面上这是“基类指针指向派生类对象”但底层其实发生了地址偏移。这就是this指针调整的含义之一。虚函数调用时如果 C 重写了 B 的fb而当前this指向 B 子对象编译器需要把它调整回 C 对象的起始地址再跳转到C::fb。常见的实现在这种场景下会生成一个 thunk可以理解为一个小的胶水函数先做指针修正再跳真实函数。4.2 菱形继承和虚继承绕路访问共享对象菱形继承是多继承的进阶场景struct A { virtual void f(); int x; }; struct B : A {}; struct C : A {}; struct D : B, C {};这里 A 在 D 里会出现两份D 内部既有 B 里的 A又有 C 里的 A。这种重复不仅浪费空间还很容易在类型转换时产生歧义。虚继承让 A 在最终派生类里只保存一份但代价是定位 A 子对象时不再能靠固定偏移而要通过虚基类表或类似的偏移信息去间接查。虚继承里的“虚”不是指虚函数。它更像是给继承关系加了一层间接寻址。最终派生类构造时要负责初始化虚基类访问虚基类成员时也多一次间接跳转。性能上不如普通继承但解决了菱形问题。这里不用死记具体偏移布局关键是理解一个现象多态的前提是能根据对象布局找到正确的 vptr而多继承、虚继承改变了布局方式所以编译器会做各种指针调整和间接定位。面试能讲清这一层已经比很多人深入了。5. 用一段实战代码验证动态绑定是“查表”而不是“猜”5.1 先讲设计为什么这里必须用运行期多态下面做一个可以用vectorunique_ptrShape存各种形状的小例子。之所以必须用运行期多态是因为形状类型在运行期才确定用户可能输入半径可能输入宽高程序不能提前知道要往容器里放哪种对象。如果容器存的是Shape对象本身就会发生对象切片多态直接失效。所以要存指针最好用智能指针。5.2 完整代码与运行结果#include iostream #include memory #include vector class Shape { public: virtual ~Shape() default; virtual double area() const 0; virtual const char* name() const { return Shape; } }; class Circle : public Shape { public: explicit Circle(double r) : r_(r) {} double area() const override { return 3.14159265358979 * r_ * r_; } const char* name() const override { return Circle; } private: double r_; }; class Rectangle : public Shape { public: Rectangle(double w, double h) : w_(w), h_(h) {} double area() const override { return w_ * h_; } const char* name() const override { return Rectangle; } private: double w_; double h_; }; class Triangle : public Shape { public: Triangle(double w, double h) : w_(w), h_(h) {} double area() const override { return w_ * h_ / 2; } const char* name() const override { return Triangle; } private: double w_; double h_; }; void report(const Shape s) { std::cout s.name() area s.area() \n; } int main() { std::vectorstd::unique_ptrShape shapes; shapes.emplace_back(std::make_uniqueCircle(2.0)); shapes.emplace_back(std::make_uniqueRectangle(3.0, 4.0)); shapes.emplace_back(std::make_uniqueTriangle(4.0, 5.0)); for (const auto p : shapes) { report(*p); } return 0; }输出Circle area 12.5664 Rectangle area 12 Triangle area 10关键在report(const Shape s)这个函数。它只认识Shape不知道传入的是Circle还是Rectangle。但执行s.area()时函数却准确调用了真实对象的版本。这个决策不是在编译期做的而是运行时拿到对象后通过 vptr 查到对应虚表里的函数地址再执行。5.3 把引用换成普通对象多态立刻消失如果你把report(*p)改成report(Shape(*p))编译器会如何它会创建一个Shape对象并试图从派生类对象转换到基类。这个转换能编译通过但只复制基类部分的成员派生类部分被丢掉也就是对象切片。切片后的对象的 vptr 已经被改成Shape虚表的 vptr所以调用s.area()走的是Shape的实现。这里Shape的area是纯虚函数甚至会导致逻辑错误或编译期抽象类实例化的问题。这也解释了多态的一个铁律要用指针或引用不要用对象传值。6. 最容易翻车的三个多态细节切片、构造期虚调用、虚析构6.1 对象切片为什么能编译通过却不是多态切片是很多新手踩的第一个坑。看这段Circle c(2.0); Base s c; // 这是切片s能构造成功因为派生类对象可以转换为基类对象。但s里只保留了Shape部分vptr 也指向Shape虚表。后面无论怎么调用s.area()都和Circle没关系。切片最隐蔽的地方在于它不会立刻报错而是让程序在后续逻辑里出现“看起来像多态但实际没生效”的诡异行为。判断方法很简单只要传参或赋值用的是对象本身而不是指针或引用就要警惕切片。6.2 构造函数和析构函数里调用虚函数不会多态这个坑比切片更反直觉。很多人以为在基类构造函数里调用一个虚函数会调用到派生类的重写版本因为马上要构造派生类对象了。实际不会。class Base { public: Base() { print(); } virtual void print() { std::cout Base\n; } }; class Derived : public Base { public: Derived() { print(); } void print() override { std::cout Derived\n; } }; int main() { Derived d; }输出Base Derived原因不复杂构造Derived时先构造Base子对象这时对象还没成为完整的Derivedvptr 指向的是Base虚表所以print()调的是Base::print()。等Base构造完成vptr 才切换到Derived虚表然后进入Derived构造函数这时再调用print()就是派生类版本。析构函数同理。析构时先执行派生类析构函数再执行基类析构函数进入基类析构后vptr 已经切回基类虚表所以虚函数不会往下分发。工程建议构造函数和析构函数里不要调用虚函数尤其是会被重写的虚函数。如果确实需要复用逻辑可以拆成普通非虚函数在派生类构造阶段显式调用。6.3 为什么析构函数必须要加 virtual多态场景下经常用Shape*指向Circle删除时也是通过Shape*delete。如果Shape的析构函数不是虚函数delete p只会调用Shape::~Shape()派生类部分不会被析构资源释放就容易出问题。严格说这是未定义行为实际表现通常是派生类成员的内存泄露或资源句柄没关。只要一个类会被当成多态基类使用析构函数就声明virtual ~Shape() default;。这条几乎适用于所有含虚函数的类。6.4 dynamic_cast 和 RTTI多态的另一面运行期多态依赖于运行时类型信息。dynamic_cast从Shape转回Circle或者从Shape*转Circle*需要在运行期检查真实类型。开启 RTTI 的前提下编译器会在虚表附近保留一份类型信息用于dynamic_cast和typeid。需要注意两点dynamic_cast只能用于带虚函数的多态类型。如果编译时关闭 RTTI比如某些嵌入式环境用了-fno-rttidynamic_cast就不能用了。实践中应优先靠接口设计避免不必要的下行转换。一个全是向下转型的系统说明抽象层没有设计好多态的价值会被打折扣。7. 工程里的多态什么时候该用以及怎么用更稳7.1 多态的代价不值得在所有地方付出底层看明白了就不该继续迷信“处处多态才好”。运行期多态有三笔账每个对象多一个 vptr小对象内存占用增加明显。虚函数调用很难内联每次调用多一次间接跳转。访问虚函数表可能跳到一个冷 cache 行对循环体内的热点逻辑不友好。所以类型在编译期就能确定时的替代方案可以优先考虑模板。比如template typename T double twiceArea(const T shape) { return 2 * shape.area(); }这种写法没有 vptr编译期直接绑定甚至能内联。真正需要运行期扩展、插件化、接口抽象的位置再上虚函数多态。工程上的成熟做法是两者结合框架层用虚函数定义插件接口内部热点计算用模板或std::variant加访问器。7.2 几个我踩过坑后总结出来的习惯不是每一条都能立刻改变代码性能但每条都能减少“多态突然失效”的概率。第一凡是含虚函数的类析构函数写成virtual或至少在受控的继承关系里明确基类不打算被 delete 时也要加protected析构函数防止误删。第二每个重写函数都写override。它最大的价值不是装饰而是让编译器帮你检查函数签名是否匹配。签名错了比如少了const编译器立刻报错而不是让代码静默变成隐藏。第三不要在构造函数和析构函数里调用虚函数。这个习惯比写好一个虚表还重要因为它在运行期才会爆出问题。第四传参尽量用引用返回尽量用智能指针。用裸指针返回多态对象delete 责任模糊很容易出现基类指针删派生类但析构非虚的问题。第五final能加就加。类被标记为final后编译器知道它不可能再有派生类某些虚调用可以退化成直接调用白赚一点性能。多态的底层其实不复杂一个 vptr一张虚表一次间接调用。复杂的是看透它之后还能在合适的场景克制使用。写代码这么多年我的体会是能用组合解决就少用继承能用模板解决就少用虚函数但一旦遇到真正需要开放扩展的地方虚函数多态仍然是最可靠的手段。