ARTICLE DETAIL

资讯详情

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

C++继承深度剖析:从语法细节到项目设计实战,避开菱形继承与对象切片陷阱

C++继承深度剖析:从语法细节到项目设计实战,避开菱形继承与对象切片陷阱 继承这个话题我在C项目里几乎每天都要打交道。面试时候聊继承十个候选人里至少有六七个会在“隐藏与覆盖”“菱形继承”“对象切片”这些点上翻车。语法本身不难一个冒号加几个访问控制关键字就能让一个类“变成”另一个类的子类可它牵一发而动全身——构造顺序、析构顺序、虚函数表、内存布局、拷贝语义任何一个环节理解得含糊程序就会在运行到某个边界条件时给你一个措手不及的惊喜。这篇文章我把这些年用继承踩过的坑、总结出的设计思路以及排查问题时的实用技巧一次性写清楚不管你是刚学C的新手还是写了两三年但一直没系统梳理过继承的开发者都能在里面找到能直接拿去用的东西。1. 继承到底解决了什么问题——先别急着写语法很多人学继承是直接从“怎么继承”开始背语法但我想先聊“为什么需要继承”。理解了这个后面你才能判断一个场景该不该用继承而不是看哪个类长得像就给它们套父子关系。1.1 从生活化的例子理解继承想象你有一个“动物”的概念它具备一些公共特征有名字、会吃东西、会发出声音。后来你有了“狗”“猫”“鸟”这些更具体的概念它们除了具备动物的所有特征之外还有自己专属的行为比如狗会看家猫会抓老鼠。在代码里“动物”就是一个基类“狗”“猫”就是派生类。派生类天然拥有基类的成员变量和成员函数不需要重新写一遍这就是最直观的代码复用。用一个最简单的例子#include iostream #include string class Animal { public: Animal(const std::string name) : name_(name) {} void breathe() { std::cout name_ is breathing std::endl; } protected: std::string name_; }; class Dog : public Animal { public: Dog(const std::string name) : Animal(name) {} void bark() { std::cout name_ is barking std::endl; } }; int main() { Dog dog(Wangcai); dog.breathe(); // Dog 继承了 Animal 的方法 dog.bark(); // Dog 自己的方法 return 0; }这里Dog没有写breathe却能调用它因为public继承让Animal的public成员在Dog里依然是public。这种“is-a”关系是继承最核心的语义狗就是一种动物所以任何能接受Animal的场合都可以传一个Dog进去。1.2 继承和封装、多态的关系面向对象三大特性——封装、继承、多态经常被当作三个独立知识点去背实际上它们是一条链上的三环。封装把数据和操作打包在类内部只暴露必要的接口这是大厦的地基继承在封装的基础上建立类与类之间的层次关系让公共逻辑上移、差异化逻辑下沉多态则依赖继承这个结构让程序在运行时根据对象的真实类型调用不同的实现。没有继承多态就失去了载体。想想看如果你有一组形状要绘制没有继承你只能写一长串if-else判断对象的类型然后逐个调用对应的函数每新增一个形状就要改动所有判型的地方。有了一个Shape基类和一堆Shape的子类你可以统一用“获取面积”“绘制”这样的接口来操作具体怎么算怎么画由每个子类自己决定。这就是继承配合虚函数带来的价值面向统一的接口编程而不是面向具体的类型编程。1.3 一个容易忽略的前提基类应该抽象出“共性”继承设计失败的案例绝大多数源于把“相似的代码”当成了“共性的本质”。两个类都有run方法不代表它们就应该是父子关系。正确的做法是先从业务场景里抽象出稳定的接口再考虑用继承还是用组合来实现。我在后文的“项目中的继承设计”部分会展开讲这个判断过程这里先埋个伏笔继承是手段抽象才是目的。2. 三种继承方式——public/protected/private的差异和选择C里继承有三种权限public继承、protected继承、private继承。很多教材把这三种方式列成一张表让读者背但很少解释为什么实际项目里几乎只用public继承另外两种到底什么时候才派得上用场。这一节我把它们的本质讲透。2.1 先复习访问修饰符的作用范围类成员的访问权限由高到低是public任何地方都能访问、protected类内部和派生类内部可以访问、private只有类内部能访问。注意一个细节protected和private在“类内部”这个层面其实是等价的区别只在于派生类能不能访问。这一点是理解继承方式的关键。看一段验证代码class Base { public: int pub; protected: int prot; private: int pri; }; class Derived : public Base { public: void inspect() { pub 1; // 可以public成员在派生类中可见 prot 2; // 可以protected成员在派生类中可见 // pri 3; // 错误private成员对派生类不可见 } };不管用什么继承方式基类里private的成员都不会被派生类直接访问这是C的底线。继承方式影响的只是“基类中的public和protected成员到了派生类里变成什么权限”。2.2 三种继承方式的对照表继承方式基类public成员在派生类中的权限基类protected成员在派生类中的权限基类private成员典型用途public继承publicprotected不可直接访问is-a关系最常用protected继承protectedprotected不可直接访问实现继承进一步隐藏接口细节private继承privateprivate不可直接访问has-a关系的替代实现接口全部私有用代码验证一下权限降级的效果class Base { public: void pub_method() {} protected: void prot_method() {} }; class PubDerived : public Base { // pub_method 在派生类中为 public // prot_method 保持 protected }; class PriDerived : private Base { // pub_method 和 prot_method 在派生类中都是 private public: void wrapper() { pub_method(); // 内部可以调用 } }; int main() { PubDerived pd; pd.pub_method(); // 可以public继承保持public // pd.prot_method(); // 不可以protected对类外不可见 PriDerived pr; // pr.pub_method(); // 错误private继承后基类public成员在派生类中是private pr.wrapper(); // 只有通过派生类自己的public接口才能间接调用 return 0; }2.3 为什么实际项目里几乎只用public继承public继承表达的是语义层面的“is-a”狗是一种动物圆是一种形状FileLogger是一种Logger。外部代码可以把派生类对象当作基类来用这是多态的地基。protected继承和private继承都属于“实现继承”它们的目的是复用基类的实现细节而不是暴露接口。其中private继承可以看作组合的一种替代方案。什么时候用private继承一个经典场景是你需要用到基类里某个protected成员同时又不希望外部代码把这个类当成基类来用。比如class Timer { protected: void tick() { /* ... */ } }; class MyService : private Timer { public: void onLoop() { tick(); // 复用Timer的实现细节 } };你也可以用组合实现同样的效果MyService内部持有一个Timer对象。差别在于private继承能访问Timer的protected成员而组合不行除非Timer把对应成员设为public。还有一点private继承天然获得了“空基类优化”的资格对于只包含类型和函数、没有任何数据成员的基类可以让派生类尺寸不增加。我的建议很简单新代码里90%的情况用public继承剩下那10%如果动了private继承的心思先问问自己能不能用组合替代。C社区有一句流传很广的话——“继承是除了组合之外最后的手段”。后面我会专门讲组合优先原则这和本节的内容是呼应的。3. 多继承与菱形继承——C特有的双刃剑Java和C#砍掉了多继承Python用MRO算法解决了多继承的冲突问题只有C保留了最原始的多继承能力并且把处理冲突的责任交给了程序员。多继承用好了能写出很精巧的架构用不好就是一个大型事故现场。这一节把多继承的坑全部摆出来讲。3.1 多继承到底适合什么场景多继承的经典用途有两个。第一个是“接口组合”让一个类同时实现多个纯虚接口这在设计上等价于Java里的多接口实现。第二个是“实现复用接口暴露”的组合从一个实现类继承获得代码从一个抽象类继承获得对外接口。举个我实际做过的例子一个数据采集系统里的网络模块class NetworkInterface { // 纯接口 public: virtual void send(const char* data, size_t len) 0; virtual ~NetworkInterface() default; }; class TcpHelper { // 实现类提供了具体的socket封装 protected: void openSocket(int port) { /* ... */ } void writeSocket(const char* data, size_t len) { /* ... */ } }; class TcpNetworkModule : public NetworkInterface, public TcpHelper { public: void send(const char* data, size_t len) override { writeSocket(data, len); // 复用TcpHelper的实现 } };这种“一个负责接口一个负责实现”的多继承模式非常干净问题不大。真正的麻烦出在菱形继承。3.2 菱形继承问题——一个经典事故现场菱形继承说的是基类A有两个直接子类B和C然后类D同时继承B和C这样就形成了一个菱形的继承结构。问题在于B和C各自保存了一份A的成员数据D继承了B、C两份A的数据导致D内部出现两个A子对象。class Base { public: int value; }; class MidLeft : public Base { }; class MidRight : public Base { }; class Bottom : public MidLeft, public MidRight { public: void show() { // value 10; // 错误编译器不知道value是MidLeft::value还是MidRight::value MidLeft::value 10; // 只能手动指定访问哪一个 MidRight::value 20; } };用之前的Animal例子改造一下更直观假设Animal有一个age_成员Dog和Cat分别继承Animal而“狗猫兽”这个类同时继承Dog和Cat那么它会有两份age_。这往往不是设计者想要的结果而且数据冗余之外还有更隐蔽的问题如果你把Bottom对象传给一个参数类型为Base的函数会因为存在两个Base子对象而直接编译报错歧义。C给出的标准解法是虚继承在中继类继承基类时加上virtual关键字class Base { public: int value; }; class MidLeft : virtual public Base { }; class MidRight : virtual public Base { }; class Bottom : public MidLeft, public MidRight { }; int main() { Bottom b; b.value 30; // 不报错了只有一个Base子对象 return 0; }加上virtual之后编译器保证最终的派生类里只存在一份Base子对象MidLeft和MidRight共享它。代价是虚继承的基类子对象在内存中的位置不再固定在派生类开头而是通过某种偏移机制动态定位访问效率上会有一些可以忽略不计的损失更直观的代价是设计复杂度上升。3.3 对比其他语言的处理方式Python的多继承同样会遇到菱形结构但它在语言层面定义了MRO方法解析顺序通过C3线性化算法算出每个类该取哪个版本的方法程序员不需要手动指定。JavaScript虽然现在有class和extends但底层是原型链查找方法靠沿原型链向上寻找第一个匹配项遇到菱形结构天然表现为“先到先得”。C选择了一条更直接、也更考验人的路交给程序员自己决定是虚拟继承该共享基类还是让多个基类子对象各自存在。这就意味着你在设计继承层级时脑子里得有一张清晰的继承拓扑图。3.4 我在实际项目里对多继承的取舍给出一个可复用的判断标准如果多继承只涉及“多个纯接口类”放心用如果涉及“一个具体实现类一个或几个接口类”谨慎用如果出现了菱形结构先停一下想想能不能把被共享的基类降级成成员对象通过组合规避。我的经验是绝大多数菱形结构都能通过“提取组合成员”来化解真正必须虚继承的场景其实非常少。虚继承本身没有错但它会把类的关系图弄得很复杂后来维护的人往往只看得到B继承A、D继承B和C这两行声明完全意识不到中间还藏着一个虚拟基类。所以能用组合化解的尽量别用虚继承硬撑。4. 虚函数与运行时多态——继承的灵魂如果继承只是代码复用那它顶多算一种“高级复制粘贴”。继承真正的价值在于配合虚函数实现运行时多态程序在运行过程中面对同一个接口调用不同对象各自实现的行为。这一节我会从虚函数表讲起把“覆盖和隐藏的区别”“纯虚函数和抽象类”“构造析构顺序”这些高频又容易出错的点一次讲透。4.1 虚函数背后的原理虚函数表当你的类里声明了至少一个virtual函数时编译器会为这个类维护一张虚函数表vtable表里存放的是各个虚函数的实际入口地址。每个对象里会多出一个隐藏的指针——虚函数指针vptr指向自己所属类的虚函数表。调用一个虚函数时程序先通过对象的vptr找到虚函数表再从表里取出真正的函数地址来调用。class Shape { public: virtual double area() const { return 0; } virtual void draw() const { /* 默认实现画一个占位图形 */ } }; class Circle : public Shape { private: double radius_; public: Circle(double r) : radius_(r) {} double area() const override { return 3.14159 * radius_ * radius_; } void draw() const override { /* 画圆 */ } };关键点来了虚函数调用是运行期决策。当你写Shape* s new Circle(1.0); s-draw();时s的静态类型是Shape*但s指向的对象的vptr指向的是Circle类的虚函数表所以调用的是Circle::draw。这个机制让代码可以面向抽象编程不管将来来了多少种形状调用方只需要知道它们是Shape。4.2 覆盖、隐藏、重载——三个被混为一谈的概念这三个词是C面试里的高频区分题现实中踩坑的频率也高。重载overload同一个作用域内函数名相同但参数列表不同静态决议。覆盖override派生类定义一个与基类虚函数相同签名名字、参数、const限定符都相同的函数并标注override替换虚函数表中的对应条目动态决议。隐藏hiding派生类定义了一个与基类同名的函数不管参数和类型是否一致都会把基类的同名函数从派生类的作用域里“遮住”静态决议。最容易踩的坑是“想覆盖结果写成了隐藏”。比如基类里有一个virtual void handle(int)你在派生类里写了void handle(double)打算处理double型的请求结果编译器并没有把它当作对handle(int)的覆盖——它只是在派生类里新增了一个同名函数同时把基类的handle(int)隐藏了。当你通过基类指针调用s-handle(42)时因为签名不匹配可能走到基类的handle(int)当你通过派生类对象调用d.handle(42)时整数42会被转成double调用的是派生类的handle(double)。两个调用结果完全不一致逻辑直接错乱。正确的习惯是派生类覆盖虚函数时必须加override关键字。这不仅是自我文档化更是让编译器帮你检查签名是否真的和基类一致。签名对不上编译器直接报错当场暴露问题而不是留到运行期给你一个难以定位的诡异行为。4.3 纯虚函数与抽象类当一个类里有了纯虚函数 0这个类就成了抽象类不能直接实例化。抽象类的作用是定义接口契约要求所有派生类必须实现这些纯虚函数。如果一个派生类没有全部实现它自己也是抽象类同样不能实例化。用C做纯接口类时我喜欢把所有业务方法写成纯虚函数析构函数写成virtual ~IInterface() default;并且不声明任何数据成员。class ILogger { public: virtual void log(const char* message) 0; virtual void flush() 0; virtual ~ILogger() default; };这是C里最接近Java接口的东西。注意那个析构函数——它是整个设计里绝对不能省略的一环。原因往下看。4.4 构造与析构的顺序——以及构造/析构函数里别调虚函数派生类对象创建时构造顺序是先构造基类部分再构造派生类新增的部分。销毁时顺序正好反过来先执行派生类析构函数再执行基类析构函数。很多人知道这个顺序但不一定理解它背后的原因基类先构造才能保证派生类构造时所需的公共基础设施已经就位反过来派生类先析构才能保证它在使用基类资源的时候那些资源还没有被释放。由此引出一个非常有名的禁忌不要在构造函数和析构函数里调用虚函数。原因很简单构造基类部分时派生类的部分还不存在虚函数表的入口仍指向基类自己的版本析构派生类的部分之后派生类的数据已经没了再调虚函数也只能调到基类的版本。所以在这两个阶段调用虚函数调用到的都是当前类自己的版本而不是最终派生类的版本。你以为是多态其实是自嗨。class Base { public: Base() { init(); } virtual void init() { std::cout Base::init std::endl; } }; class Derived : public Base { public: void init() override { std::cout Derived::init std::endl; } }; int main() { Derived d; // 输出 Base::init而不是 Derived::init return 0; }还有一个和析构函数强相关的经典错误基类的析构函数没有virtual然后用基类指针delete派生类对象。结果就是只调用基类的析构函数派生类的资源永远不会被释放这就是未定义行为。别觉得它只是“内存泄漏”在复杂的继承体系里它可能让程序在销毁对象时直接崩溃。凡是准备当作多态基类来用的类析构函数必须是虚的这条没有任何例外。5. 项目中的继承设计——从理论到实践到这一步语法层面的东西就讲完了。但很多读者真正头疼的不是“怎么写”而是“该不该写”。我在带团队做技术评审时最常说的一句话是**如果你们讨论一个设计讨论了半小时还没用上任何继承先别急着引入它。**这一节讲讲项目里怎么判断继承的适用性以及一套我一直在用的实践准则。5.1 组合优先于继承“优先使用组合而非继承”是GoF设计模式里的第一原则。这话的意思不是“继承不好”而是说很多看起来适合继承的场景用组合会更简单、更灵活、更不容易出错。判断标准很简单如果A和B之间不具备is-a关系就不要用继承只要能用“A拥有一个B”来表达的就用组合。拿现实来说你有一个Car类和一个Engine类。Car和Engine是什么关系Car拥有一个Engine是has-a关系。如果写成Car继承Engine那就是“汽车是一种发动机”语义上完全错误而且会让Car莫名其妙获得发动机的所有内部接口。更隐蔽的问题是这种错误继承一旦建立后续的扩展都会建立在错误的地基上——比如Engine加一个转速变量Car就跟着有了一个转速但它可能是辆电动车根本不该有这东西。组合优先原则真正要防止的是“公共代码复用的冲动”。看到两个类里有一模一样的函数第一反应经常是抽一个基类把它们“统一”起来。这个冲动要克制。正确的流程是先问自己这些类共享的是什么——是行为接口还是实现细节如果共享的是行为接口用继承如果只是碰巧有几段代码长得像优先抽成工具函数或者独立的辅助对象让两个类各自持有它们。曾经有个同事把管理字符串的StringHelper类作为基类让所有需要格式化字符串的业务类继承它。短期看代码确实省了可三个月后他在其中一个业务类里为StringHelper添加了一个缓存字段导致所有继承它的类都无端多出一块内存。这就是把“复现代码”当成了“复现架构”的代价。5.2 一个完整的实践案例渲染系统中的形状体系我用一个实际的例子把这一整套设计落一遍。假设要写一个轻量渲染系统需要支持圆形、矩形、三角形等多边形形状每类形状有自己计算面积、绘制图形的方式。用继承来建模class Shape { public: virtual double area() const 0; virtual void draw() const 0; virtual ~Shape() default; }; class Circle : public Shape { private: double radius_; public: Circle(double r) : radius_(r) {} double area() const override { return 3.1415926 * radius_ * radius_; } void draw() const override { /* ... */ } }; class Rectangle : public Shape { private: double width_, height_; public: Rectangle(double w, double h) : width_(w), height_(h) {} double area() const override { return width_ * height_; } void draw() const override { /* ... */ } };然后需要一个工厂让调用方不用关心具体类型#include memory class ShapeFactory { public: static std::unique_ptrShape createCircle(double r) { return std::make_uniqueCircle(r); } static std::unique_ptrShape createRectangle(double w, double h) { return std::make_uniqueRectangle(w, h); } };调用方统一按Shape来使用std::vectorstd::unique_ptrShape shapes; shapes.push_back(ShapeFactory::createCircle(2.0)); shapes.push_back(ShapeFactory::createRectangle(3.0, 4.0)); for (const auto s : shapes) { std::cout area s-area() std::endl; s-draw(); }这个设计的价值在于以后扩展一个新形状比如Triangle只需要新增一个继承Shape的类然后在工厂里加一行创建函数。所有调用方代码一行都不用改。这就是开闭原则——对扩展开放对修改关闭。对比一下如果不用继承而用if-else判断类型每加一个形状就要去改所有出现判型的地方改漏一个就是运行期bug。这里有三个设计细节值得强调。第一Shape析构函数声明为virtual这是多态基类的硬性要求。第二工厂统一返回std::unique_ptrShape利用移动语义避免把子类对象“切片”成父类——这是下一节要说的重点。第三纯虚函数把Shape明确标记为“不可实例化的接口”从编译层面杜绝了空Shape对象被创建的可能。5.3 继承相关的编码规范清单基于上面的实践整理一份我通常要求团队成员遵守的清单多态基类的析构函数必须是virtual否则用基类指针删除派生类对象属于未定义行为。覆盖虚函数时必须写override让编译器替你校验签名没有覆盖意图的同名函数换个名字避免隐藏造成意外。构造和析构函数中不调用虚函数调了也不是多态还会让人误读逻辑。优先使用纯虚函数定义接口类接口类不要带数据成员让实现类的责任更清晰。不要为了复用代码而使用public继承复现代码用组合、工具函数或模板解决。出现菱形继承前先画图想一想是不是该用虚继承或者干脆用组合重新建模。基类的数据成员尽量设为private派生类需要访问时提供protected的getter/setter。直接裸露protected数据会让派生类和基类高度耦合后续想改基类内部结构会牵动所有派生类。这些规范看着简单每一条都是真实事故换来的。我见过最简单的一款“无virtual析构导致崩溃”程序在退出时对全局的单例形状对象调用基类析构结果派生类的智能指针成员还没来得及释放持有的资源句柄全部泄漏程序结束时的清理逻辑直接访问了已经失效的句柄段错误。崩溃现场离错误根源隔了整整一个模块排查起来极其痛苦。6. 常见问题与排查技巧实录这一节我直接把平时工作里被问得最多、踩得最实的继承问题整理成速查表再挑几个典型场景详细讲解排查思路。6.1 问题速查表问题现象根本原因解决方案通过基类指针调用不到派生类的函数函数不是虚函数静态绑定将函数声明为virtual派生类加overridedelete基类指针时只调用了基类析构基类析构不是virtual将基类析构声明为virtual派生类写了好多功能但基类指针都调不到函数签名不一致变成了隐藏统一签名添加override让编译器校验编译报“cannot convert ... to ...”存在多个基类子对象类型转换歧义用虚继承消除重复子对象或重新设计构造函数里调虚函数行为不符合预期构造阶段虚函数绑定当前类不要在构造函数中调用虚函数把子类对象按值传给基类参数对象切片派生类信息丢失传引用、传指针或使用智能指针派生类调用基类的某个成员编译报不可访问访问权限不足或继承方式把成员降权调整继承方式或基类成员访问级别6.2 详解三个最典型的坑第一个是对象切片。这是很多人第一次写继承就掉进去的坑。把派生类对象按值赋值给基类类型的变量时只有基类部分的子对象被复制过去派生类新增的部分被直接切掉了这就是“切片”。解决办法是通过引用或指针传递对象。这也是为什么现代C里你会看到std::unique_ptrShape和Shape满天飞几乎不会看到一个按值返回Shape的函数。void drawByValue(Shape s) { s.draw(); } // 糟糕参数按值传递切片 void drawByRef(const Shape s) { s.draw(); } // 正确引用传递保留多态第二个是虚函数的隐藏问题。我在4.2节提到了这里举个例子展示它带来的bug有多隐蔽class Base { public: virtual void process(int x) { std::cout Base::process(int) std::endl; } }; class Derived : public Base { public: void process(double x) { std::cout Derived::process(double) std::endl; } }; int main() { Derived d; d.process(42); // 调用 Derived::process(double)42被转成double Base b d; b.process(42); // 调用 Base::process(int)因为没有覆盖 }同一个数字42通过派生类对象调用走了double版本通过基类引用调用走了int版本。如果这两个版本内部逻辑还不同程序行为就是撕裂的。遇到这种问题第一反应就是检查派生类的同名函数有没有写override没有就是一票否决直接补上。第三个是虚继承终结子类的构造顺序。虚继承还有个冷门坑如果A是虚基类B虚继承AC继承B而且最终派生类是DD也虚继承A那么A的构造函数由D直接调用不是由B调。这一点让很多人意外——他们的直觉是“既然B继承了A那构造B时肯定把A的构造函数带上了”可在虚继承的机制里负责构造虚拟基类的是最底层的最终派生类。如果B的构造函数里有A(参数)而D的构造函数没有显式传给A构造函数的参数A就会用默认构造函数初始化。这种初始化遗漏在大型项目里特别难发现因为它不会报错只是程序运行起来某个字段的初始值不对。6.3 独家排查技巧排查继承相关的运行时问题我有一套固定的打法。第一招让编译器把类布局和虚函数表dump出来。GCC和Clang都支持-fdump-class-hierarchy编译选项会生成一个文本文件里面清楚地列出了每个类的虚函数表条目、每个子对象的内存偏移。看一个菱形继承的类究竟有几个A子对象偏移是多少一翻这个文件就全明白了。第二招用调试器看vptr。在gdb里p *obj可以打印对象的内存布局能看到vptr指向的地址然后用info vtbl obj查看虚函数表的条目。对比不同对象的vptr指向能快速确认这个对象到底走的是哪个类的方法表。我曾经用这个方法解决过一个诡异问题一个对象从工厂出来后被某个函数以引用方式传了一圈最后vptr指向的虚函数表居然是中间某个临时对象类型的表——排查下来发现是移动语义写错了导致对象被移动后旧对象的内存被复用。第三招善用编译器的告警。开启-Wall -Wextra后重名隐藏、析构函数非虚、忘了写override这类问题很多会有告警提示。不要让告警被滚动日志淹没把告警当错误来对待也就是开启-Werror会让这类问题在编译阶段就现形。结尾最后分享一点个人体会。继承是C里最需要“设计直觉”的机制它的语法三天就能学完但什么时候用、什么时候不用、用哪一种继承方式、需不需要虚继承这些判断只能靠实际项目慢慢磨。我自己的经验是遇到一个“要不要继承”的设计难题先写一版组合方案和一版继承方案然后用两个问题来裁决——改动一个派生类时会不会被迫改动基类或其他派生类外部代码是否希望感知到这个类的具体类型两个问题回答都是“否”继承就是合适的有任何一个是“是”就回去重新考虑组合。这个方法帮我避开了大量后期返工。如果你在看这篇的时候正好在纠结某个类设计不妨也把这两问题写在代码注释里过三个月回来看多半会庆幸自己当时选对了。
返回列表