C++多态实现原理:虚函数、纯虚函数与基类指针深度解析
Th3.8 这个编号一看就是某个面向对象课程或讲义系列里的固定节次。标题把三个高频考点放在一起——基类和子类指针、虚函数与纯虚函数、多态性其实这三者讲的是同一件事继承体系下编译器如何决定一次调用最终调用到谁的函数。我见过太多人背概念时头头是道一写代码就露馅最常见的是把子类对象交给基类指针后调出来的函数完全不是预期那个或者delete一个猫对象却只执行了动物的析构函数内存泄漏了还一脸茫然。这篇文章我按自己学习和带人时的思路来写不按教材目录平铺。先把指针的类型兼容问题讲透再进虚函数的底层机制然后是纯虚函数与抽象类最后用一段完整示例收拢多态的真实用法。每个部分我都会放能直接跑的代码、容易踩的坑以及为什么会这样的底层原因。适合正在啃 C 面向对象、被各种概念绕晕的初学者也适合想把这几个知识点串成体系的人。1. 基类指针与子类指针先搞懂类型兼容这条铁律1.1 向上转型为什么总是安全的先看一段最经典的代码class Animal { public: void eat() { cout Animal::eat endl; } }; class Cat : public Animal { public: void catchMouse() { cout Cat::catchMouse endl; } }; int main() { Cat cat; Animal* a cat; // 子类指针/对象 赋给 基类指针 a-eat(); // 编译通过调用 Animal::eat return 0; }Animal* a cat;这就是向上转型upcasting也叫向上造型。很多初学者会疑惑我明明创建了一只猫为什么用一个动物指针指过去这会不会丢信息答案是不会丢而且编译器百分百允许。原因在内存布局。一个Cat对象在内存里并不是猫这种独立结构而是由两块拼起来的基类子对象Animal 的部分加上子类自己的成员Cat 的部分。基类子对象通常排在对象内存布局的前面部分所以你的Animal*指向Cat对象的起始地址时编译器只需要做一次隐式偏移把这个指针解释成只访问对象前部的 Animal 部分。换句话说Animal* a cat;是你主动告诉编译器我现在只按动物来看待这只猫。 从类型的角度猫是一种动物所以这个转换成立从内存的角度子类对象里面必定包含完整的基类部分所以也不会越界访问。这就是它安全的根本原因。这里还要纠正一个常见误解向上转型不是把猫变成动物并丢掉猫的特征。对象本身还是CatcatchMouse方法还在只是当前这个Animal*看不到它。想看到转回来就行但转回来就不是白送的了见下一节。1.2 向下转型能编译不代表能运行方向反过来就麻烦得多Animal* a new Cat(); Cat* c static_castCat*(a); // 编译能通过问题来了如果a实际指向的并不是Cat而是一只Dog呢class Dog : public Animal {}; Animal* a new Dog(); Cat* c static_castCat*(a); // 编译通过但运行期访问 c-catchMouse() 就是未定义行为这里static_cast是编译期的我信任你式转换编译器不做任何运行时检查。你告诉它这个动物指针其实指向猫编译器就信了直接把地址交给你。万一你判断错了指针指向的内存区域根本不是猫的布局后面调用catchMouse就是在错误的内存上干活轻则得到垃圾数据重则直接程序崩溃。这就是**向下转型downcasting**的风险本质子类不一定有基类指针指向的对象可能压根不含子类特有的那部分。教材里常说一句向下转型不安全但不说全导致很多人以为只要编译过了就安全。真实情况是static_cast的向下转型必须配合程序员自己的判断力你要百分之百确定这个对象确实是目标类型否则就是自己给自己埋雷。那有没有安全的方式有dynamic_cast后面第 4 章专门讲。1.3 切片使用基类对象接收子类对象的隐藏坑指针讲完了再补一个几乎所有初学者都会踩的坑用基类对象来接子类对象。Animal a Cat(); // 注意不是指针是对象赋值这一行能编译但和指向完全是两码事。Cat对象里除了 Animal 部分还有自己特有的成员而a这个变量的内存空间从一开始就是按Animal的大小分配的放不下整只猫。所以编译器会做一个操作只把 Animal 部分拷贝过去Cat 特有的成员全部丢弃。这就是著名的对象切片slicing。直观感受一下class Cat : public Animal { public: int lives 9; // 猫特有的九条命 }; Cat c; Animal a c; // lives 没了a.lives 根本不存在你可以把切片理解为拿一个小盒子去装一个大物件装不下的部分被强制留在外面。这带来两个后果第一子类数据丢失第二如果后面再想从a还原出Cat数据已经不完整了。所以涉及继承体系的传参、赋值、容器存储几乎一律要使用指针或引用而不是基类对象。vectorAnimal装猫装狗装进去全是切片后的动物这个错误在项目里非常常见。2. 虚函数真正的底层机制编译期看不到的那张表2.1 静态绑定和动态绑定的分界线现在做个实验给之前的Animal和Cat都加一个同名方法speak()看通过Animal*调用到底执行哪个class Animal { public: void speak() { cout Animal::speak endl; } }; class Cat : public Animal { public: void speak() { cout Cat::speak endl; } }; int main() { Animal* a new Cat(); a-speak(); // 输出 Animal::speak不是 Cat 的 return 0; }反直觉的输出出现了。明明指向猫调speak却执行动物的版本。原因在于a的静态类型是Animal*编译器在编译期间看到a-speak()时只按Animal 里有没有 speak来解析找到了就直接绑定到Animal::speak。这叫静态绑定也叫早绑定在编译阶段就把调用地址确定死了。这就是虚函数出现之前 C 处理继承关系的方式同一个名字在子类里重新定义各自调用各自版本。通过基类指针只能调到基类的那个版本。要让同一行代码执行不同函数必须引入动态绑定也就是虚函数。把speak前面加上virtualclass Animal { public: virtual void speak() { cout Animal::speak endl; } }; class Cat : public Animal { public: void speak() { cout Cat::speak endl; } }; int main() { Animal* a new Cat(); a-speak(); // 输出 Cat::speak return 0; }为什么加了virtual就有截然不同的表现因为编译器不再敢在编译期拍板就是这个函数地址了。它改成运行时去对象里查一张表表里记录着这个对象真正的 speak 函数应该在哪里。这位主角就是虚函数表。2.2 虚函数表与对象头部的隐藏指针主流 C 编译器实现虚函数的方式高度一致每个含有虚函数的类会生成一张虚函数表vtable本质是一个函数指针数组每个对象实例会在内存开头多出一个隐藏指针vptr这个指针指向它所属类的那张虚函数表。流程是这样的定义Cat cat;时cat的内存布局变成[vptr][Animal 成员][Cat 成员]vptr 指向Cat类的 vtable。Cat类的 vtable 里记录speak函数指针 →Cat::speak的真实地址。调用a-speak()时编译器生成的代码先取对象头部的 vptr再通过 vptr 找到 vtable再取出speak条目的函数指针最后间接调用。这个查表再跳转的过程就是动态绑定。动态的含义在于决定权推迟到运行期由对象头部的 vptr 来决定最终去哪。有一个很容易被忽视的细节vptr 在对象构造阶段就会被初始化。对象的构造函数在进入函数体之前编译器会插入一段代码把 vptr 设置成当前类对应的 vtable 地址。这引出一个极其经典的坑——构造函数里调用虚函数不会产生多态class Base { public: Base() { print(); } virtual void print() { cout Base::print endl; } }; class Derived : public Base { public: Derived() { print(); } void print() override { cout Derived::print endl; } }; int main() { Derived d; return 0; }输出是什么Base::print然后Derived::print而不是想象中两次Derived::print。原因就在 vptr 的初始化顺序先构造基类子对象此时 vptr 指向 Base 的 vtableBase()里的print()动态绑定到 Base 版本基类构造完毕编译器才把 vptr 改成 Derived 的 vtable接着执行Derived()这时再调print()才是 Derived 版本。记住这条经验构造函数和析构函数内部虚函数规则自动退化成普通函数调用。这是语言设计者刻意为之因为在基类构造期间子类部分还没有初始化如果允许调到子类版本子类成员全是未初始化的垃圾数据后果不堪设想。2.3 override、final、默认参数容易被名字掩盖的暗坑C11 之后重写虚函数建议带上override关键字。它不是必须的但作用极大让编译器帮你检查这个函数到底有没有真的重写基类的虚函数。最容易翻车的情况是签名不匹配比如基类virtual void speak() const;子类写void speak();漏了const编译器不报错因为这是两个完全不同的函数但你的意图是重写实际上变成了隐藏。加了override编译器会直接报错逼你正视签名。final则用来终止重写class Animal { public: virtual void speak() {} }; class Cat : public Animal { public: void speak() override final {} }; class PersianCat : public Cat { public: void speak() override {} // 编译错误speak 是 final };还有一个几乎没人注意的坑——虚函数带默认参数class Animal { public: virtual void speak(string prefix animal: ) { cout prefix Animal::speak endl; } }; class Cat : public Animal { public: void speak(string prefix cat: ) { cout prefix Cat::speak endl; } }; int main() { Animal* a new Cat(); a-speak(); // 输出 cat: Cat::speak 吗不是 animal: Cat::speak return 0; }默认参数是编译期静态绑定的它绑的是指针的静态类型Animal提供的默认值而函数体是动态绑定的Cat。结果就是参数与函数体拼装在一起输出animal: Cat::speak。任何一本教材都会告诉你不要给虚函数设置默认参数但只有踩过坑的人才知道为什么。3. 纯虚函数与抽象类把约定写进类型系统3.1 从不知道具体怎么画说起抽象类的真实用途虚函数解决的是同名函数在不同子类里有不同实现但有时你连一个合理的基础实现都写不出来。比如设计一个图形类class Shape { public: virtual void draw() { /* 画什么图形太抽象了画不了 */ } virtual double area() { return 0.0; } };Shape::draw到底该怎么画圆形、矩形、三角形各有各的画法形状这个概念本身没有可执行的绘制逻辑。如果硬给它一个空实现或返回 0 的实现反而有副作用万一子类忘记重写area()程序会静默使用那个 0造成逻辑错误。更好的做法是让这个函数没有实现强制子类必须提供class Shape { public: virtual void draw() 0; // 纯虚函数 virtual double area() const 0; }; class Circle : public Shape { public: void draw() override { cout draw circle endl; } double area() const override { return 3.14159 * r * r; } private: double r 1.0; }; 0后面的含义是这个类不提供该函数的实现且该类成为抽象类。抽象类有三个特点不能实例化。Shape s;编译直接报错。子类必须实现所有纯虚函数才能成为具体类并实例化。纯虚函数是接口契约的载体父类规定你必须能画、有面积至于怎么画你自己决定。这在设计层面意义重大。它把抽象从设计文档里的形容词变成了语言强制检查的规则。只要编译器能通过就说明所有子类都兑现了能画、有面积的承诺。接口类只含纯虚函数没有数据成员更是把这种思想推到极致它定义的就是一个纯粹的规范。3.2 析构函数为什么必须虚底层因果链析构函数和虚函数的关系是 C 里最容易出内存问题的组合之一。还是用 Animal 的例子class Animal { public: Animal() {} ~Animal() { cout ~Animal endl; } }; class Cat : public Animal { public: int* buf new int[1024]; ~Cat() { delete[] buf; cout ~Cat endl; } }; int main() { Animal* a new Cat(); delete a; // 只输出 ~AnimalCat 的析构没执行buf 内存泄漏 return 0; }问题链条非常清晰a的静态类型是Animal*如果~Animal不是虚函数delete a会走静态绑定只调用Animal::~Animal。而Cat对象里分配的那块buf永远不会被释放内存泄漏就这么悄无声息地发生了。把析构函数改成虚函数class Animal { public: virtual ~Animal() { cout ~Animal endl; } };delete a时动态绑定生效先调用Cat::~Cat释放buf再自动调用基类析构Animal::~Animal。这里的关键是派生类的析构函数天然会调用基类析构函数你只需要保证先调子类析构、再调基类析构的顺序而虚析构正是启动这一顺序的开关。所以一条普遍经验是只要一个类设计为基类析构函数就要写成虚函数。如果一个类既不用继承也没有虚函数那就别加virtual避免无谓的 vptr 开销。有一种更极端的情况析构函数本身是纯虚函数。它听起来矛盾——纯虚函数没有实现但析构函数必须有实现因为在子类析构的末尾编译器仍会调用基类析构。解决办法是类内声明、类外实现class AbstractBase { public: virtual ~AbstractBase() 0; }; AbstractBase::~AbstractBase() { // 必须提供空实现否则链接报错 }AbstractBase成为抽象类禁止实例化同时子类析构时还能正常调用基类析构完成清理。这种写法适合我需要一个抽象基类且它自身需要清理某些资源的场景。3.3 纯虚函数带函数体一个冷门但关键的细节与析构函数同理其他纯虚函数也可以在声明 0的同时给出函数体语法是类外定义class Shape { public: virtual void draw() 0; }; void Shape::draw() { cout common preparation endl; } class Circle : public Shape { public: void draw() override { Shape::draw(); // 显式调用基类的公共逻辑 cout draw circle endl; } };这个写法的价值在于纯虚函数保证了子类必须自己实现但基类还能提供一个默认的公共骨架子类通过ClassName::function()的显式限定来复用。这在模板方法模式里很常见基类定义步骤框架部分步骤做成纯虚函数由子类填充具体逻辑。不过我需要提醒一句这种写法在面试选择题里经常出现很多人以为带 0的函数不能有函数体一看到AbstractBase::foo 可以定义函数体吗就答错。记住 0规定的是子类必须重写不剥夺基类定义实现的权利。二者并不冲突。4. 多态的真实战场同一段代码服务不同对象4.1 一个可以直接运行的完整示例把前面所有概念拼在一起写一个能体现多态价值的完整程序#include iostream #include vector #include memory using namespace std; class Shape { public: virtual double area() const 0; virtual void describe() const 0; virtual ~Shape() {} }; class Circle : public Shape { public: explicit Circle(double r) : r_(r) {} double area() const override { return 3.14159 * r_ * r_; } void describe() const override { cout Circle, r r_ endl; } private: double r_; }; class Rectangle : public Shape { public: Rectangle(double w, double h) : w_(w), h_(h) {} double area() const override { return w_ * h_; } void describe() const override { cout Rectangle, w_ x h_ endl; } private: double w_; double h_; }; void printArea(const Shape s) { s.describe(); cout area s.area() endl; } vectorunique_ptrShape buildShapes() { vectorunique_ptrShape shapes; shapes.push_back(make_uniqueCircle(2.0)); shapes.push_back(make_uniqueRectangle(3.0, 4.0)); return shapes; } int main() { for (const auto s : buildShapes()) { printArea(*s); } return 0; }printArea接收一个const Shape它完全不知道传进来的是圆还是矩形但调用s.area()和s.describe()时动态绑定自动派发到正确的子类实现。将来再加一个Triangle类只要继承Shape并实现两个纯虚函数printArea一行不用改新类型即可无缝加入。这就是开闭原则的朴素版本对扩展开放对修改关闭。这里有个使用细节值得强调如果按值传参会触发切片那printArea(const Shape s)用引用就必须是常态。引用和指针是 C 中维持多态性的两条通道引用语法更干净还天然规避了空指针问题。容器里存unique_ptrShape则避免了裸指针手动delete的繁琐和泄漏风险。4.2 多态不是免费的性能与设计上的账要算清多态这么好为什么实际项目里不会到处都用因为动态绑定是有代价的。一是调用开销。虚函数调用比普通函数多一次间接跳转现代 CPU 对间接跳转的预测通常不如直接调用在高频循环里差距会被放大。早期 C 项目里某些实时系统会刻意避免虚函数改用模板和 CRTP 把多态解析推到编译期。二是对象体积膨胀。每个有虚函数的对象都会多一个 vptr。假设某个类有大量短生命周期对象比如游戏引擎里的粒子系统每个粒子多 8 字节64 位平台百万级粒子的额外内存就很可观。三是内联失效。虚函数的调用通过函数指针完成编译器在编译期不知道最终调用哪个实现通常无法内联。而普通函数或模板可以在编译期展开做大量优化。我的个人建议是先按设计正确性选择再按性能热点优化。不要为了让一个函数快一点而放弃多态那是过早优化但也不要在一个每秒调用百万次的内部循环里用虚函数而不做任何性能验证。先用工具测出热点再用模板、分支或改写方案处理局部性能瓶颈这是一个成熟工程的选择顺序。4.3 dynamic_cast 和 typeid运行时类型识别的正确用法多态体系里有时候你需要知道这个基类引用/指针到底指向什么具体类型。C 提供了两个运行时类型识别RTTI工具dynamic_cast和typeid。dynamic_cast的安全向下转型用法Animal* a new Cat(); if (Cat* c dynamic_castCat*(a)) { c-catchMouse(); } else { cout not a cat endl; }dynamic_cast会在运行时检查a指向的对象类型如果确实是Cat返回对应指针否则返回空指针对引用转型则抛出bad_cast异常。注意前提基类必须有虚函数。因为dynamic_cast需要借助 vptr 中的信息来确认对象真实类型。一个没有虚函数的类层次RTTI 无法工作。typeid则用来直接查询类型信息cout typeid(*a).name() endl; // 输出实现相关通常含类名typeid返回一个type_info对象typeid(*a) typeid(Cat)可以用于判断。这两种工具在正确场景下很有用比如调试时确认对象类型、处理跨模块的接口调用。但我要泼一盆冷水项目里大量出现dynamic_cast往往不是好兆头。它通常意味着你对类型的判断没有利用好多态本该由虚函数完成的差异化行为被写成了先判断类型、再手动分支的代码。每出现一次dynamic_cast都应该停下来问一句能不能把这个判断改成新增一个虚函数 真正健康的继承设计调用方是不需要关心具体类型的。最后说一点我在实际项目里反复体会到的经验学这几个概念不要满足于知道每种写法的结果一定要亲手把类和指针组合起来跑一遍。尤其是析构函数的链式调用、构造函数里虚函数失效、默认参数静态绑定这三个坑它们是面试高频题更是生产环境里真实发生过多次的故障点。把这几个例子的输出自己推一遍C 面向对象的底子才算真的立住了。