ARTICLE DETAIL

资讯详情

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

C++继承要点解析:构造析构顺序、虚函数与菱形继承

C++继承要点解析:构造析构顺序、虚函数与菱形继承 继承一个几乎所有C教材都会讲、几乎所有C面试都会问的知识点。但说实话我见过太多人把继承停留在class B : public A这个语法层面等到真正上手做项目要么在多重继承里绕晕要么被构造析构顺序坑得欲哭无泪要么硬生生把继承用成了脱裤子放屁的多此一举。这篇不打算给你复述教科书只聊我在实际工程和带新人过程中觉得C继承里最值得掰开揉碎讲清楚的几个点踩过的坑、想通的道理一次性说透。1. 三种继承方式public、protected、private到底改了什么1.1 从访问控制的角度看继承同一句class Derived : public Base把public换成protected或者private代码能不能编译、外部怎么看待Derived完全是两个世界。很多初学者只知道public继承最常用但并不知道这三个词的真正意义。简单说继承方式决定的是基类成员在派生类里以及通过派生类对象访问时它们的访问级别被重新定义成什么。用一个十几行的小例子就能看明白class Base { public: void pubFunc() {} protected: void protFunc() {} private: void privFunc() {} }; // public继承基类的public仍是publicprotected仍是protectedprivate不可访问 class PubDerived : public Base { }; // protected继承基类的public降级为protected外部无法通过对象直接调用 class ProtDerived : protected Base { }; // private继承基类的public和protected全部降级为private class PrivDerived : private Base { }; int main() { PubDerived pd; pd.pubFunc(); // OKpublic继承保持了接口 ProtDerived pt; // pt.pubFunc(); // 编译错误protected继承把接口藏起来了 PrivDerived pv; // pv.pubFunc(); // 编译错误private继承完全密封 return 0; }这段代码是理解三种继承的一把钥匙。public继承表达的是接口继承也就是Derived完全继承了Base对外的全部接口任何能用Base对象的地方都能用Derived对象这就是所谓的is-a关系——猫是动物所以Animal*可以指向Cat对象。而protected和private继承本质上是实现继承它们不是为了让你当作基类来用而是为了复用基类的实现细节。1.2 判断该用哪种继承的实战经验我在实际代码里见过一个特别典型的误用有人想把一个工具类的方法全部复用到新类里图省事写了class MyClass : public Tool但MyClass和Tool在业务上根本构不成is-a关系。这种场景下如果Tool不具备多态性、没人会通过Tool*去操作MyClass那public继承就是把内部实现细节全部暴露给了外部调用者。一旦哪天你改了Tool的接口所有把MyClass当Tool用的代码全部跟着崩耦合度直线上升。判断该用哪种继承我的习惯是问自己三个问题外部代码需要把派生类当作基类来传递吗需要用public继承。我只是想用基类已经写好的成员函数业务上两者不是is-a关系优先考虑private继承或者干脆改成组合。既不想暴露接口又希望派生类内部还能继续往上继承这种中间态才用protected继承。protected继承在实际项目里用得很少它更像一个半成品状态外部不可见但给更下一层的派生类留了口子。如果你发现自己写的是protected继承大概率是在做框架设计或者模板库否则建议停下来想想是不是设计上应该用组合更合适。1.3 友元与继承的一个冷门坑继承和友元之间有个很容易被忽略的细节友元关系不会被继承。基类的友元函数可以访问基类的private和protected成员但让它去访问派生类的private/protected成员编译器直接报错。反过来也一样如果某个函数被声明为派生类的友元它也不自动具备访问基类private成员的权限除非基类里也声明了它是友元。这个坑在写操作符重载、序列化函数这些必须碰私有成员的场景里特别常见。我有一段时间在写一个跨模块的日志系统基类里定义了好友函数做对象快照输出后来某个派生类加了自己的私有字段想让日志函数顺手打出来结果发现怎么改都不对卡了半天才反应过来——友元根本不会传给派生类。解决方案也简单要么在派生类里也声明对应的友元要么给基类提供一个protected接口让派生类通过接口把私有数据暴露出来。2. 构造与析构的顺序C继承里最容易翻车的战场2.1 构造顺序的完整链路在C继承体系里构造一个派生类对象绝对不是先进入派生类的构造函数体里再一步步往上找基类。真正的执行顺序是这样的按继承列表的顺序依次调用所有直接基类的构造函数按派生类中成员变量声明的顺序依次构造所有成员对象最后才进入派生类自己的构造函数体换句话说基类比成员先就绪成员比构造函数体先就绪。这个顺序是有讲究的构造函数体里的代码可能要用到基类子对象和成员变量如果它们还没初始化好你在这个阶段读到的就是一堆随机值这在逻辑上完全说不通。所以C标准强制了这套顺序。看一个稍微复杂点的例子#include iostream class A { public: A() { std::cout 构造A\n; } ~A() { std::cout 析构A\n; } }; class B { public: B() { std::cout 构造B\n; } ~B() { std::cout 析构B\n; } }; class C : public A, public B { private: B b_; public: C() { std::cout 构造C\n; } ~C() { std::cout 析构C\n; } }; int main() { C c; return 0; }运行结果是构造A 构造B 构造B 构造C 析构C 析构B 析构B 析构A注意这里出现了两次构造B一次是基类B一次是成员对象b_。C的基类按声明顺序先构造然后才是成员对象。析构顺序则完全镜像反过来先C自己的析构体再成员对象再基类。这个顺序可以用一句话记先构造的后析构后构造的先析构跟栈一样。2.2 初始化列表里的隐蔽陷阱初始化列表是继承体系里第二个高频翻车点。很多人以为初始化列表的执行顺序就是列表里写的顺序事实上决定权在变量声明顺序跟列表书写顺序无关。例如class Base { public: Base(int x) : value_(x) {} int value_; }; class Derived : public Base { public: Derived() : Base(42), derived_(value_) // 直觉上derived_应该拿到42 {} private: int derived_; };这个例子看起来没问题但是如果derived_声明在某个先于它的成员之后而那个成员间接依赖了value_就可能出现用了还没初始化的值的情况。更隐蔽的是如果基类构造函数需要在初始化列表里被显式调用比如基类没有默认构造函数而你把调用参数写成了派生类某个成员的值那这个成员此时还没构造完成。你等于拿一堆半成品去造基类。我个人的规矩是初始化列表里只使用参数绝不引用同类成员变量如果需要根据成员计算什么放到构造函数体里去做。这个习惯帮我避免了至少十次莫名其妙的问题。2.3 为什么基类析构函数必须写virtual这是一个老生常谈但我依然会在面试中反复遇到有人答不上来只要你的类设计出来是要被继承的基类析构函数就应该是virtual。原因是当通过基类指针或引用删除一个派生类对象时如果析构函数不是virtual编译器只会调用基类的析构函数派生类部分成员对象、堆上资源全部不会释放产生未定义行为。代码上表现就是class Base { public: ~Base() {} // 非virtual }; class Derived : public Base { private: int* data_; public: Derived() : data_(new int[100]) {} ~Derived() { delete[] data_; data_ nullptr; } }; int main() { Base* ptr new Derived(); delete ptr; // 只调用了~Base()data_泄漏 return 0; }这个问题在大型项目中非常隐蔽不是每次构造派生类都会泄漏只有那些以基类指针持有派生类对象的代码路径会出问题而且内存泄漏工具往往只能告诉你某处分配的内存没释放很难直接定位到是析构函数缺了virtual。还有个更微妙的点即使析构函数不是virtual你在实际测试中可能也不会立刻看到内存疯涨因为进程退出时操作系统会回收全部内存。这类问题只有在长期运行的服务进程里才会慢慢积累等到内存曲线开始爬坡你光靠日志已经很难回溯了。所以最好的做法就是在写第一个类、第一个继承关系时就把基类析构函数标记为virtual而不是等出了问题再补。3. 虚函数与动态绑定继承体系里真正的灵魂3.1 从vptr/vtable讲清楚多态机制虚函数是C继承真正有价值的地方。virtual关键字的存在意味着同一条调用语句在不同对象上会执行不同的函数体这就是动态绑定运行时多态。其底层机制是每个含有虚函数的类编译器会为它生成一张虚函数表vtable表中保存了该类所有虚函数的地址。每个对象内部有一个隐藏指针vptr指向这张表。调用虚函数时程序先去对象的vptr找表再从表的对应位置取函数地址然后调用。这个机制带来的实际含义是虚函数调用比普通成员函数慢一点点——多了一次指针寻址和间接跳转。现代CPU的分支预测对这种间接跳转不那么友好所以如果你在高频循环里调用虚函数性能损耗是可感知的。但这不意味着你应该事事避免虚函数而是说把虚函数用在设计上真正需要多态的地方而不是拿来当普通函数用。一个实用的优化建议是如果某个函数要在每秒百万次的循环里调用且确实需要多态你可以考虑在热路径上先取出虚函数指针比如在循环体外通过引用拿到对象或者干脆用模板CRTP将多态从运行时搬到编译期。但对大多数业务代码而言这种优化属于做过早优化的范畴先用virtual把代码写清楚等你真的从性能剖析器里看到热点再动手。3.2 构造函数和析构函数中调用虚函数不变态吗这是C继承里我印象最深的一个坑在构造和析构函数中调用虚函数调用的不会是多态版本而是当前这个构造/析构阶段所属类的版本。原因是虚函数派发依赖vptr指向的vtable而vptr是在每个类的构造函数完成时被设置为当前类的vptr。当基类构造函数运行时派生类还没开始构造vptr还指向基类vtable因此调用虚函数只会执行基类版本等派生类构造结束后vptr才指向派生类vtable此时才具备多态能力。析构函数反向同理。看这个例子#include iostream class Base { public: virtual void print() { std::cout Base::print\n; } Base() { print(); } virtual ~Base() {} }; class Derived : public Base { public: void print() override { std::cout Derived::print\n; } Derived() { print(); } }; int main() { Derived d; return 0; }输出结果是Base::print Derived::print如果不懂这个机制的人看到Base的构造函数里明明调用了print为什么是Base的print会以为是个bug其实这是C对对象生命周期的一种保护在对象还没完整构造起来之前不允许它表现出不完整的多态身份。这个规则影响设计不要在构造函数里依赖虚函数来做初始化。比如基类构造函数想通过虚函数让派生类提供某个配置值这是行不通的。正确做法是把配置值作为构造参数传给基类或者在派生类构造完成后显式调用初始化函数。3.3 override、final和隐藏hidingC11引入override关键词后我终于可以不用担心在重写时手滑把参数写错了。override不是强制关键词但它像一道编译器级别的保险丝你写void print(int x) override如果基类没有对应的虚函数编译器直接报错而如果你漏写了override恰恰又和基类函数签名不一致你写出来的其实是一个新函数把基类的同名函数给隐藏了hiding而不是重写override。隐藏和重写之间的差别曾让我在一个信号处理模块上苦战半天。基类有个virtual void OnMessage(const Message)派生类写了个void OnMessage(Message)——少了个const结果基类指针调用时永远走不到派生类版本。编译器没报任何错因为签名不同被视为隐藏这其实是一个全新的函数。事后我在团队里定下规矩凡是重写虚函数必须写override凡是继承体系里不想被重写的基类上标记final。一句话就堵住了这个类别的全部低级错误。4. 菱形继承与虚继承绕不开的设计难题4.1 菱形继承的问题出在哪菱形继承指一个派生类同时继承两个中间类而这两个中间类又都继承自同一个基类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编译器会直接报歧义错误——它不知道你要访问B里那份A的value还是C里那份A的value。更麻烦的是如果你把D对象的地址转换成A*也会因为到底转换成哪份A的问题报错。这个问题在真实项目中并不少见。我见过一个UI框架基类Widget被Button和Panel都继承了而某个高级组件想同时继承Button和Panel的接口结果一份Widget的属性被复制成两份坐标系统、事件状态全部打架调试时看内存布局都累。4.2 虚继承到底做了什么虚继承的解决方式是让中间类使用virtual继承基类class A { public: int value; }; class B : virtual public A { }; class C : virtual public A { }; class D : public B, public C { };此时D中只有一份A子对象B和C共享这份A。编译器为了支持这种共享会在B、C中插入指向A子对象的偏移信息访问A成员时需要通过间接寻址。代价是对象内存布局更复杂多出额外的指向信息成员访问略微变慢多一次间接偏移计算初始化顺序更微妙虚基类最先构造且其构造参数需要由最派生类来提供为了支持虚基类最派生类的构造函数必须在初始化列表里直接调用A的构造函数否则A会走默认构造。这一层隔代指定构造参数的机制让很多人在不知不觉中把代码写得很绕。4.3 我在工程里对虚继承的判断标准我处理菱形继承时会先思考一个更本质的问题这个继承结构是不是设计上出了问题虚继承虽然能解决成员重复问题但也在提醒你——你的类层次可能已经复杂到难以维护的程度了。我对团队的建议通常是层级一般不超过三层超过三层需要考虑重构尽量避免出现菱形如果出现优先尝试把公共基类改为接口类纯虚类或者用组合替代继承如果确实需要共享基类数据优先考虑把公共部分提取成成员对象而不是虚继承只有当你必须通过一个共同的基类指针统一管理多个又共享祖先的对象时才认真考虑虚继承在实际项目里我最后真正使用虚继承的次数并不频繁但每次用都是在框架级抽象中比如插件系统里多个接口模块共同依赖一份公共生命周期状态的场景。对于普通业务代码虚继承往往是设计复杂度失控的信号。5. 继承不是银弹哪些场景该用组合5.1 组合优于继承的判断标准很多C开发者刚学会继承时会有一种万物皆可继承的冲动看到两个类有点相似的代码就想通过继承减少重复。但继承其实是所有代码关系中最强的一种耦合派生类依赖基类的实现细节一旦基类改动波及面可能非常大。相比之下组合在一个类中持有另一个类的对象是一种松耦合的复用方式它表达的是has-a关系——汽车有发动机而不是汽车继承发动机。我判断该用组合还是继承有几个很实际的标准是否真的满足is-a猫是动物所以Cat继承Animal成立但如果说猫是灭鼠工具这就很牵强如果只是为了复用扑鼠的代码那改成class Cat { MouseCatcher catcher; }更合理。基类是否需要多态指针操作如果从来不会有Base*指向派生类virtual没有用武之地继承演变成纯粹的代码搬运那组合通常更合适。是否要覆盖override基类的方法如果承载的行为完全相同、只是数据不同那组合加参数化的构造函数往往比继承出多个子类更清晰。5.2 一个重构实例从继承到组合我接手过一个历史遗留模块里面有个基类ReportGenerator里面包含了生成报表的十几个步骤。后来需求加了新的报表类型新同事直接在基类上加了个宏开关然后派出SpecialReportGenerator去重写基类的一些步骤最后代码里出现了基类根据某个标志位跳过某段逻辑、派生类再调用基类受保护接口补回逻辑这种奇怪的控制流。重构方案是把报表的每一步拆成策略接口ReportStep然后ReportGenerator组合多个ReportStep通过传入不同的步骤对象来产出不同报表。原来的继承树全部删除替换为运行时组装。修改之后新增一种报表类型只需要实现新的ReportStep不再需要动基类的共享逻辑。这个改动的本质是从继承复用实现变成了组合复用接口。如果你发现你的继承层次里派生类为了复用基类代码不断去重写基类的虚函数、调基类的protected成员那大概率是继承用错了方向组合才是出路。5.3 接口继承与实现继承的取舍最后一点我想说的是继承这个词在不同语境下其实承载着两种含义接口继承派生类继承基类的纯虚函数目的是兑现同一个接口不同行为基类只定契约不定实现实现继承派生类直接继承基类已经写好的函数体目的是复用代码现代C项目里接口继承纯虚类基本是安全且推荐的因为它建立的是契约实现继承则需要非常克制因为它建立的是耦合。我在写框架时倾向于把接口类和实现类分开设计接口类只放纯虚函数实现类通过组合或private继承来实现接口类声明的功能。6. 实战中排查继承问题的几个具体场景6.1 基类指针delete时崩溃野指针与未定义行为我排查过一个线上服务崩溃问题崩溃栈非常诡异有时出现在析构函数有时出现在内存释放。初看完全随机的最后用valgrind跑了一轮才发现是基类虚析构函数缺失导致派生类资源没释放后续代码访问了已经释放的派生类对象。这类问题要跳出一个误区未定义行为不保证崩溃也不保证每次都崩溃。它可能特定优化级别下才爆发也可能只在某个平台崩溃。排查思路第一步永远是把所有涉及继承的类都检查一遍基类析构函数必须为virtual第二步是静态扫描工具或编译器警告确保无遗漏。一旦确认这条很多玄学崩溃能解决一半。6.2 多重继承中的名称歧义多重继承带来的常见的坑就是歧义。假设class D : public B, public C中B和C都定义了一个void print()D对象调用d.print()时编译器会报告歧义。解决方式是用作用域限定符d.B::print()但这往往也是代码异味——说明D的接口设计存在问题。我在设计多重继承时有一条底线多个基类之间禁止有同名成员函数除非它们本来就来自同一个虚基类。如果做不到就应该重新审视多重继承是否必要。现实中很多多重继承都可以拆成单一继承多个接口纯虚类接口继承天然没有数据成员和实现几乎不会产生歧义冲突。6.3 切片问题的实际影响把派生类对象按值赋给基类对象时会发生对象切片object slicing派生类部分全被切掉只剩基类子对象。这不算bug但很容易让人困惑class Base { public: virtual void f() {} int base_data; }; class Derived : public Base { public: int derived_data; }; void Process(Base b) {} // 按值传参 Derived d; Process(d); // d被切片成Basederived_data必然丢失切片还会导致虚函数调用退回到基类版本因为切片后的对象根本就是一个基类对象。很多程序员在这个问题上栽过因为他们默认派生类总是表现得像派生类。记住一条规则如果你需要多态参数和容器必须是指针或引用绝不能是按值的对象。严格点说STL容器存对象时如果存的是基类对象也会导致切片因此实际项目里常用std::shared_ptrBase或std::unique_ptrBase来存储。6.4 static成员与继承的共享语义在继承体系中基类的static成员变量只有一份所有派生类共享而不是每个派生类都复制一份。这点和Java/C#中的语义一致但C初学者经常以为我在派生类里改了它基类不受影响结果发现全局状态被改掉了。如果确实需要每个派生类各有一份static惯用技巧是static 模板CRTPtemplate typename Derived class Countable { public: static int count_; }; class MyClass : public CountableMyClass {};此时MyClass::count_和OtherClass::count_是各自独立的变量。这个技巧在需要按类统计实例数量、注册表等场景下非常实用。7. 我最后想说的几句实在话C继承这门技术真正拉开差距的地方从来不是语法本身而是你什么时候该用它、什么时候不该用它以及它在内存模型和生命周期上到底做了什么。构造函数不要把虚函数当作多态的钩子基类析构函数第一时间记得写virtual重写虚函数一律加override能用组合解决问题的时候就别硬造继承——这几条做到了你已经比很多写了几年C的人要稳。如果你刚接触C继承可以这么练找一个小项目比如简单表达式求值器或待办事项管理器先把继承用到你能设计出的最复杂层次再尝试用组合、模板和接口重新实现一遍。两版代码都跑起来后再去对比可维护性。这个过程比看十篇博客都有用。
返回列表