如果你也是从“类”和“继承”这两个章节一路学过来的,大概率会遇到这样一幕:明明基类指针指向的是一个派生类对象,调用同名函数时却傻傻分不清到底执行了谁的代码。这个困扰,本质上就是C++公有继承中“多态”这个核心机制在起作用。这篇内容不铺垫太多概念,我们直接从代码现场出发,把虚函数、动态绑定、虚表指针这些名词掰开揉碎,讲清楚多态为什么能工作、怎么用才靠谱,以及那些看起来没问题、一跑就翻车的常见陷阱。如果你是正在啃C++语法、准备跨过入门门槛的初学者,这篇应该能帮你把“公有继承中的多态”这一课补得明明白白。
1. 从“指针是父类型”聊起:多态到底解决了什么
1.1 一个让新手集体困惑的场景
先看一段几乎所有人都会写的代码:
#include <iostream> #include <string> class Animal { public: void eat() { std::cout << "Animal is eating." << std::endl; } }; class Dog : public Animal { public: void eat() { std::cout << "Dog is eating bones." << std::endl; } }; int main() { Animal* p = new Dog(); p->eat(); // 输出是什么? delete p; return 0; }我见过不少刚学到这里的朋友,第一反应是“p指向的是Dog对象,当然调用Dog::eat”。实际上这段代码输出的是“Animal is eating.”。原因很简单:eat()不是虚函数,编译器在编译阶段看到p的类型是Animal*,就直接把调用绑定到Animal::eat()上去了。这个行为在C++里叫静态绑定,也叫编译期决议。
而真正需要多态的场景是:我们手里只有一个基类指针或引用,但希望它能根据“指针实际指向的对象类型”来选择执行哪一个版本的同名函数。把上面的eat()声明加上virtual关键字,结果就完全不同了。
1.2 绑定时机:编译期决议与运行期决议的本质区别
很多人会把“重载”和“多态”混在一起,其实它们的决策时机完全不同。
重载(overload)发生在同一个作用域内,函数名相同、参数列表不同。编译器在编译期根据实参的个数和类型,从一组候选函数里选出一个,这个选择在编译阶段就定死了,属于静态决议。比如void print(int)和void print(double),你传整数就调整数版本,传浮点数就调浮点数版本,没有任何运行时信息参与。
虚函数(virtual function)参与的是另一套机制:运行期决议,也叫动态绑定。当通过基类指针或引用调用虚函数时,程序在运行阶段根据对象的真实类型去查找对应的函数实现。还是用吃饭举例:
class Animal { public: virtual void eat() { std::cout << "Animal is eating." << std::endl; } }; class Dog : public Animal { public: void eat() override { std::cout << "Dog is eating bones." << std::endl; } }; class Cat : public Animal { public: void eat() override { std::cout << "Cat is eating fish." << std::endl; } };这时再用Animal* p = new Dog(); p->eat();,输出就会是“Dog is eating bones.”。即便你把这个指针装进std::vector<Animal*>,遍历时依次调用eat(),每个对象都会打印自己那一份实现。这就是多态的核心价值:用一份统一的代码,驱动不同类型表现出各自的行为,调用方不需要关心对象到底是谁。
1.3 为什么只有“公有继承”能撑起多态
这个问题值得停下来想一想。私有继承和保护继承在C++里也有它们的用途,但它们不表达“is-a”的关系。
公有继承意味着派生类完整继承了基类的对外接口,任何能接受基类对象或基类引用的地方,都可以安全地传入派生类对象。这个性质在C++里叫里氏替换原则的直接体现:狗是一种动物,所以动物指针能指向狗对象。正是因为在公有继承下,基类访问级别没有被压缩,派生类依然可以作为基类使用,多态才有生存的土壤。
如果换成私有继承,Animal* p = new Dog()这种写法直接编译不过,因为私有继承只表示“内部实现复用”,不构成对外可替换的关系。保护继承介于两者之间,对外同样不具备多态的基础。所以我一直建议初学者先把公有继承的语义吃透,再回头研究其他两种继承方式,否则很容易把语法记住,却不知道它到底想说啥。
2. 虚函数机制拆解:vptr与动态联编的前世今生
2.1 虚函数表(vtable)是如何组织同名不同实现的
动态绑定听起来很玄乎,背后其实就是一张函数指针表。每个含有虚函数的类,编译器都会为它生成一张虚函数表,通常叫vtable。这张表里面存放的是该类的虚函数实现地址,按声明顺序排列。
比如前面例子里的Animal类,它的虚函数表里有一项,指向Animal::eat()。Dog类从Animal继承,它的虚函数表中对应位置被覆盖成Dog::eat()。Cat类同理。当对象被创建出来时,编译器会在对象内部悄悄塞一个隐藏的指针,叫虚表指针(vptr),指向这类对象所属类的vtable。
所以,每个对象其实都随身带了一张“身份卡”,卡上记录着“遇到这个虚函数时,去执行哪个地址的代码”。同一个类创建出来的所有对象,共享同一张vtable,但每个对象的vptr都是独立的,指向同一张表。这就是为什么多态只需要一次运行时查表,代价可控但效果显著。
2.2 对象构造阶段vptr的迁移
这里有个细节新手几乎一定会踩:构造函数的执行顺序是先基类后派生类,而vptr的值会随着构造阶段推进而改变。
当你在派生类对象的构造过程中,进入基类构造函数时,对象内部那部分内存还被当成基类来看待,此时的vptr指向的是基类的vtable。只有基类构造函数执行完毕、轮到派生类构造函数体执行时,vptr才会被更新为指向派生类的vtable。反过来,析构时顺序也相反:先执行派生类析构,再退回到基类析构,vptr也随之回退到基类版本。
这个机制的后果非常直接:在构造函数或析构函数内部调用虚函数,不会得到多态行为。它只会调用当前构造或析构阶段的那个类所对应的版本。我见过有人试图在基类构造函数里通过虚函数初始化派生类状态,结果发现根本调不到派生类实现,白白被整了一下午。记住一句话:构造和析构期间,vptr还没有“到达最终形态”,所以虚函数不会按对象完整类型分派。
2.3 一次虚函数调用的寻址路径
用通俗的话说,普通函数调用是一步直达:编译器算好函数地址,call一下就完事。虚函数调用则多绕了两道弯:
- 先从对象的vptr取出虚表地址;
- 再根据虚函数在表中的偏移量,取出真正的函数指针;
- 最后通过这个函数指针间接调用。
这段过程在汇编层面看就是多了一次内存读取和一次间接跳转。对绝大多数应用来说,这个开销微乎其微。但在极端性能敏感的内层循环里,一次两次可能无所谓,一万次一亿次就会体现出来。这也是为什么很多高性能库的作者会对虚函数非常克制,后面我会专门聊成本问题。
3. 公有继承中的重写、隐藏与默认参数:三个最常见的翻车现场
3.1 真正的“重写”长什么样
在C++里,派生类重新定义基类的虚函数,这件事叫覆盖或重写(override)。它的判定条件是严格的:函数名相同、参数列表相同、const限定符相同、返回值兼容(通常是相同,或者返回基类指针/引用的派生版本,这叫协变返回类型)。
C++11之后引入了override关键字,我强烈建议在写派生类虚函数时一律加上。它能帮编译器替你检查:如果签名不匹配,编译器直接报错,而不是默默让你写了一个全新的函数。比如基类是virtual void draw(int x),派生类写成了void draw(double x) override,立刻编译失败,省去了运行时百思不得其解的痛苦。
看一个正确范例:
class Shape { public: virtual double area() const = 0; }; class Circle : public Shape { private: double radius; public: Circle(double r) : radius(r) {} double area() const override { return 3.14159 * radius * radius; } };这里const和返回值类型都完全匹配,override确认无误。很多人会漏掉const,然后发现函数没有被重写,只是意外的隐藏,给排错带来很大麻烦。
3.2 隐藏与重写的混淆:调了但没完全调
隐藏跟重写是两件完全不同的事。当派生类定义了一个与基类同名的函数,但参数列表不同,或者基类那个函数不是虚函数,派生类的版本会“遮蔽”基类的所有同名函数。
最典型的坑是:
class Base { public: void show() { std::cout << "Base show" << std::endl; } }; class Derived : public Base { public: void show(int x) { std::cout << "Derived show: " << x << std::endl; } }; int main() { Derived d; d.show(); // 编译错误,因为show(int)把基类的show()藏起来了 return 0; }基类明明有show(),但因为派生类新增了一个不同参数的show(int),基类那个无参版本被完全遮蔽了。解决办法要么是在派生类里写using Base::show;把基类版本引入作用域,要么避免这样命名。这个坑之所以隐蔽,是因为很多初学者以为“同名函数天然就是重载”,但重载有一个前提:必须在同一个作用域内。继承体系中基类和派生类作用域不同,根本不会发生跨层的重载,只会发生隐藏。
3.3 默认参数绑定的是静态类型:一个极其阴险的坑
默认参数和虚函数混在一起时,会产生一种让人抓狂的行为:函数体执行的是派生类的,默认参数却是基类的。
class Base { public: virtual void print(std::string msg = "base default") { std::cout << "Base: " << msg << std::endl; } }; class Derived : public Base { public: void print(std::string msg = "derived default") override { std::cout << "Derived: " << msg << std::endl; } }; int main() { Base* p = new Derived(); p->print(); // 输出 Derived: base default delete p; return 0; }第一次见到这个结果的人基本都懵了:函数体确实是Derived::print()跑出来的,但参数却用的是Base的默认值“base default”。原因在于默认参数是编译期静态绑定的数据,编译器在看到p的类型是Base*时,已经把缺省实参填成了基类版本;而虚函数调用是在运行期才决定函数地址。一个静态决定,一个动态决定,两边不一致,就出现了这种“混血”输出。
我的建议很简单:不要在虚函数里依赖默认参数。要么不写默认参数,要么把默认值放在外层包装函数里,让虚函数本体干净无默认。这个坑很难通过看代码直接发现,但一旦出现在公共接口里,排查成本非常大。
4. 析构函数为何十有八九要加virtual
4.1 没有虚析构时delete基类指针会发生什么
这是公有继承中最经典、也最昂贵的一个坑。先看代码:
class Base { public: ~Base() { std::cout << "Base destructor" << std::endl; } }; class Derived : public Base { private: int* data; public: Derived() : data(new int[100]) {} ~Derived() { std::cout << "Derived destructor" << std::endl; delete[] data; } }; int main() { Base* p = new Derived(); delete p; // 问题来了 return 0; }delete p时,Base的析构函数不是虚函数,编译器按静态类型Base*调用析构,结果只有Base的析构函数被执行,Derived的析构函数压根没跑,data指向的数组直接泄漏。更糟的是,这在标准定义里属于未定义行为。虽然多数编译器不会立刻崩溃,但内存泄漏、资源泄漏、析构逻辑失效是几乎必然的后果。
4.2 虚析构的正确释放顺序
把基类析构函数声明为virtual之后,一切就正常了:
class Base { public: virtual ~Base() { std::cout << "Base destructor" << std::endl; } };当delete p执行时,因为析构是虚函数,运行时先按对象的真实类型调到Derived::~Derived(),派生类把自己管理的资源释放干净,随后自动调用基类析构函数,再释放基类部分。整个析构链是从派生到基类逐层展开的,跟构造顺序正好相反。
这里还有一个容易忽视的细节:只要基类析构函数是虚的,从它派生的所有类的析构函数即使不写virtual,也会自动被视为虚析构,因为析构函数的重写规则里没有返回值这个概念,派生类写法天然满足重写条件。但为了可读性,建议派生类也写上override。注意,析构函数重写只能写override,不能写virtual和override同时以外的东西,严格来说是可以用virtual的,只是没必要。
4.3 什么时候可以不加virtual:设计上就不该被继承的类
有人可能会问:那我每个类都加virtual,岂不是万事大吉?先别急,加virtual是有代价的:每个对象多了vptr,vtable占用存储,某些场景下还会阻止编译器内联。更重要的是,会破坏类的值语义。一个类有虚函数后,拷贝、赋值、按值传递都会变得更加复杂。
正确做法是:如果一个类的设计意图就是作为基类,允许被继承并通过基类接口操作,那么它的析构函数几乎必然要加virtual。反过来,如果一个类的设计意图是“最终产品”,不允许被继承,那么C++11之后可以直接写成class FinalClass final { ... }。这样即使别人强行继承,编译器也会拒绝。只有在这种情况下,不加虚析构才是安全的。
判断标准就一句话:如果有人可能用基类指针或引用去delete派生类对象,基类析构就必须virtual;如果自己都不是基类,那就不用背这个包袱。
5. 用纯虚函数划定接口边界:继承体系设计的骨架
5.1 抽象类:把“应该做但怎么做未定”交给派生类
有些函数在基类里根本没有合理实现。比如“形状的面积”,你说基类Shape::area()该返回什么?圆形面积和矩形面积算法完全不同,基类根本没法给出一个对所有派生类都合适的默认值。这时候就该用纯虚函数:
class Shape { public: virtual double area() const = 0; virtual ~Shape() = default; };= 0把area()变成了纯虚函数,Shape也因此成为抽象类。抽象类不能直接实例化,只能作为接口和公共骨架存在。任何想成为“具体形状”的派生类,都必须实现area(),否则它自己也依然是抽象类,不能创建对象。
这种设计把“契约”从语法层面钉死:漏实现,编译器直接报错,不需要等到运行时才发现“哦原来这个类不该被实例化”。我建议在学习阶段多做这种设计练习,它比单纯背语法更能帮你建立接口思维。
5.2 非虚接口(NVI)模式的雏形
公有继承里的多态并不仅仅是把所有成员函数都写成virtual。很多资深开发者会采用一种叫非虚接口(Non-Virtual Interface)的方案:对外暴露的公开成员函数保持非虚,内部再调用虚函数来完成具体行为。
class DataProcessor { public: void process() { // 公开接口,非虚 beforeProcess(); doProcess(); afterProcess(); } private: virtual void beforeProcess() {} virtual void doProcess() = 0; virtual void afterProcess() {} };这样做的好处是:基类把调用流程、前置后置逻辑都固化在process()里,派生类只需要重写各个虚函数步骤,不能破坏整体流程的顺序。这是一种非常经典的模板方法模式雏形,也是公有继承多态配合封装原则的典型组合。很多框架代码里你都能看到这种影子,理解了它,读源码会顺手很多。
5.3 纯虚函数也能有函数体:特殊但有用
可能有人会惊讶:纯虚函数也能写实现。没错,= 0只是表示“这个函数必须被派生类重写”,不代表基类不能提供一个通用版本。派生类可以显式调用Base::doSomething()来复用基类的公共逻辑。
这种写法比较少见,但在一些需要“派生类必须自己决定是否调用基类默认行为”的场景里非常灵活。只不过我建议初学者先把它当成“知道有这回事”即可,日常设计优先保持纯虚函数简洁无实现,不要上来就炫技。
6. 多态的成本账:性能损耗与对象切片等实务提醒
6.1 一次虚函数调用到底比普通函数慢多少
前面讲过,虚函数调用需要通过vptr间接取得函数地址。这个过程比普通函数直接调用多了一次内存读取和一次间接跳转。在大多数程序里,这几十个时钟周期的差异可以忽略不计。但在热循环里,比如图形引擎每帧要对几万个对象调用变形函数,或在高频交易系统里对每个事件做多态分派,这个开销就会被放大。
更重要的是,虚函数阻止了内联。普通函数在编译器能看到实现的情况下,可以内联展开,省掉调用开销;虚函数的调用地址在运行期才知道,编译器在大多数情况下无法内联。所以性能敏感的内核代码中,架构师往往会采用模板、CRTP(Curiously Recurring Template Pattern)等方式,把多态从运行期搬到编译期。这不是说虚函数不好,而是提醒你:工具没有绝对优劣,要看场景。
6.2 对象切片:按值传递是如何把多态“切”掉的
如果说vptr和虚表是运行期的多态保障,那“按值传递参数”就是运行期多态的杀手。看这个例子:
void printArea(Shape s) { // 按值传递 std::cout << s.area() << std::endl; } Circle c(1.0); printArea(c); // 发生了对象切片printArea的参数类型是Shape,但传入的是Circle对象。按值传参需要构造一个Shape的副本,这个副本通过Shape的拷贝构造函数完成,派生类部分被舍弃掉了,得到的只是“失去圆形的那个圆”。这被称为对象切片。切片后,vptr指向的是Shape的虚表,即使虚函数机制再先进,也救不回来。
解决方式很简单:传引用或指针,永远不要按值传递多态对象。void printArea(const Shape& s)就完全没问题,因为引用不会产生副本,仍然绑定到原始的Circle对象。这个规则我建议写进你自己的代码风格清单里:见到基类类型参数,第一反应就确认是不是const引用。
6.3 构造函数和析构函数内调用虚函数的行为
前面说过vptr在构造阶段是逐步迁移的。落实到代码里就是:不要在基类构造函数或析构函数里调用虚函数期望触发多态。你调用的一定是当前正在构造/析构的那个类的版本,而不是最终派生类的版本。这个规则说白了就是“初始化未完成前,不要把对象当成最终形态来用”。很多设计模式、状态机初始化代码的bug,都从这里冒出来。
6.4 什么时候应该回避虚函数
说了这么多,多态是神器,但也不是万金油。如果你的类不需要通过基类接口操作,不需要在容器里存放不同类型对象,也没有“同一接口多种行为”的需求,那就不必强行引入虚函数。虚函数会带来vptr开销、抑制内联、弱化值语义,这些都是成本。一个只有十行代码的小工具类,为了“以后可能扩展”加上virtual,通常是不值得的。我倾向于认为:虚函数是设计出来的,不是预防出来的。等到你真的需要统一接口处理不同对象时,再加不迟。
7. 一套完整可编译的多态示例与自查清单
7.1 完整示例:统一接口处理文档类型
把散落的知识收拢起来,看一个能直接编译运行的完整示例。假设我们有一套文档处理接口,支持PDF和Word两种格式:
#include <iostream> #include <memory> #include <vector> #include <string> class Document { public: virtual void open() = 0; virtual void save() = 0; virtual std::string type() const = 0; virtual ~Document() = default; }; class PdfDocument : public Document { public: void open() override { std::cout << "Opening PDF document. " << std::endl; } void save() override { std::cout << "Saving PDF document. " << std::endl; } std::string type() const override { return "PDF"; } }; class WordDocument : public Document { public: void open() override { std::cout << "Opening Word document. " << std::endl; } void save() override { std::cout << "Saving Word document. " << std::endl; } std::string type() const override { return "Word"; } }; void handleAllDocuments(const std::vector<std::unique_ptr<Document>>& docs) { for (const auto& doc : docs) { std::cout << "Type: " << doc->type() << std::endl; doc->open(); doc->save(); } } int main() { std::vector<std::unique_ptr<Document>> docs; docs.push_back(std::make_unique<PdfDocument>()); docs.push_back(std::make_unique<WordDocument>()); handleAllDocuments(docs); return 0; }这段代码把多态的几大核心要素都串起来了:抽象基类提供纯虚接口,派生类各实现各的,容器里装的是基类unique_ptr,遍历时统一调用接口,销毁时由于虚析构的存在,每个派生类资源都能正确释放。unique_ptr<Document>之所以能正确管理派生类对象,正是因为Document析构函数是虚的。
7.2 多态代码自查清单
把踩过的坑整理成一张检查表,写业务代码前对照扫一遍,能省掉大量调试时间。
| 检查项 | 说明 | 建议处理 |
|---|---|---|
| 基类析构是否virtual | 用基类指针delete派生类对象时,析构必须虚 | 多态基类一律加virtual析构 |
| 派生类重写是否加了override | 防止签名不匹配导致的隐藏 | 重写虚函数统一写override |
| 是否按值传递多态对象 | 按值传参导致对象切片 | 改用指针或const引用 |
| 构造/析构里是否调用虚函数 | 此时不会触发最终类型分派 | 避免设计此行为 |
| 虚函数是否依赖默认参数 | 默认参数静态绑定、函数体动态绑定 | 不要在虚函数中使用默认参数 |
| 类是否真的需要多态 | 没有统一接口需求时不要硬加虚函数 | 需要时再设计,不必提前预防 |
| 派生类是否实现所有纯虚函数 | 漏实现则类无法实例化 | 编译报错即可发现,建议IDE实时提示 |
7.3 几个练手方向
多态学完,光看示例是不够的。建议按这个顺序给自己出题:
第一,写一个支付系统接口,基类定义pay()和refund()纯虚函数,派生出支付宝、微信、银行卡三个类,各自完成不同逻辑,再写一个函数统一处理一批支付单。第二,把前面文档示例扩展出EncryptedWordDocument类,让它继承WordDocument的同时重写save(),观察派生类层层继承下多态依然生效。第三,试试给Document增加一个virtual Document* clone() const纯虚函数,并在每个派生类中实现深拷贝克隆,体会多态实现原型模式的思路。
按这个路子练下来,公有继承中的多态就不再是“书上那段概念”,而会成为你脑子里一种自然的代码组织方式。以后再遇到“基类指针到底调了谁的函数”这类问题,你应该能直接回答:除非调用的是虚函数,否则编译器只看指针的静态类型;如果是虚函数,那就看对象口袋里那张vptr指向的虚表上写的是谁。