ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++继承详解:从is-a关系到多态、钻石继承与避坑指南

C++继承详解:从is-a关系到多态、钻石继承与避坑指南 还记得第一次用C做职工管理系统的时候我完全没有继承的概念。主管、普通员工、实习生各有二十几个字段我直接复制粘贴了三份结构体改一个字段要全局搜索替换三次。直到某天改完漏了一处程序跑出来的数据全乱了才认真把继承啃了一遍。回头再看当初犯的很多错都是什么是继承这个最基础的问题没想透。这篇文章就从继承的本质讲起把语法、访问控制、构造析构顺序、多态覆盖、钻石继承这些最常出问题的点全部过一遍最后附上我踩坑总结的排查速查表。适合刚看完语法书但不知道继承怎么用的新手也适合写了一段时间C但被多继承和虚函数坑过的朋友。1. 为什么要用继承先算清设计上的账1.1 没有继承时代码是怎么浪费的我先举个极端但真实的例子。假设你有一个基类概念叫员工字段是姓名、工号、入职日期。然后你有普通员工和经理两种角色经理比普通员工多了带团队人数、下属名单这些字段。如果没有继承代码会写成两份独立的结构体里面有限考勤、算绩效、打印档案这些函数得各写一份。表面看只是多敲几遍键盘但真正的隐患在后面你发现员工需要加一个社保账号字段就得同时打开三四个地方修改改考勤算法时漏掉其中一个版本整套数据就出现不一致。继承解决的就是这个核心矛盾——把公共的部分只需要定义一次派生类通过对基类的扩展获得差异点。用继承表达出来的关系很简单普通员工是一个员工经理也是一个员工。这就是面向对象里说的is-a关系。is-a关系是继承唯一的正当理由。如果你脑子里想的是经理需要员工的功能这种has-a的需求那更好的做法是组合而不是继承。1.2 继承真正解决的三个问题第一公共接口统一。所有从员工派生出去的类型都能用指向基类的指针或引用来访问这样你的函数可以写成void PrintProfile(const Employee e)传进来的不管是普通员工、经理还是实习生都能正确打印。这就是面向对象设计里说的面向抽象编程不面向具体类编程。第二字段和行为的一致性维护。基类有了变动派生类自动同步不需要到处修补份拷贝。第三为多态提供载体。如果只有继承没有虚函数那继承只是一个代码复用的工具谈不上架构设计。有了虚函数你才能让同一个函数调用在不同对象上产生不同行为这才是继承真正的威力所在。1.3 和封装、多态的关系很多初学者分不清封装、继承、多态各自扮演什么角色。我用一个粗糙但好记的比喻封装是把数据和操作打包在一起并对外只暴露必要的接口就像餐厅只给你菜单不让你进后厨继承是在已有的类之上进行扩展和差异化相当于菜单里新增套餐套餐包含基础菜品再加上专属配菜多态则让同一条点餐指令在不同套餐上得到不同结果。三者互相配合缺一个整体设计感就崩塌了。没有继承多态无从附着没有虚函数继承就退化为简单的代码复制工具。2. 继承的三种方式public、protected、private选错就崩2.1 从最简单的public继承讲起先看代码。假设有这样的基类class Employee { public: void SetName(const string name) { name_ name; } string GetName() const { return name_; } protected: string name_; int id_; private: string salaryAccount_; };然后写一个公开继承class Manager : public Employee { public: void SetTeamSize(int size) { teamSize_ size; } private: int teamSize_; };在public继承下基类的public成员在派生类中仍然是public外界可以用manager.SetName(...)正常调用基类的protected成员在派生类中是protected派生类自己的成员函数里能直接访问name_和id_但外部代码不能通过manager.name_来访问基类的private成员在派生类中彻底不可见不能直接用salaryAccount_只能通过基类提供的公开接口间接访问。2.2 三种继承方式的权限变化表这是最值得反复对照的一张表继承方式基类public成员在派生类中的权限基类protected成员在派生类中的权限基类private成员在派生类中的权限public继承publicprotected不可访问protected继承protectedprotected不可访问private继承privateprivate不可访问注意规律继承方式决定基类成员在派生类中的可见级别封顶值。public继承保持原样protected继承把基类的public成员降级成protectedprivate继承则把所有能访问的成员全部收进派生类的private区域相当于子类继承的东西不让孙子用。2.3 不同继承方式的选型建议实际工程里public继承占到九成以上因为它表达的是is-a关系配合基类指针和虚函数可以正常实现多态。很多新手会问那protected和private继承什么时候用我给出常见的几种场景。protected继承可以理解为is-implemented-in-terms-of的设计意图它表达的是派生类能重用基类实现但不希望外界把派生类当成基类用。比如你写了一个MyList它内部想复用std::vector的实现但又不想暴露vector的全部接口可以用class MyList : private std::vectorT这样外部调用mylist.size()可能都不是你想公开的得自己在派生类里逐个转发。private继承比protected更封闭它连子类重用都不让继续传递了实际项目中很少见多数场景改成组合更清晰。但必须提醒不要把private或protected继承当作常规手法去尝试。在绝大多数需求里private继承能解决的问题组合都能解决而且组合的代码更直观、依赖更少。我刚学继承时觉得既然有三种方式就每个都试试结果非但没有提升设计反而把接口全封死了后来重构才改回组合。3. 构造与析构的顺序这步错了排查到怀疑人生3.1 构造函数的调用顺序先看一段判断输出顺序的代码class Base { public: Base() { cout Base构造 endl; } }; class Derived : public Base { public: Derived() { cout Derived构造 endl; } }; int main() { Derived d; return 0; }输出结果是先打印Base构造再打印Derived构造。原因很简单派生类对象在内存布局里包含了基类部分构造时必须先把基类的那片区域初始化好再来初始化派生类自己的成员。这个顺序编译器强制保证不受你写的初始化列表顺序影响。不过有个坑在这里初始化列表的书写顺序和实际执行顺序不一样。实际的初始化顺序是先按继承顺序构造基类再按成员声明顺序构造成员变量最后执行构造函数体和你在初始化列表里写的顺序完全无关。有些人看代码不小心会把成员初始化顺序写乱比如class Manager : public Employee { public: Manager(const string name) : teamSize_(10), nameCopy_(name) {} private: string nameCopy_; int teamSize_; };如果nameCopy_声明在前teamSize_声明在后那实际会先初始化nameCopy_(name)再初始化teamSize_(10)。虽然结果没错但一旦成员之间有依赖关系比如b_(a_)而a_声明在b_后面就会用未初始化变量这是编译器- Wreorder会警告的典型场景。3.2 析构函数的调用顺序析构顺序和构造完全相反先执行派生类的析构函数体再析构派生类的成员对象最后调用基类的析构函数。也就是说销毁对象是从最外层定制层开始拆一路拆到最基础的地基层。这个顺序和直觉一致派生类的资源往往依赖基类的资源你先释放基类派生类就会引用到不存在的部分所以必须反过来先释放派生类自己的东西。3.3 基类构造函数需要参数时怎么办如果基类只有带参数的构造函数、没有默认构造函数那么派生类的每个构造函数必须显式在初始化列表里调用基类的构造函数否则编译直接报错。写法是class Employee { public: Employee(const string name, int id) : name_(name), id_(id) {} protected: string name_; int id_; }; class Manager : public Employee { public: Manager(const string name, int id, int teamSize) : Employee(name, id), teamSize_(teamSize) {} private: int teamSize_; };这里有个经验基类尽量写一个有默认实现的构造函数并考虑是否提供protected的无参构造protected: Employee() default;。否则未来每个派生类构造都必须传参数派生类一多调用点就非常密集。反过来说如果基类本身就没有默认构造的需求强行加默认构造又容易让对象处于半初始化状态所以这里要按场景权衡不能一刀切。如果基类存在必然的状态依赖宁可让派生类明确传参也不要留一个空壳默认构造。另一个高频问题是在构造过程中使用虚函数。比如基类构造函数里调用PrintInfo()这个虚函数结果会是基类版本不是派生类版本。C规定构造期间虚函数不按动态类型分派。原因很好理解派生类成员在基类构造时还没初始化如果此时调用派生类的虚函数必然会访问到未初始化的成员。我见过有人在这种代码里排查了一天最后发现是构造函数里调虚函数。规范做法是设计一个Init()虚函数在派生类构造完成后由外部显式调用或者干脆在构造函数里只做构造不做业务逻辑分派。4. 隐藏、覆盖和多态三兄弟认不全面试必挂4.1 隐藏同名函数带来的遮蔽效应隐藏指的是派生类中定义了与基类同名的函数会导致基类的同名函数在派生类对象上通过成员访问时被遮蔽。这里的同名不要求参数相同只要名字一样就触发。举个例子class Base { public: void Print(int x) { cout Base Print int: x endl; } void Print(double y) { cout Base Print double: y endl; } }; class Derived : public Base { public: void Print(string s) { cout Derived Print string: s endl; } };此时derived.Print(42)会直接报错因为编译器在Derived的作用域中只找到了Print(string)根本不会去基类里找匹配的Print(int)、Print(double)。这就是隐藏比重载更霸道的地方重载是在同一作用域内进行参数匹配而隐藏直接屏蔽了整个同名函数集合。解决方式是在派生类的构造函数里写using Base::Print;把基类的重载集合引入派生类作用域这样派生类既能使用Print(string)也能使用继承来的Print(int)。4.2 覆盖virtual与真正的多态覆盖是虚函数的行为。基类把函数声明为virtual派生类用相同的函数签名重新实现这时通过基类指针或引用调用函数实际执行的是对象真实类型对应的版本。这就是运行时多态的核心机制。class Employee { public: virtual void ShowInfo() const { cout 姓名: name_ , 工号: id_ endl; } private: string name_; int id_; }; class Manager : public Employee { public: void ShowInfo() const override { // 加override Employee::ShowInfo(); cout 带团队人数: teamSize_ endl; } };注意这个关键区别隐藏只看函数名覆盖要求函数名、参数列表、const限定都要一致。万一派生类写成了void ShowInfo() override但基类是virtual void ShowInfo() const这个函数签名就不一致编译器会在加上override后直接报错而不是默默地让你以为是覆盖。这就override的价值编译期就能暴露签名不一致的问题。4.3 override、final和纯虚函数的设计建议在C11之后纯虚函数用 0表示含有纯虚函数的类不能实例化只能作为接口约束存在这对应到设计模式里的抽象基类或接口。纯虚函数最大的价值在于模板方法场景基类定义一套流程骨架流程里的某个步骤声明为纯虚函数由派生类各自实现基类通过虚函数分派来执行不同派生类的版本。final关键字则用来锁死继承和覆盖class Manager final : public Employee表示Manager不允许再被继承void ShowInfo() const override final表示此虚函数不允许后续派生类继续覆盖。这在一套多层继承体系里特别有用能防止别人乱改你设计的扩展点。实际工程里的经验是能用override必须用能加final尽量加能避免多层继承就避免。三层以上的继承会让虚函数表的分派逻辑变得很难追踪调试时你没法立刻确定当前对象确实调用的是哪个版本。很多团队甚至明确要求继承层级最多两层。5. 多继承与钻石问题能用但别乱用5.1 多继承的适用场景C支持一个类同时继承多个基类比如一个CPythonInterpreter可以同时继承IScriptEngine和ILanguageRuntime分别获得两套接口。多继承在接口层面上是很自然的尤其是组件化设计里一个对象同时满足多个接口约定时多继承的代码表达很直接。但多继承也有代价名字冲突、构造函数参数复杂化、对象内存布局变大。如果多个基类有同名成员函数派生类引用时就会出现二义性必须用Base1::Func()显式说明。所以很多团队风格是一个业务类有多个基类时一般只有一个真正有实现细节的基类其余全部是纯接口类。也就是把多继承退化为单实现多接口的组合这在现代C工程里是最常见的用法。5.2 钻石继承的二义性钻石继承是指一个类从两个类继承而这两个类又同时继承同一个基类class A { public: int value; }; class B : public A {}; class C : public A {}; class D : public B, public C {};此时D的对象里存在两份A的副本一份来自B一份来自C。访问d.value时编译器无法确定你说的是哪一份直接报二义性错误。更麻烦的是两份副本会各自保存状态造成逻辑上同一个基类数据在两个分支里不一致。我见过一个图形引擎项目里图标类同时继承自两个界面基类而这两个基类又共同继承自一个事件处理类结果同一次点击事件被处理了两遍因为两份事件基类的副本都收到了消息。这个问题就是钻石问题的典型表现。5.3 虚继承的解决方案和代价为了解决钻石继承C引入了虚继承class B : virtual public A {}; class C : virtual public A {}; class D : public B, public C {};加上virtual关键字后D中只保留一份A的副本B和C共享同一份A。签名变化是虚继承的类在构造时最派生类要直接负责基类A的构造。也就是说写D的构造函数时必须显式初始化A哪怕B和C的构造函数可能也需要初始化A最终只有直接构造D的初始化情况确定唯一的一份A。虚继承不是免费的编译器要给虚基类子对象加一层间接指针访问虚基类成员多一次寻址而且初始化顺序规则更复杂。实际工程里我的建议是虚继承能用但只在确实需要共享一份公共基类时使用。如果你发现自己写了钻石结构先停下来检讨一下是不是继承层次本身设计过度了往往提取一个虚基类做共享接口或者用组合替代B和C分别继承A的关系问题就简单得多。6. 常见问题与排查技巧速查6.1 基类析构函数没有virtual内存泄漏妥妥的最经典的问题基类析构函数不写virtual然后通过基类指针delete派生类对象。因为delete一个指向派生类的基类指针时如果析构函数不是virtualC只按指针的静态类型调用析构函数也就是只调用基类析构派生类的资源回调函数不会执行内存泄漏、文件句柄不释放等问题就会悄然发生。标准解决方案是在基类中把析构函数声明为virtual。如果基类是纯接口类写virtual ~IInterface() default;就好。治理规则很简单只要一个类设计为要被继承就老老实实给析构函数加virtual或至少保证基类析构通过protected来禁止外部对基类指针执行delete。后者的做法被一些对性能极其敏感的库采用因为可以避免虚析构调用开销。但普通业务代码直接virtual析构最稳妥别花时间优化这种地方。6.2 构造函数里调用虚函数为什么不按多态走前面提过构造函数里调用虚函数不会触发多态。这里再扩展一下析构函数里调用虚函数同样不会触发多态。因为析构函数执行时派生类部分已经被销毁对象当前的动态类型已经退化回基类了此时调用虚函数只能解析到基类版本。一个比较隐蔽的场景是你需要让派生类参与基类的初始化流程但又不能在构造函数里调虚函数。我以前的做法是在构造完成后由外部显式调用一个Init接口或者用一个工厂函数在对象完全构造好后再执行初始化。更简洁的思路是把这些逻辑放到一个void Init()虚函数让工厂函数在构造完成后统一调用这样既保留了多态分派又避免构造中调用虚函数导致的各类坑。6.3 拷贝构造和赋值在继承中的联动问题当派生类中包含指针成员并且基类也需要深拷贝时完整的拷贝构造函数和赋值运算符要小心处理class Base { public: Base() {} Base(const Base other) { cout Base copy endl; } }; class Derived : public Base { public: Derived(const Derived other) : Base(other) { cout Derived copy endl; } Derived operator(const Derived other) { if (this ! other) { Base::operator(other); // 再处理派生类自己的成员拷贝 } return *this; } };新手常见的错误是只给派生类写拷贝构造但忘了在初始化列表里调用Base(other)结果拷贝基类部分时用的是默认构造基类里的成员全部变成空壳数据悄悄丢失。赋值运算符同理需要显式调用Base::operator(other)。这条坑排查起来很痛苦因为编译不报错跑起来数据却不对建议在写拷贝逻辑时脑子里始终有一条基类部分也得拷贝的提醒。6.4 和其他语言继承方式的横向对比热词里有不少js继承、typescript interface怎么继承之类的问题。做技术选型时横向了解很有帮助。JavaScript的历史遗留原型链继承写法上比较绕ES6的class语法本质上还是基于原型链同样需要理解构造函数 prototype super这套体系TypeScript的interface本身不产生运行时实体它只表达编译期结构约束class可以多implements多个interface多种类和接口的关系本质上就是多个抽象约定的同时实现这和C的多接口继承非常像Python支持多继承默认的C3线性化能自动处理钻石问题但也带来MRO顺序难以预测的问题Java则直接禁止多继承类用接口多实现绕开钻石问题。对比下来C的多继承语法最原始也最灵活同时它不替你决定大量策略需要开发者自己对设计有约束力。我个人的结论是如果企业没有统一架构框架尽量避免业务代码里面出现C多继承否则团队成员对继承体系的理解成本会急剧上升。还有一个细节值得提一下基类指针和派生类指针之间进行转换时static_cast和dynamic_cast适用范围不同。向上转换是安全的可以用static_cast完成向下转换不确定是否安全时应该用dynamic_cast配合虚函数表做运行时类型检查但需要基类含有虚函数才能编译。在严格禁用了RTTI的嵌入式环境或高性能计算环境里dynamic_cast不可用推荐方案是给基类加一个virtual TypeId GetTypeId() const或类似的type接口用自实现的类型标签来替代向下转换。这也是我们在做游戏引擎资源管理器时的一个常用经验因性能需要不开启RTTI就得设计出自己的类型识别机制。最后实际写类的时候还有个小习惯派生类里如果重写了虚函数在函数体内第一步调用基类同名函数可以让公共逻辑不会丢失。比如void ShowInfo() const override { Employee::ShowInfo(); ... }。所谓重写不是重来而是在公共逻辑之上做扩展这句话值得多体会几次。随着我写的类越来越多我发现系统工程里继承用的真正重点不在于怎么写出花哨的虚函数表而在于能不能克制住自己加继承层级的欲望尤其是在业务快速迭代的代码库里少一层继承就少一份心智负担。class Manager : public Employee { public: void ShowInfo() const override { Employee::ShowInfo(); cout 管理人数: TeamSize() endl; } };如果你只能记住这篇文章里的一句话我建议是写继承之前一定先确认是不是真正的is-a关系。如果是放心用public继承如果不是组合往往更省心。这也是我回看自己早期代码最大的感悟曾经花大力气构造的庞大继承树最后大多被我用组合重新重构了反而逻辑更清晰、出bug概率更低。
返回列表