继承和多态,是C++里讨论热度最高的两个词,也是最容易“背会了但不会用”的一对组合。我在不少团队里见过这种场景:候选人能把虚函数表、菱形继承、纯虚函数这些名词说得头头是道,但真正拿到一个三层继承体系,让他在基类里加一个虚函数,他却说不清会不会影响所有派生类,更别提delete一个基类指针时为什么内存泄漏。这说明我们很多时候缺的不是概念,而是把语法翻译成内存模型和调用规则的能力。
这篇文章想做的,就是把C++的继承与多态放到对象模型层面重新走一遍,说清楚三件事:继承到底动摇了什么,多态是怎么在运行时发生的,以及工程上哪些写法会让你在三个月后付出代价。内容既覆盖初学者需要的基础模型,也包含老手面试和改Bug常用的避坑点,代码风格偏现代C++,和编译环境关系不大,你用VS、Clang还是VSCode都能直接跑。
为了不把文章写成八股文,我刻意加了不少自己踩过的坑和评审代码时的观察。如果你只是想背面试答案,直接跳到第六章;如果你想真正搞懂设计取舍,建议从头读,因为后面的很多坑,根子在前面的内存布局里。
1. 继承到底解决了什么问题
1.1 从代码复用到类型体系
很多教程一上来就给继承下定义:子类继承父类的成员,实现代码复用。这句话没错,但太浅。如果你只是想让两个类共享一段逻辑,相比继承,组合甚至拷贝粘贴都可能是更简单、更可控的方案。继承真正的价值,是让一组类型在“形状”上保持一致,让代码能够面向一个抽象写逻辑,而不是面向一堆具体类型写分支。
举个例子:你有一个Animal基类,下面有Dog、Cat,它们都有makeSound()这个行为。如果不用继承,你只能写if (type == dog) dog.sound(); else if (type == cat) cat.sound();。每加一种动物,所有带这个判断的地方都得改。用继承后,函数签名写成void listen(const Animal& a),它接受任何Animal的派生类,调用a.makeSound()时会自动找到正确实现。这个“面向抽象编程”的能力,比单纯省几行重复代码重要得多。
所以判断该不该继承,先问一句:你要表达的是不是严格的“is-a”关系?Dog是一种Animal,成立;Logger含有Config,不成立,后者用组合。我把这个原则记了很多年,它帮我在设计阶段挡掉了大量后续返工。
1.2 C++继承的三种访问层级:这里最容易犯糊涂
C++的继承分public、protected、private三种,初学者常以为它们只是“哪个成员能访问”的区别,其实它们改写了基类成员在派生类中的可见性,也影响了外部代码能不能把派生类指针转成基类指针。
| 继承方式 | 基类public成员在派生类中变为 | 基类protected成员变为 | 基类private成员 | 外部向上转型 |
|---|---|---|---|---|
| public | public | protected | 不可直接访问 | 允许 |
| protected | protected | protected | 不可直接访问 | 仅派生类内允许 |
| private | private | private | 不可直接访问 | 不允许 |
这里有个很容易踩的坑:默认继承方式不是public。struct Derived : Base是public继承,而class Derived : Base是private继承。很多从Java转过来的人会忽略这一点,结果外部调用Base* p = &d直接编译失败。private继承在工程里最常见的用途是实现“has-a”关系:我想复用基类的实现,但不想对外暴露这层类型关系。如果你见过STL容器通过private继承某些分配器或迭代器辅助类,就是这个动机。
protected继承就更少见了,因为它既没有public继承的接口一致性,又没有private继承的“完全隐藏”,通常只有某些特殊框架会用到。日常开发里,我先按“public继承表示is-a,private继承表示has-a”这个二分法取舍,protected继承基本不碰。
2. 继承的底层记忆:切片、布局与菱形问题
2.1 切片与向上转型:为什么传值会丢数据
C++的继承关系在内存里最直白的表达是:一个派生类对象,前面是一块基类子对象,后面是派生类自己新增的成员。你可以把派生类对象理解成“三明治”:最底层是基类部分,上面是派生类扩展。当把派生类对象赋值给基类对象时,C++只拷贝前面那块基类部分,派生类新增字段会被硬生生切掉。
struct Base { int a = 1; }; struct Derived : Base { int b = 2; }; void printByValue(Base obj) { std::cout << obj.a; } int main() { Derived d; printByValue(d); // 发生切片,b 永远到不了 printByValue 里 }这个现象叫object slicing,切掉的还不只是数据。如果Base里有虚函数,切片后对象的虚表指针会指向Base的虚表,动态类型彻底变成Base,多态也就失效了。常见的翻车现场是:把Derived对象塞进std::vector<Base>,原以为容器里存了一堆不同的派生类,实际每个元素都是切过片的Base,后续所有虚函数调用全都落到Base实现上。
所以我说“按值传递会丢掉类型信息”。想保持多态,请用基类指针或引用:Base& ref = d或Base* p = &d都能完整保留派生部分,只有栈上新建临时对象拷贝时才会切片。工程上建议容器里存std::vector<std::unique_ptr<Base>>,既保证动态类型正确,又自动管理生命周期。
2.2 菱形继承与虚继承:为什么面试官总爱问这个
菱形继承是指一个派生类同时从两个中间类继承,而这两个中间类又继承自同一个最顶层基类。代码长这样:
struct A { int value; }; struct B : A {}; struct C : A {}; struct D : B, C {};此时D的对象里存在两份A子对象,一份来自B,一份来自C。访问d.value会导致二义性,因为编译器不知道你是想读B里的A,还是C里的A。你当然可以显式指定d.B::value,但这样设计本身已经说明问题:D同时继承了同一个实体的两份副本,状态分裂,逻辑上也很难自洽。
解决方式是虚继承:struct B : virtual A,struct C : virtual A。虚继承让D里只保留一份A子对象,编译器需要额外处理虚基类的偏移信息,通常会带来一定的间接寻址成本。这也是菱形继承被很多人称为“语法允许但你最好别碰”的原因。工程上,只有在接口类继承体系里,虚继承才有点价值:多个接口都继承同一个标记接口,实现类希望最终只有一份标记接口的基类子对象。标准库里的basic_ios就是经典例子,但它属于“需要时再模仿”的珍稀动物,不是常规手段。
如果你在写业务代码时发现需要菱形继承,先停下来想想能不能拆成组合。菱形不是炫技题,它是C++给“多继承”这个特性强制收的税。
2.3 使用final阻断继承:有些类就不该被继承
C++11开始提供了final关键字,可以直接禁掉一个类被继承,或者禁掉某个虚函数被继续重写。
class Base final { public: void run(); }; // 编译错误:不能从 final 类继承 class Derived : public Base {};很多团队喜欢给所有不打算做基类的类加上final,理由有两个:一是明确表达设计意图,防止后来人自作主张往上叠层级;二是某些编译器知道final类不可能有派生类后,对虚函数调用可以做去虚拟化优化,把间接调用变成直接调用。这个优化在热点路径上是有意义的。
但我不建议一上来就把所有类都final。如果一个类是纯数据类,根本没人会继承它,final的意义不大;如果一个类本身就是接口基类,加final等于自断后路。工程上的惯例是把“由我控制且确定不可扩展”的实现类加final,库代码里尤其常见。搭配组合使用效果更好:与其允许别人继承你的大类做扩展,不如把可变行为封装成成员对象,对外暴露组合出来的接口。
3. 多态是怎么动起来的:虚表、虚指针与动态绑定
3.1 虚函数与虚函数表
C++的多态核心是虚函数机制。只要类里有virtual函数,编译器就会给这个类的对象安插一个隐藏的指针,叫vptr,它指向一张属于该类的虚函数表,叫vtable。虚表里按固定顺序存放着这类对象能调用的虚函数地址。换个生活化的比喻:每个对象身上挂了一张小纸条,纸条注明“我这种对象的同类行为清单放在哪里”,行为清单就是虚表。
class Animal { public: virtual void makeSound() { std::cout << "generic animal sound\n"; } virtual ~Animal() = default; }; class Dog : public Animal { public: void makeSound() override { std::cout << "woof\n"; } };当Derived重写某个虚函数时,它自己的虚表里对应槽位会被替换成Derived::makeSound的地址,没重写的函数则继续指向基类实现。同一个类的所有对象共享同一张虚表,每个对象只额外存一个vptr。所以sizeof(Animal)通常比“只有基础成员”的同类多出一个指针大小。标准没有规定必须用vptr/vtable,但主流编译器在常见ABI上都是这么实现的,面试官问“虚函数怎么实现”时,说这个模型基本不会错。
3.2 动态绑定在调用点发生
虚函数调用时,编译器并不直接生成一条call Animal::makeSound指令,而是生成类似这样的动作:先从对象取出vptr,再到虚表中取指定槽位的函数地址,然后间接跳转。这个跳转取决于对象的动态类型。
Animal* a = new Dog(); a->makeSound(); // 运行时看 a 指向的真实对象,决定调用 Dog::makeSound最关键的是,编译器在编译这段代码时并不知道a实际指向Dog还是Cat,它只按“Animal有一个makeSound虚函数”来生成调用代码。等程序跑起来,从虚表里找到的可能是Dog的地址,也可能是Cat的地址,这就是动态绑定。如果一个类没有虚函数,编译器对这种调用会直接静态解析到Animal版本,而有了virtual和派生类重写,才能保证行为随真实对象走。
我这里想强调一下override关键字的价值。C++11之后,重写虚函数最好都写void makeSound() override,让编译器核对你确实在重写基类的某个virtual函数。假如写得是void makeSound(int x),或者漏了const,编译器会直接报错,而不是让这个函数变成“隐藏的同名函数”,静悄悄地把多态破坏掉。我见过太多线上问题最后都是这种签名不匹配导致的,加一个override能省掉一整晚排查时间。
3.3 构造函数和析构函数里的虚函数为什么靠不住
很多人不知道,C++在构造函数里调用虚函数,不会触发动态绑定。比如基类构造函数调用this->makeSound(),即使派生类重写了makeSound,这里还是调用基类版本。
struct Base { virtual void makeSound() { std::cout << "Base"; } Base() { makeSound(); } }; struct Derived : Base { void makeSound() override { std::cout << "Derived"; } }; Derived d; // 输出 Base,不是 Derived原因是对象构造有严格的次序:先构造基类子对象,再构造派生类成员。在基类构造函数执行期间,派生类部分还没开建,vptr还指向基类的虚表,编译器此时不会冒险去调用一个“可能依赖未初始化成员”的派生类函数。析构则刚好反过来:先析构派生类部分,再把vptr改回基类虚表,最后执行基类析构。因此在析构函数里调用虚函数,同样只会解析到当前阶段的类版本。
这种设计是合理的,也是所有C++程序员必须接受的规则。我踩过最离谱的一次,是想在基类构造函数里统一调用一个被子类重写的init(),结果子类完全没被执行,业务参数全是默认值。解决方式很简单:需要子类参与初始化的逻辑放在子类构造函数里,或者把初始化动作拆到非虚函数,由子类显式调用。
4. 写下多态代码前先想清楚的事:抽象类、接口与虚析构
4.1 纯虚函数与抽象类
当类里出现virtual void send() = 0;这种写法时,这个函数是纯虚函数,类变成抽象类,不能直接实例化。抽象类存在的意义是定义“你需要提供哪些行为”,而不是“我帮你实现哪些细节”。它非常适合做接口层。
class ISensor { public: virtual double read() = 0; virtual ~ISensor() = default; }; class TemperatureSensor : public ISensor { public: double read() override { return 36.5; } };派生类必须实现所有纯虚函数,否则它自己仍是抽象类。C++里没有单独的interface关键字,传统做法就是用只含纯虚函数和虚析构的类充当接口。这种接口类一般不带数据成员,所以即使一个派生类同时实现多个接口,也不容易出现菱形继承那种状态重复问题。
一个容易忽略的点是:纯虚函数也可以有自己的函数体。比如void send() = 0;可以额外在类里提供void ISensor::send() { ... },派生类如果想要基类的通用实现可以显式调用ISensor::send()。但一般情况下没人这么写,纯虚函数保持“只有协议,没有实现”会更清晰。
4.2 虚析构函数必须安排上
如果你的类会被继承,而且你打算通过基类指针来delete派生类对象,那么基类析构函数必须是virtual。最经典的例子:
class Base { public: ~Base() {} // 非虚 }; class Derived : public Base { int* data = new int[100]; }; Base* b = new Derived(); delete b; // 只调用 Base 的析构,Derived::data 泄漏delete b时,静态类型是Base,如果Base析构不是虚函数,编译器不会向下追溯到Derived的析构函数。Derived的资源清理逻辑永远不执行。更严重的是,这属于未定义行为,在某些ABI上可能直接崩溃。
所以我的习惯是:只要一个类具有虚函数,就顺手把析构函数写成virtual ~类名() = default。如果这个类不打算被继承,可以不写虚析构,因为虚析构会引入vptr,让对象内存变大,也会影响值语义和内存布局。如果只是不想被通过基类指针删除,也可以把析构函数设为protected,但这和多态删除的需求冲突,一般还是虚析构最省心。抽象接口类尤其要注意:ISensor这种类一定要virtual析构,否则删除接口指针时同样会出问题。
4.3 override和final的新写法
现代C++里,重写虚函数建议一律加override,它不只是风格问题,更是一道编译器防线。例如:
class Base { public: virtual void open() const; }; class Derived : public Base { public: void open() override; // 编译错误:基类是 const,这里不是 void open() final override; // 正确:既是重写,也不允许继续重写 };加了override后,任何签名对不上、const漏写、参数类型不匹配都会被编译器当场抓出来。final则承担“绝招”的角色:一个虚函数被final后,后代类就不能再重写它。把final放在具体实现类上,可以保护某个关键行为不被后续派生类篡改。我写库代码时特别爱用final,它让编译器可以做更多去虚拟化,本质上是“我告诉你没有更多重写,你按最优化方式处理”。
还有个冷知识:override和final是上下文关键字,你完全可以定义一个名字叫override的变量,但为了团队代码可读性,千万别这么干。同事看到这种变量名会怀疑人生。
5. 多态的性能账与替代方案
5.1 虚函数真的慢吗
虚函数不是“免费午餐”。一次虚函数调用的成本,比一次普通函数调用多了至少一次内存间接寻址:先读对象头上的vptr,再从虚表对应槽位读函数地址,然后间接跳转。这个过程中,CPU还很难预测目标地址,分支预测失败会增加流水线惩罚。更重要的是,通过基类指针进行的虚调用通常无法内联,编译器很难跨过间接调用做激进的优化。
但“慢”要放在场景里看。如果它发生在UI回调、网络接口、启动流程这些低频路径上,多几个纳秒完全无所谓,虚函数带来的代码可维护性收益远超这点开销。但在热循环里,比如每秒跑百万次的算法内核,虚函数的间接调用就可能成为热点。我的原则是:先把逻辑写对,再用分析工具确认虚调用占比,别一上来就“优化”。
| 方案 | 调用开销 | 额外存储 | 适用场景 |
|---|---|---|---|
| 虚函数 | 间接调用,通常无法内联 | 对象加vptr,类加虚表 | 运行期多态、插件化、接口抽象 |
| 模板+函数对象 | 可直接内联 | 通常无额外内存 | 编译期已知类型,追求性能 |
| std::variant + std::visit | 中等,固定候选集跳转 | 联合体+判别字段 | 有限类型集合的“多态” |
5.2 RTTI、dynamic_cast与typeid的代价和边界
dynamic_cast是C++提供运行时类型转换的手段,但前提是类型必须是多态的,也就是类里至少要有一个虚函数。
Base* p = getObject(); Derived* d = dynamic_cast<Derived*>(p); if (d != nullptr) { // p 确实指向 Derived 或其更派生类 }指针转换失败返回nullptr,引用转换失败抛出std::bad_cast。dynamic_cast需要在运行时检查类型信息,通常要沿着继承关系来回查找,开销比普通static_cast高不少。真正的问题不是性能,而是设计:一个系统里如果到处都是dynamic_cast,说明基类接口设计得太弱,调用方知道具体类型,却还得绕一大圈去猜。
我常见的重构思路是:与其向下转型,不如在基类里增加一个虚函数,把“特定类型才有的操作”共性化为接口行为。虽然这会让基类变大,但调用方不再关心动态类型,整体稳定性会好很多。另外注意,有些编译环境会关闭RTTI,比如用-fno-rtti编译,dynamic_cast和typeid会不可用,跨平台时这类代码会意外编译失败。
5.3 什么时候根本不该用虚函数
虚函数不是“面向对象”的代名词。以下场景我会果断放弃虚函数:对象非常小且高频率创建销毁,比如粒子系统里的粒子,多加一个vptr可能让缓存命中率下降;需要稳定内存布局直接读写的场景,比如网络报文、协议结构体,带vptr的对象不能安全地当作裸字节拷贝;还有核心算法内核,类型在编译期就能确定,模板是实现零成本抽象更好的选择。
模板多态也叫编译期多态,通过模板参数传入策略类或函数对象,编译器直接生成对应的特化代码,往往能内联到极致。
template <typename Animal> void listen(const Animal& a) { a.makeSound(); // 编译期确定具体类型 }但模板多态不能完全替代虚函数。比如你要存储一组类型不同的对象,让它们在运行时按各自行为触发,模板就非常别扭。实际工程里最合理的分工是:接口边界、插件点、扩展点用虚函数;算法内部、热路径、固定类型集合用模板或variant。了解两者的成本,才能说出“这里不用虚函数”的依据,而不是拍脑袋。
6. 常见问题与排查技巧实录
6.1 经典C++八股快速复盘
面试和日常Review里,关于继承与多态的问题翻来覆去就那几个,我把高频知识点整理成一个速查表,照着过一遍基本不会在基础题上翻车。
| 问题 | 答案 |
|---|---|
| 构造函数可以是虚函数吗 | 不行,构造时vptr还没设置好,虚调用无从谈起 |
| 析构函数可以是纯虚函数吗 | 可以,但必须给出定义,否则派生类析构链会出问题 |
| 虚函数一定能内联吗 | 不一定,知道具体对象时编译器可能去虚拟化,但多态间接调用通常无法内联 |
| override和重载是一回事吗 | 不是,override覆盖基类虚函数,重载是同一作用域同名不同参数 |
| 私有虚函数能被派生类重写吗 | 能,但调用受到访问控制限制,派生类内部可能无法直接调用 |
| 抽象类可以有构造函数吗 | 可以,派生类构造时会调用基类构造函数 |
| 没有虚函数的类能被dynamic_cast向下转吗 | 不能,会编译错误或行为异常,必须先具备多态 |
这些点背后都是对象模型约束。比如“构造函数不能是虚函数”,本质是虚调用需要对象已经存在、vptr可用;构造过程里对象还没完全成型,这个前提不成立。理解了这一点,八股就不再是死记硬背。
6.2 我踩过的坑:隐藏规则与重载误伤
C++有一个让Java程序员极度不适应的规则:派生类里定义同名函数,会隐藏基类里的所有同名重载。注意这里是“隐藏”,不是“重载”。
struct Base { void f(); void f(int); }; struct Derived : Base { void f(int, int); // 隐藏了 Base 里的所有 f }; Derived d; d.f(); // 编译错误 d.f(1); // 编译错误原因在于C++的名字查找:派生类作用域里一旦找到f这个名字,就停止向上查找,后面的重载匹配完全以派生类里找到的这些f为候选。想恢复基类同名函数,需要在派生类里写using Base::f;。很多老代码里,派生类想“重载”基类虚函数,结果因为参数个数不同,变成了隐藏,调用处仍然走基类实现,多态莫名失效。解决这类问题的第一道防线还是加override,编译器能立刻识别“你想重写,但签名对不上”。
另外,如果基类成员是虚的,派生类成员可以不加virtual仍然构成重写,这种写法语法合法,但可读性差。我会要求团队统一加override,不靠“基类虚函数自然传递”这种隐式规则。
6.3 继承体系膨胀后的重构经验
一旦项目跑到中期,继承体系就容易失控。典型症状是:类继承深度超过三层,基类方法塞了几十个,很多派生类根本不关心某个虚函数却被迫实现,dynamic_cast在代码里出现频率比日志还高。这种状态我称之为“继承腐烂”,它比重复代码更危险,因为你改基类一个虚函数,影响面可能是灾难级的。
我有一个印象很深的案例:日志模块的基类virtual void write(int level)后来要改成virtual void write(int level, const Context& ctx),因为所有子类都加了override,编译期直接报出了所有没适配的位置。这正说明override的检查价值。如果当时没有override,编译器不会拦,运行期所有日志的调用点都会悄悄走基类兜底实现,排查起来要命。
重构时我的顺序是:先抽取接口,把真正需要多态的行为收敛成几个小接口;再给每个实现类只实现与自己相关的接口;最后把非多态的共享代码通过组合放进公共组件。组合优先于继承不是说继承不能用,而是说继承只用来表达“稳定的is-a关系”和多态边界。回头再看那些烂掉的三层继承,多数本来用两个小对象组合就能写得明明白白。
7. 一些带项目的实操心得
7.1 我自己的三条建议
我写C++这些年,慢慢形成了几条比较固定的原则,分享出来供参考。第一,所有涉及多态的虚函数重写统一加override,能被final封住的行为尽量封住,让编译器成为你的第一道审查员。第二,不在构造函数和析构函数里调用虚函数,这是很多人反复踩的雷,我甚至连日志类里的虚函数调用都要检查一遍。第三,多态只放在软件边界,比如插件接口、模块回调、业务扩展点;核心算法、数据结构内部尽量用模板和值语义,把性能账算明白。
这三条不一定适合每个团队,但它们帮我减少过很多“看起来正常却找不到原因”的线上问题。C++允许你写非常灵活的代码,灵活本身不危险,危险的是在没必要灵活的地方滥用灵活性。继承和多态也一样:用对了,是架构的骨架;用多了,是团队的噩梦。
7.2 一个可落地的自查清单
如果你正在Review一段涉及继承的代码,建议按下面这份清单过一遍:这层继承是否表达严格的is-a关系,还是其实只是贪图基类实现;基类析构是否虚函数,是否有人会delete基类指针;每一个重写函数是否都加了override,签名是否和基类一致;构造函数和析构函数里有没有调用虚函数;代码里有没有大规模dynamic_cast,如果有,基类接口是不是太宽泛;最终类是否应该加final,把不打算扩展的意图明确下来。
我自己越来越觉得,C++的继承与多态不是靠“背规则”掌握的,而是靠“看内存布局”和“看调用过程”来理解的。只要你能在脑子里画出对象模型:哪里是基类子对象,哪里是vptr,虚表里存了什么,很多看似玄学的编译错误都会变得非常直接。
最后再分享一个小技巧:遇到继承导致的问题时,别急着加虚函数,先把类关系图画出来,标出每个虚函数会被哪些类重写、每次间接调用的动态类型可能有哪些候选。多数情况下,画完图你就能发现某个类其实不该继承,而应该组合。这套方法在我带过的项目里一直很管用,希望对你也一样。