继承是C里最核心也最容易被误解的机制之一。很多人只把它当作复用代码、扩展类功能的语法工具却没意识到它牵扯的远不止语法对象的内存布局、类的对象模型、多态的底层实现全都和继承深度绑定。本文将抛开表层用法从底层原理切入系统解析继承的真实运作机制。一、初识继承1.1 继承的概念与引入继承inheritance是面向对象编程里实现代码复用最核心的手段。它能让我们在保留原有类全部特性的基础上做扩展添上新的成员函数方法和成员变量属性由此产生的新类就叫做派生类。继承把面向对象的层级结构直观地展现了出来也符合我们从简单到复杂的认知规律。我们之前熟悉的复用大多停留在函数层面而继承是站在类设计的高度做复用。举个很实在的例子现在有Student学生和Teacher老师两个类。两者都有姓名、地址、电话、年龄这些成员变量也都有 identity 身份认证的成员函数要是两个类里各写一遍纯纯的代码冗余。当然二者也有各自独有的内容老师专属的成员变量是职称学生专属的是学号学生有study学习的成员函数老师有teaching授课的成员函数。class Student : public Person{ public: void identity(){//...;} void study(){//...;} protected: int _stuid; }; class Teacher : public Person{ public: void identity(){//...;} void teaching(){//...;} protected: string title; };更省心的做法是把这些公共成员都抽出来统一放进一个Person人类里。后续Student和Teacher直接继承这个类就能复用所有公共成员不用再逐行重复定义省了不少功夫。class Person{ public: void identity(){ cout void identity() _name endl; cout _age endl; } protected: string _name 张三; // 姓名 string _address; // 地址 string _tel; // 电话 private: int _age 18; // 年龄 };说了这么多那继承到底要怎么写1.2 继承的定义格式先把叫法说清楚像Person这种被继承的类叫基类也常叫父类像Student这种继承后产生的新类叫派生类也叫子类。两组说法本质是一回事只是翻译习惯不同后面混着用也不用纠结。继承的语法格式很简单派生类名后面跟一个冒号冒号右边先写继承方式再跟上基类名。相当于告诉编译器我这个派生类要以指定的方式继承基类的成员。1.3继承的方式访问限定符和继承方式采用的是同一套关键字。先说结论如下表我们一部分一部分的拆开讲1.3.1基类private成员继承基类的private成员无论派生类采用哪种继承方式在派生类中都是不可见的。这里的“不可见”很容易产生误解不是说这个成员没被继承下来它实实在在地存在于派生类对象的内存空间里只是语法上做了严格限制派生类不管是在类内部还是类外部都不能直接访问它。简单说就是父类的私有成员子类能继承到但摸不着。当然也不是完全没有办法访问我们可以通过父类自己提供的public/protected成员函数去间接操作这些私有成员相当于走父类预留的“官方通道”。1.3.2 基类protected成员继承基类的private成员派生类完全碰不到但如果有这样的需求某个成员不想在类外被直接访问却希望派生类内部可以自由使用这时候private就满足不了了protected应运而生。可以说protected这个访问限定符本质就是因继承机制才出现的它专门用来给派生类开放访问权限同时对类外保持封闭。1.3.3 继承方式与访问权限总结把前面的规则收拢一下规律其实很清晰基类的private成员在派生类中一律不可直接访问这是铁则。除此之外的成员访问权限遵循 “就严原则”继承方式的权限和基类成员本身的权限两者取更严格的那个。权限从宽到严排序为public protected private。补充两个实用细节使用class关键字定义派生类时默认继承方式是private使用struct定义时默认继承方式是public。但无论哪种都建议显式写出继承方式代码可读性会好很多。实际开发中绝大多数场景都用public继承protected/private继承非常少见也不推荐使用。因为后两种继承下来的成员只能在派生类内部使用扩展性和可维护性都很差。二、模板类的继承2.1 容器适配器的另一种实现思路之前学习stack、queue这类容器适配器的时候我们最常用的实现方式是组合在适配器类内部定义一个底层容器的成员变量再基于这个对象封装出适配器的对外接口就像下图这样在类里放一个vectorint v作为底层存储。但适配器也完全可以用继承来实现。比如要实现一个stack我们可以直接继承vector模板类直接复用它的底层能力templateclass T class stack : public std::vectorT{ public: void push(const T x){ /* 调用底层插入逻辑 */ } void pop(){ vectorT::pop_back(); } const T top(){ return vectorT::back(); } bool empty(){ return vectorT::empty(); } };2.2 模板继承的坑按需实例化带来的问题2.2.1 调用基类成员必须显式指明依赖模板有一个核心特性叫按需实例化模板类的成员函数不会在类实例化时就全部生成只有真正被调用到的时候才会被实例化。而当子类继承一个模板基类时这个特性就会引出问题 比如我们写stackint s;这一步只会实例化stackint的构造函数连带触发vectorint的构造函数但vector里的其他成员函数都还没被实例化。当我们调用s.push(5)时编译器去实例化stackint::push函数。如果push里直接写pop_back()编译器这时候不会自动去模板基类里查找这个函数因为模板基类的成员属于“依赖名”它是否存在、是什么形态都和模板参数T相关编译器默认不会到依赖名的作用域里查找成员直接就会报“标识符未定义”的错误。所以模板类继承模板类时要调用基类的成员必须显式告诉编译器“这个成员来自基类”有两种标准写法通过this-调用this-pop_back()显式指定基类作用域vectorT::pop_back();这么设计的目的本质还是为了配合按需实例化避免不必要的模板展开同时让编译器明确成员的来源减少歧义。2.2.2 按需实例化藏住的 “隐形错误”按需实例化还有一个特点没有被调用到的成员函数编译器不会深入检查它内部的语法和逻辑正确性。举个例子templateclass T class A{ public: A(){} void push(const T x){ x.func(); } };如果我们只写Aint a;构造一个对象此时只会实例化构造函数push函数根本不会被实例化。哪怕push里写了x.func()int类型根本没有func这个成员函数编译器这时候也不会报错。只有当你真正调用a.push(xxx)时编译器实例化push函数才会暴露出这个错误。关于这点不同版本的编译器还有差异VS2013比较“宽松”只要是没被调用的接口一律不做深入检查有问题也不会提前提示。VS2019及之后的版本做了优化不依赖模板参数的代码会提前做基础语法检查但像x.func()这种依赖模板参数 T 的代码依然要等到函数实例化时才会检查。三、父子类对象的赋值兼容转换3.1 赋值兼容转换到底是什么先给前提只有在public公有继承下这条规则才成立子类对象可以直接赋值给父类对象、父类指针或是父类引用。这个操作有个很形象的俗称叫切片/切割。就像上图画的那样子类对象的内存里上半段是从父类继承来的成员_name、_sex、_age下半段是自己独有的成员比如Student的_No。赋值的时候相当于把子类里属于父类的那部分“切”出来单独赋值过去子类自己的部分直接被截掉不用。反过来肯定不行父类对象不能直接赋值给子类对象编译就会报错毕竟父类缺了子类独有的成员补不上。一个很容易踩的误区这真不是类型转换很多人第一眼看到“不同类型互相赋值”下意识就觉得这是类型转换其实完全是两码事。怎么证明看这个图的对比就一目了然。拿普通的int转double举例子int i 1; double d i;这是正经的类型转换中间会生成一个临时的double变量再把值赋给d。临时变量自带常性所以你写double rd i; 编译必报错。普通左值引用接不住临时对象。但父子类的赋值完全不一样Person rp sobj;这行代码能顺顺利利跑通。原因很简单全程根本没生成临时变量。这个引用直接绑定了子类对象里、原本就存在的那一块父类内存相当于给子类里的 “父类部分” 起了个别名。如下图也能实锤通过rp修改_name改的就是sobj本身的数据没有拷贝、没有转换就是同一块内存。代码示例与内存对应我们结合代码和内存图再顺一遍逻辑就彻底通了int main() { Student sobj; // 1. 子类对象赋值给父类对象、父类指针、父类引用反过来不行编译报错 Person pobj sobj; // 切片拷贝只复制父类部分的成员 Person* pp sobj; // 父类指针指向子类中父类部分的起始地址 Person rp sobj; // 父类引用引用子类中父类的那一块区域 return 0; }下图也画得很清楚父类指针的访问范围刚好卡在子类对象里父类成员的边界上碰不到子类自己的_No成员。最后补个小知识点反过来父类的指针/引用也能通过强转转回子类的指针/引用但这事儿有安全前提只有当这个父类指针本来就指向一个子类对象时转换才是安全的。如果是多态场景更推荐用dynamic_cast做运行时类型识别稳妥很多。四、继承体系里的名称隐藏规则实战拆解4.1 隐藏规则详解代码实例继承关系中基类父类和派生类子类有着互相独立的作用域。 如果父子类定义了同名成员子类的同名成员会屏蔽对父类同名成员的直接访问这个现象叫做隐藏也叫名称遮蔽。若想在子类内部访问被隐藏的父类成员必须用基类::基类成员的格式显式标注作用域。这里有个高频踩坑点成员函数形成隐藏的门槛很低只要函数名一致就会触发隐藏哪怕参数列表不同也不例外千万别和要求同一作用域才能成立的函数重载弄混实际开发里非常不建议在继承体系设计同名成员后续维护很容易出现难以排查的bug。用一段可运行代码直观演示效果class Person{ protected : int _num 111; string _name 小李子; // 此处_num存储身份证号 }; class Student : public Person{ public: void Print() { cout 姓名:_name endl; cout 身份证号:Person::_num endl; // 显式指定父类作用域访问被隐藏的成员 cout 学号:_numendl; // 无限定符会优先查找子类作用域的_num } protected: int _num 999; // 学号和父类同名形成隐藏关系 }; int main(){ Student s1; s1.Print(); return 0; }最终打印出的学号_num是999子类的_num把父类的同名成员隐藏了不加限定的情况下编译器会优先在子类作用域检索符号。 很多人会产生误解隐藏不是父类成员消失了只是调整了编译器的符号查找优先级父类成员依旧存在于子类对象内存中只是不能直接访问而已。4.2 两道小测题检验掌握程度A类和B类的两个func函数属于什么关系 A. 重载 B. 隐藏 C. 没关系下面程序的编译运行结果是 A. 编译报错 B. 运行报错 C. 正常运行class A{ public: void fun(){cout func() endl;} }; class B : public A{ public: void fun(int i){cout func(int i) i endl;} }; int main() { B b; b.fun(10); b.fun(); return 0; };逐题解析第一题选B重载的硬性要求是函数处于同一个作用域父子类分属两个独立作用域仅函数名一致就满足隐藏的判定条件。第二题选 Ab.fun(10)可以正常调用子类带参版本但b.fun()会编译失败子类的fun(int i)隐藏了父类无参的fun()子类作用域检索不到无参函数想要调用只能写b.A::fun()指明父类作用域。最后再敲个实用警钟除非业务刚需不然千万别在继承体系里定义同名成员五、派生类的6大默认成员函数深度解析5.1 六个默认成员函数的继承适配规则之前学类和对象时我们了解过编译器会自动为类生成6个默认成员函数这张思维导图清晰划分了它们的职能构造、析构负责初始化与资源清理拷贝构造、赋值重载完成对象的拷贝复制两组取地址重载分别适配普通对象和const对象日常开发基本不用手动实现。5.1.1 构造函数子类的构造函数必须调用父类构造函数完成从父类继承来的那部分成员的初始化。 我们可以把子类对象拆成两部分理解继承得到的父类专属成员只能交给父类构造初始化子类新增的独有成员才由子类构造处理。如果父类有无参默认构造编译器会自动帮你调用要是父类没有默认构造就必须在子类构造的初始化列表里显式调用父类构造public: Student(const char* name, int num, const char* addrss) : Person(name) // 显式调用父类构造初始化继承来的成员 , _num(num) , _addrss(addrss) {} protected: int _num 1; // 学号 string _addrss 湖州市吴兴区; int main(){ Student s(张三, 1, 湖州市); return 0; }5.1.2 拷贝构造函数子类的拷贝构造必须调用父类的拷贝构造完成父类成员的拷贝初始化Student(const Student s) : _num(s._num) , _addrss(s._addrss) , Person(s) // 调用父类拷贝构造此处触发了子类向父类的赋值兼容转换 {}补充细节初始化列表的初始化顺序严格遵循类内成员的声明顺序父类成员声明更早、内存排布更靠前因此永远先初始化父类部分如果父类有编译器自动生成的默认拷贝构造不用我们手动写调用语句编译器会自动补齐逻辑。5.1.3 赋值重载operator子类的赋值重载必须调用父类的operator完成父类成员的赋值拷贝。这里是新手高频翻车点子类的operator会隐藏父类的同名函数不能直接裸写operator(s)错误写法会触发无限递归Student operator(const Student s) { if (this ! s) { operator(s); // 调用的仍是子类自身的operator陷入递归死循环 _num s._num; _addrss s._addrss; } return *this; // 原文笔误*t已修正 }从调试堆栈截图能直观看到层层嵌套的递归调用栈正确写法是显式标注父类作用域Person::operator(s);这样才能调用到父类的赋值重载逻辑。5.1.4 析构函数子类析构函数执行完自身的资源清理逻辑后编译器会自动插入调用父类析构的代码强制遵循「先清理子类独有成员再清理父类继承成员」的顺序。 很多人会疑惑父子析构函数函数名写法不同怎么会形成隐藏其实编译器会把所有类的析构函数统一编译为内部标识destructor()所以父子析构天然满足隐藏的判定条件。这里有个致命误区绝对不要手动在子类析构里写Person::~Person();// destructor() ~Student() { Person::~Person(); }截图里仅创建了 3 个 Student 对象却打印出 6 次父类析构调用手动写了一次调用编译器又自动补了一次直接造成重复析构的未定义行为。反汇编代码也能实锤子类析构的末尾会自动插入父类析构的调用指令5.1.5 构造析构生命周期总结铁律一定要记牢创建子类对象先执行父类构造函数→再执行子类构造函数销毁子类对象先执行子类析构函数→再自动执行父类析构函数编译器把析构调用逻辑强制自动化就是为了避免开发者手动操作搞反析构顺序、引发内存崩溃而构造阶段开放手动显式调用的权限是为了适配父类自定义构造的多样化场景。5.2 两种实现不可被继承类的方案方案 1C98传统方案——私有化基类构造函数子类想要实例化对象就必须调用父类的构造函数如果把基类构造设为私有子类完全无法访问父类构造就没法创建子类对象自然实现了禁止继承的效果。方案 2C11更推荐方案——final关键字直接用final修饰基类直白标记这个类为“最终类”编译器会直接拦截后续所有继承行为代码可读性也更强class Base final{ // final锁定禁止任何类继承 public: void func5() { cout Base::func5 endl; } protected: int a 1; private: // C98方案在这里私有化构造函数实现禁继承 Base(){} }; // 下行代码会直接编译报错无法继承final修饰的类 class Derive : public Base{ public: void func4() { cout Derive::func4 endl; } };六、继承与友元继承与静态成员6.1 继承与友元交情不世袭先记死一条结论友元关系不能继承。说白了你是我父类的朋友不代表你自动就是我子类的朋友。父类的友元函数只能访问父类的私有/保护成员碰不到子类的私有和保护成员。看个典型的错误写法class Student; // 前置声明让Display的形参能识别Student类型 class Person{ public: friend void Display(const Person p, const Student s); protected: string _name; }; class Student : public Person { protected: int _stuNum; }; void Display(const Person p, const Student s) { cout p._name endl; // 没问题Display是Person的友元 cout s._stuNum endl; // 直接报错访问不了子类的保护成员 }很多人会想当然子类都继承了父类那父类的友元自然也能访问子类成员。其实完全不是这么回事Display只拿到了Person的友元授权Student没给它开权限自然碰不到_stuNum。解决办法也很直接在 Student 类里也给 Display 补一个友元声明就行。6.2 继承与静态成员全族共享一份如果父类里定义了static静态成员那整个继承体系里这个成员永远只有一份实例。不管你派生多少个子类、创建多少个对象大家共用这同一个静态成员。和普通成员对比一下就一目了然普通成员父类有一份原生的子类继承后自己也有一份独立拷贝两者地址不同互不干扰。静态成员父类和子类用的是完完全全同一份地址一模一样。上代码直观验证class Person { public: string _name; static int _count; }; int Person::_count 0; // 静态成员必须类外初始化 class Student : public Person { protected: int _stuNum; }; int main() { Person p; Student s; // 普通成员_name两个对象地址不同各存各的 cout p._name endl; cout s._name endl; // 静态成员_count地址完全相同共用同一份 cout p._count endl; cout s._count endl; // 公有静态成员通过类域也能直接访问 cout Person::_count endl; cout Student::_count endl; return 0; }上面的测试代码运行结果换个角度理解也很形象父类里的静态变量本质就是个套着父类类域外壳的全局变量整个程序只有一份继承体系自然也共享。七、多继承原理与菱形继承的坑7.1 三种继承模型先理清先把基础概念掰明白单继承一个派生类只继承一个直接基类就是我们最常见的一对一继承。多继承一个派生类同时继承两个或以上的直接基类。多继承的对象内存布局有规律先继承的基类排在内存最前面后继承的跟在后面最后放派生类自己的成员。菱形继承多继承的一种特殊坑人形态。两个子类继承同一个父类又有一个类同时继承这两个子类形状像个菱形因此得名。菱形继承天生带两个致命问题数据冗余和二义性最顶层的基类成员会在最底层的派生类里存两份。也正因如此很多语言直接就不支持多继承比如Java从根源上规避了这个问题。实际开发里也非常不建议写出菱形继承的结构。7.1.1 单继承与多继承的内存模型单继承的内存就是“父类成员在前子类成员在后”顺顺当当一块连续空间。 多继承就是按继承顺序把多个父类的成员依次排开最后接子类自己的成员相当于把几个父类的内存拼在了一起。7.2 菱形继承到底坑在哪举个最经典的场景Person是父类Student和Teacher都继承Person最后Assistant助教同时继承Student和Teacher这就是标准的菱形继承。 Assistant对象里会存两份Person的成员一份来自Student路径一份来自Teacher路径所有问题都源自这两份重复的基类。7.2.1 数据冗余平白无故浪费空间这个最好理解Person里的姓名、年龄这些基础信息Assistant里存了两份。但现实里一个助教只需要一份个人信息多出来的那份完全是多余的平白膨胀了对象空间这就是数据冗余。7.2.2 二义性连程序都懵了冗余只是浪费空间二义性直接让对象的逻辑都站不住脚。 举个更极端的例子帮你感受Male继承Person后sex代表男性Female继承 Person后sex 代表女性。那Child同时继承Male和Female它就同时拥有了两个sex成员一个是男一个是女那这个孩子到底是什么性别程序根本给不出唯一答案这就是二义性。这可不只是编译报错的小事它直接让对象的语义崩了现实里一个人只能有一个性别菱形继承却硬生生造出两份Person子对象搞出两个性别属性完全违背了设计初衷。落到代码里就是这样Assistant a; a._name peter; // 编译报错对_name的访问不明确编译器不知道你要访问的是Student路径的_name还是Teacher路径的_name。 当然可以手动指定作用域a.Student::_name peter来解决二义性但数据冗余的问题还是消不掉。7.2.3 标准库也有菱形继承菱形继承虽然坑但C标准库里还真有经典的菱形继承场景就是我们后面会讲的IO流库它正是靠虚继承解决的问题。7.3 虚继承菱形问题的官方解法7.3.1 菱形虚拟继承怎么写为了解决菱形继承的冗余和二义性C引入了虚继承。用法很简单在中间层子类继承顶层基类时加上virtual关键字。class Person{ public: string _name; // 姓名 }; // 中间层继承时加virtual虚继承Person class Student : virtual public Person{ protected: int _num; //学号 }; class Teacher : virtual public Person{ protected: int _id; // 职工编号 }; // 最底层子类正常继承即可 class Assistant : public Student, public Teacher{ protected: string _majorCourse; // 主修课程 }; int main(){ Assistant a; a._name peter; // 不再报错整个对象只有一份_name return 0; }虚继承解决问题的核心逻辑让Student和Teacher共享同一份Person基类对象而不是各自带一份。这样Assistant里最终也只会有一份Person成员冗余和二义性就都解决了。话虽这么说但还是那句忠告非必要别写菱形继承更别随便用虚继承。这东西理解成本高底层也有额外的性能开销写出来维护的同事大概率要骂街。7.3.2 关于虚继承的三个常见误区这里纠正几个很容易踩的坑误区一虚继承会影响中间类的正常使用。错。虚继承主要影响最底层的派生类比如 Assistant单独用Student或者Teacher的时候和普通继承没什么区别。误区二只给一个中间类加virtual就行。错。必须所有中间子类Student 和 Teacher都加虚继承才能保证最终只有一份基类。误区三virtual加在最底层就行。给你看个题想让E继承后不出现A的冗余virtual该加在哪 答案是加在B和C继承 A 的时候。 记住原则哪层开始产生重复的基类就在哪层的继承上加virtual。冗余源自A就从B、C继承A的时候开虚继承。7.3.3 多继承与虚继承小结很多人吐槽C语法复杂多继承就是典型的槽点之一。 有了多继承就会有菱形继承有了菱形继承就得有虚继承底层实现跟着变复杂还带了性能损耗。所以业界普遍共识是尽量别设计菱形继承多继承本身都要慎用。不少后来的语言直接砍掉了多继承也算是变相承认这是C的一处设计瑕疵。前面说的IO流库的菱形继承就是用虚继承解决的摘段源码给你看templateclass CharT, class Traits std::char_traitsCharT class basic_ostream : virtual public std::basic_iosCharT, Traits {}; templateclass CharT, class Traits std::char_traitsCharT class basic_istream : virtual public std::basic_iosCharT, Traits {};后面的iostream同时继承istream和ostream靠虚继承保证只有一份basic_ios基类。7.4 多继承里的指针偏移小细节先做道小题热热身下面代码里三个指针的值说法正确的是class Base1 { public: int _b1; }; class Base2 { public: int _b2; }; class Derive : public Base1, public Base2 { public: int _d; }; int main() { Derive d; Base1* p1 d; Base2* p2 d; Derive* p3 d; }结论是p1和p3地址相同p2地址更高。​​​​​​​ ​​​​​​​道理也简单我们前面说过多继承的内存布局先继承的排在最前面。Derive先继承Base1再继承Base2所以内存里Base1在最开头Base2紧跟其后最后是Derive自己的_d。所以指向Base1的p1刚好和Derive对象的起始地址重合指向Base2的p2会自动偏移到Base2 子对象的起始地址数值上比p1、p3大。打开内存窗口和监视面板一看就能实打实印证这个偏移效果。八、继承与组合两种复用的正确打开方式8.1 什么是组合和继承到底差在哪先看两种实现stack的写法一眼就能看出区别组合写法把list容器直接当成成员变量塞进类里class stack{ private: list _lt; };继承写法直接让stack继承list类class stack : public list {};前者是在类内部“持有”一个基类对象这就是组合后者是通过继承“拥有”基类的全部成员这就是继承。落到设计原则上两者有明确的语义区分public继承是is-a是一种的关系每个派生类对象本质上都是一个基类对象。组合是has-a包含一个的关系如果B组合了A说明每个B对象内部都持有一个A对象。8.1.1 两句话搞懂 is-a 和 has-a不用记复杂定义套生活场景立刻明白is-a我是一名大学生。大学生属于人的一种这就是继承关系。has-a我有一颗心脏。人身上包含心脏这个部件这就是组合关系。8.1.2 本质差别白箱复用vs黑箱复用写法和语义的差别还是表层两者真正的分水岭在封装性和耦合度上。业内也给两种复用起了很形象的名字白箱复用和黑箱复用。继承 白箱复用white-box reuse继承是基于基类的内部实现来扩展派生类所谓“白箱”就是基类的内部细节对子类是可见的。 代价也很直接继承一定程度上破坏了基类的封装性。基类的protected成员子类能直接访问基类的实现改动很容易牵连到派生类。父子类绑定极深耦合度非常高。 另外继承不管是public还是protected级别的接口子类都能直接使用相当于把基类的半内部能力都开放给了子类。组合 黑箱复用black-box reuse组合是继承之外的另一种复用选择不用继承而是通过组装、组合对象来实现更复杂的功能。 被组合的对象只对外暴露定义清晰的public接口内部实现完全不可见就像一个打不开的黑盒子所以叫“黑箱复用”。 组合的两个类只通过公开接口交互彼此内部改动互不影响依赖关系弱、耦合度低还能保住各自的封装性。而且组合只能用到对方的public接口开放的接口越少耦合就越低。补个大家好理解的类比这和软件测试的思路完全对应黑盒测试只测功能、不关心内部代码白盒测试要深入代码逻辑校验两者的可视程度是一个道理。8.1.3 最终结论优先选组合业界有一条公认的设计原则优先使用对象组合而不是类继承。 实际开发里能上组合就尽量用组合耦合低、改动灵活、后续维护省心太多。但也不用把继承一棍子打死如果类和类之间明确是is-a的关系用继承更贴合语义要实现多态的话继承是必选项组合替代不了。 如果一个场景两种方式都能实现拿不准选哪个选组合基本不会踩坑。