1. 从四处复制的代码泥潭里爬出来继承到底解决什么问题我接手过一个小工具项目里面有三个类FileLogger、ConsoleLogger、NetworkLogger。打开一看每个类里都有几乎一样的timestamp()函数、一样的formatLevel()函数、一样的m_level成员变量。改一个时间戳格式我得改三处还漏过一次导致有两处输出格式和另外一处不一样排查了半小时。这种代码就是典型的需要继承的信号。继承inheritance在 C 里的定位非常明确它表达的是是一个is-a的语义关系同时承担代码复用的职责。上面的例子里三个 Logger 都是一个 Logger那些重复的成员就应该被搬到共同基类里派生类只保留各自差异化的部分。先把最小可用的语法摆出来class Logger { // 基类 / 父类 public: void log(const std::string msg) { std::cout [ timestamp() ] msg \n; } protected: std::string timestamp() const; // 供派生类复用 private: int m_level 0; // 派生类也碰不到 }; class FileLogger : public Logger { // 派生类 / 子类 public: void logToFile(const std::string msg) { log(msg); // 直接复用基类的 log } };这里有几个必须一次性说清的点否则后面全乱。第一派生类对象的内存里完整包含了一个基类子对象。FileLogger对象不是引用了一个Logger它就是内嵌了一个Logger。这也是为什么继承能被当成一种强耦合关系——派生类的布局直接依赖基类的布局。第二基类的private成员确实存在于派生类对象中只是派生类的成员函数和数据成员访问不到它。很多人以为 private 成员不会被继承这是误解它被继承了只是访问受限。第三构造、析构、拷贝赋值运算符、友元函数这几样不会被继承。你别指望写个class B : public A {}就自动获得 A 的构造函数。接下来要区分一个特别容易混淆的判断题什么时候该用继承什么时候该用组合成员对象判断标准就一句话如果派生类能自然地说出我是一个基类用继承如果只能说出我有一个基类用组合。Dog是一个Animal用继承。一个Car有一个Engine用组合。我见过最典型的误用是有人让Stack去继承std::vector理由是栈底层就是数组复用一下 push_back 多方便。问题在于std::vector的insert、erase、operator[]全都跟着被继承进来了任何人拿到这个 Stack 都能从中间插一个元素进去栈的不变式只能从一端进出瞬间被破坏。正确做法是让 Stack 里包含一个 vector 作为私有成员只暴露push、pop、top。一句话总结这一节继承是绑定了is-a语义的强关系复用只是它的副产品不是它的目的。为了省几行代码去继承一个语义不搭的类后面维护的人会骂你。2. public、protected、private 三种继承方式究竟改变了什么C 给了三种继承方式很多人学完就记住一句都用 public 就对了然后碰到private继承的代码直接懵。其实这三种方式的规则可以压缩成一张表。核心逻辑是基类成员原有的访问级别与继承方式取更严格的那一个得到它在派生类中的新访问级别。而无论哪种继承方式基类的private成员在派生类里始终不可直接访问。基类中成员级别public 继承后protected 继承后private 继承后publicpublicprotectedprivateprotectedprotectedprotectedprivateprivate不可访问不可访问不可访问public 继承是最常见的形式它保持了基类接口的开放程度不变基类对外能调的函数派生类对象对外也能调这正是接口继承要的效果。protected 继承把基类的 public 接口降级成 protected外界拿不到只有更深层的派生类能用。这种用法很少见一般出现在你需要复用实现、但又不想把基类接口暴露给使用者的时候。private 继承会把基类的一切 public/protected 成员都变成派生类的 private 成员。它在语义上接近于用继承实现组合但因为继承带来的强耦合和布局绑定绝大多数情况下还不如直接写个成员对象来得干净。我个人的经验是除非你要用到空基类优化EBO这类底层技巧否则 private 继承基本可以不用。用一个具体例子把差异摆清楚class Base { public: void pub(); protected: void prot(); }; class PubDeriv : public Base { }; // 外界: d.pub() 可以调用 class PrivDeriv : private Base { }; // 外界: d.pub() 编译错误 // 在 PubDeriv 内部的成员函数里 void PubDeriv::test() { pub(); // OK基类 public 成员 prot(); // OK派生类内可访问 protected }这里有个坑我得单独拎出来说继承方式会影响能不能赋值/指针转换。public 继承Derived*可以隐式转换成Base*Derived也能绑到Base上。这就是多态能跑起来的前提。private / protected 继承这个转换在类外部是被禁止的。也就是说你写Base* p new PrivDeriv;会直接编译不过。我见过有人把派生类写成 protected 继承然后在外面拿基类指针去指向它死活编译不过翻了半天语法书才找到原因。一旦你发现需要通过基类指针统一操作一堆派生类那这组类就必须用 public 继承没有别的选择。再补一个易错点三种继承方式只影响从基类继承来的那些成员不影响派生类自己新加的成员。派生类自己写的 public 成员不管你是哪种继承方式它对外还是 public。很多人第一反应是private 继承后整个类都变成私有的了这是错的。那实际项目怎么选我给一个相当干脆的结论需要多态、需要被当成基类使用、表达 is-apublic 继承。只想复用实现、不想暴露基类接口优先考虑组合实在有理由再用 private 继承。protected 继承几乎用不到看到能读懂就行。3. 构造函数与析构函数的调用顺序对象是这样一层层搭起来的继承体系里最容易出运行时问题的不是访问权限而是构造和析构的顺序。因为你一旦在构造/析构期间访问了还没准备好或者已经被销毁的东西编译器不会拦你程序会以一种莫名其妙的方式出问题。先说结论背下来构造顺序基类构造函数 → 成员对象构造函数按声明顺序→ 派生类自己的构造函数体。析构顺序派生类析构函数体 → 成员对象析构 → 基类析构。刚好反过来像剥洋葱一样从外层往里剥。为什么构造必须自底向上因为派生类构造时很可能会用到基类已经初始化好的数据。基类得先打好地基派生类才能在上面盖楼。析构反过来是因为派生类的析构可能需要操作基类资源得先把派生类这层拆干净再去拆基类。看个例子class Base { public: Base() { std::cout Base 构造\n; } ~Base() { std::cout Base 析构\n; } }; class Member { public: Member() { std::cout Member 构造\n; } ~Member() { std::cout Member 析构\n; } }; class Derived : public Base { public: Derived() { std::cout Derived 构造\n; } ~Derived() { std::cout Derived 析构\n; } private: Member m_member; // 成员对象 };构造一个Derived对象输出顺序是Base 构造 Member 构造 Derived 构造 Derived 析构 Member 析构 Base 析构注意这里有个反直觉的地方成员对象的构造顺序由它在类里的声明顺序决定而不是由初始化列表里的书写顺序决定。如果你把初始化列表写成Derived::Derived() : m_member(), /* 其他 */ { }但m_member在类里是最后声明的那它依然最后构造。编译器一般会给出-Wreorder警告别忽略它因为如果成员之间有依赖关系比如m_b的构造用到了m_a顺序错了就是实打实的 bug。再讲派生类怎么给基类传参。派生类不能直接初始化基类的成员只能通过调用基类构造函数来完成class Base { public: Base(int x) : m_x(x) {} private: int m_x; }; class Derived : public Base { public: Derived(int x, int y) : Base(x), m_y(y) {} // 显式调用基类构造 private: int m_y; };如果你不写: Base(x)编译器会尝试调用基类的默认构造函数。如果基类没有默认构造函数比如上面这个Base那就直接编译错误。这个错误信息有时候比较绕新手常常看不懂其实就是基类没默认构造你得在初始化列表里喂给它参数。注意不要在构造函数和析构函数里调用虚函数尤其是纯虚函数。构造基类子对象时派生类部分还没建好此时虚函数表指向的是当前正在构造的这一层调用虚函数只会执行到基类的版本绝不会跳到派生类。如果你在基类构造里调了个纯虚函数那就是直接运行时崩溃。析构时同理。这个坑在模板方法 多态初始化的代码里出现频率很高。还有一个实战细节基类析构函数是否要声明为虚函数规则是——只要这个类会被当作基类、并且存在用基类指针 delete 派生类对象的可能基类析构就必须是 virtual。否则delete basePtr只会调用基类析构派生类那部分的资源就泄漏了。这条规则我在第 5 节还会展开。4. 名字隐藏为什么子类的同名函数把父类的函数藏起来了这一节讲一个折磨了无数人的现象派生类里定义一个和基类同名哪怕参数不同的函数就会把基类里所有同名函数全部隐藏而不是形成重载。先看现象class Base { public: void func() { std::cout Base::func()\n; } void func(int x) { std::cout Base::func(int)\n; } }; class Derived : public Base { public: void func(double d) { std::cout Derived::func(double)\n; } }; Derived d; d.func(10); // 编译错误还是调用 Base::func(int)答案是d.func(10)会编译报错提示找不到合适的重载。原因是Derived::func(double)把基类里那组func全隐藏了编译器在Derived的作用域里只看到func(double)10 转 double 不是精确匹配但能转实际上这里调的是Derived::func(double)而不是 int 版本……等等这里得说得更精确。真正的规则是名字查找发生在作用域层面先找到名字再做重载决议。编译器先在Derived作用域里找到func这个名字一旦找到就停止向外层基类作用域查找然后只在Derived::func这一组候选里做重载决议。所以基类的两个func根本没进入候选集。参数为 int 的调用会被Derived::func(double)接住int 隐式转 double。这就导致一个非常隐蔽的 bug你本想重载基类函数结果不小心把基类版本全盖掉了有些调用悄无声息地走进了不符合预期的实现。解决办法有两个。第一个是用using声明把基类的名字拉进来class Derived : public Base { public: using Base::func; // 把基类所有 func 引入本作用域 void func(double d) { /* ... */ } };这样d.func(10)就能正确命中Base::func(int)重载决议照常工作。第二个是显式写d.Base::func(10)但这是调用方的事属于权宜之计不推荐。根治方法是在派生类里加using Base::func;。接下来把三组极易混淆的概念一次性区分清楚这三者在面试和实际编码里都高频出现。概念触发条件效果重载 (overload)同一作用域内同名、参数不同编译期选出最匹配的一个隐藏 (hide)派生类定义同名成员不分参数基类同名成员全部不可见除非 using 引入重写 (override)派生类重写基类虚函数签名完全一致运行期通过虚表动态分发隐藏和重写最容易搞混关键区别在基类函数是不是虚函数。基类函数非虚派生类写个同名同参函数那是隐藏派生类对象调用走派生版本但通过基类指针调用还是走基类版本因为你压根没建立多态关系。基类函数是虚的派生类写了签名一致的函数那才是重写才有多态。顺便说下签名一致的具体含义函数名、参数类型列表忽略顶层 const引用和指针的底层 const 要匹配、const 限定、引用限定符都要匹配。返回类型在 C 里允许协变比如基类返回Base*派生类返回Derived*但只对指针和引用有效。为了防手滑C11 引入了两个说明符请务必用起来class Derived : public Base { public: void process(int x) override; // 明确表示我要重写签名不匹配会报错 void final_func() final; // 禁止更深层的类重写它 };override的价值在于它会在签名不匹配时报编译错误。我改过一次基类虚函数的参数类型把int改成long结果派生类里的重写函数没跟着改编译一路通过运行时行为全乱——因为那根本不是重写而是隐藏。如果当初每个重写都标了override改基类那一瞬间就会全线飘红。这是省下最多次调试时间的一个关键字没有之一。5. 虚函数与多态继承体系里最关键的一次分叉讲了这么多继承的机制真正让继承值钱的是虚函数带来的运行时多态。没有多态继承本质上就是个代码复用工具有了多态继承才能让用基类指针统一操作一堆派生类对象成为现实。用一句话概括多态调用哪个函数不取决于指针/引用的静态类型而取决于它实际指向的对象的动态类型。class Shape { public: virtual double area() const { return 0.0; } virtual ~Shape() {} }; class Circle : public Shape { public: explicit Circle(double r) : m_r(r) {} double area() const override { return 3.14159 * m_r * m_r; } private: double m_r; }; class Rect : public Shape { public: Rect(double w, double h) : m_w(w), m_h(h) {} double area() const override { return m_w * m_h; } private: double m_w, m_h; }; std::vectorstd::unique_ptrShape shapes; shapes.push_back(std::make_uniqueCircle(2.0)); shapes.push_back(std::make_uniqueRect(3.0, 4.0)); double total 0; for (const auto s : shapes) total s-area(); // 各自调各自的 area这段代码是继承加多态的标准用法s-area()在编译期根本不知道要调哪个版本得等到运行期根据对象的实际类型来定。那多态在底层是怎么实现的简化说含有虚函数的类其对象里会多一个隐藏的指针通常叫 vptr指向一张函数指针表vtable。vtable 里按固定顺序存放着这个类所有虚函数的实际地址。调用s-area()时编译器生成的是取 vptr → 在表里偏移到area的位置 → 通过函数指针调用这样的序列。派生类通过改写自己 vtable 里的条目就实现了同一位置放不同函数地址的效果。理解这一点能解释好几个现象虚函数调用比普通函数慢因为有两次间接寻址而且会阻碍内联。但别因噎废食绝大多数业务代码里这点开销可以忽略。热路径上你才需要关注。带虚函数的对象会变大每个对象多一个指针64 位下 8 字节。对象的布局依赖编译器实现语言标准没有规定 vptr 放在对象头部还是尾部所以别写依赖布局的 hack。接着讲两个必踩的坑。第一个是虚析构函数缺失Shape* s new Circle(1.0); delete s; // 如果 ~Shape() 不是虚的这里只调 ~ShapeCircle 部分不析构如果~Shape()不是 virtualdelete s就是未定义行为通常只释放了基类那部分内存派生类的资源如果有堆分配就泄漏了。只要类有虚函数就顺手把析构函数也写成 virtual养成肌肉记忆。反过来如果类不打算被多态删除就别加虚析构免得无谓地增加对象体积——不过在实战里一个已经建立起来的类层次我会倾向统一加虚析构安全第一。第二个坑是纯虚函数和抽象类。当基类里某个函数实在没有合理的默认实现时可以声明为纯虚class Shape { public: virtual double area() const 0; // 纯虚函数 virtual ~Shape() default; };带纯虚函数的类叫抽象类不能被实例化只能作为接口。派生类必须实现所有纯虚函数否则它自己也是抽象的。关于纯虚函数有两个细节值得记住纯虚函数可以有函数体。你完全可以写virtual void f() 0;然后在类外实现void Base::f() { ... }派生类需要显式调用Base::f()。这在想提供默认实现但强制派生类显式表态时有用。纯虚析构函数需要提供定义。因为派生类析构时总会链式调用基类析构所以virtual ~Base() 0;这种写法必须在类外补一句Base::~Base() {}不然链接会报错。最后提一句override和final的组合拳。final用来禁止进一步重写或者禁止继承某个类class Base final { }; // 这个类不能被继承 class Derived : public Base { void f() override final; // 这个函数不能再被更深层重写 };final的一个实际收益是给编译器优化空间——确定了不会有更深的派生类覆盖后某些调用可能被去虚化devirtualization在性能敏感的代码里值得考虑。6. 多重继承与菱形继承虚继承背后要付出的代价单个类的继承链条清晰一进入多重继承就复杂了。先看基本写法class Swimmer { public: void swim(); }; class Flyer { public: void fly(); }; class Duck : public Swimmer, public Flyer { }; // 同时继承两个基类Duck对象里会依次包含Swimmer子对象和Flyer子对象。调用d.swim()和d.fly()都没问题。但一旦两个基类里有同名的成员就会产生名字冲突必须显式限定class A { public: void f(); }; class B { public: void f(); }; class C : public A, public B { }; C c; c.f(); // 编译错误A::f 和 B::f 有歧义 c.A::f(); // 显式指定OK真正让多重继承声名狼藉的是菱形继承问题class Animal { public: int age; }; class Mammal : public Animal { }; class Bird : public Animal { }; class Bat : public Mammal, public Bird { }; // 蝙蝠既是哺乳动物又会飞Bat对象里会有两份Animal子对象因为Mammal继承了一次Bird又继承了一次两份是独立的。于是访问bat.age直接歧义必须写bat.Mammal::age或bat.Bird::age。内存浪费且两份age数据可能不一致。解决办法是虚继承class Mammal : virtual public Animal { }; class Bird : virtual public Animal { }; class Bat : public Mammal, public Bird { }; // 现在只有一份 Animal 子对象加上virtual后Mammal和Bird都共享同一个Animal子对象Bat里只有一份歧义消失。听上去很美好但虚继承的代价不小必须心里有数对象体积增加。虚继承的类通常需要额外的虚基类指针用来在运行期定位共享基类子对象的位置。访问变慢。访问虚基类成员要多一次间接寻址。构造更复杂。最底层的派生类要负责构造虚基类中间类的初始化被跳过。也就是说Bat的构造函数必须直接初始化Animal即使Animal不是它的直接基类。class Bat : public Mammal, public Bird { public: Bat() : Animal(/* 直接初始化虚基类 */), Mammal(), Bird() {} };这个规则很反直觉我见过不少人在中间层写Mammal::Mammal() : Animal(5) {}结果发现Bat构造时Animal(5)压根没起作用因为最派生类会成为虚基类的实际初始化者。这类错误往往只在运行期表现调试起来很费劲。我的实战建议很直接多重继承能不用就不用。如果只是想让一个类拥有多个能力用组合或者接口类全是纯虚函数的抽象类相当于 Java 的 interface来实现清晰得多。接口类的多重继承通常没什么问题因为接口里没有数据成员不会产生菱形数据冲突。真正需要上虚继承的场景我这些年碰到的基本就是标准库 iostream 那种类层次——istream和ostream都虚继承自ios_base一起派生出iostream避免了两份流状态。除此之外业务代码里我基本没见过非用不可的正当理由。7. 我在实际项目里踩过的继承设计坑前面讲的都是机制这一节讲我在真实代码里摔过跤的地方都是些文档里不会专门强调、但会让你熬夜的经验。第一坑对象切片object slicing。void process(Shape shape) { // 注意这里是值传递 shape.area(); } Circle c(2.0); process(c); // 传值c 里的 Circle 部分被切掉只留下 Shape 部分值传递基类对象时C 只会拷贝基类那部分数据派生类特有的成员被丢弃虚表指针也被换成基类的。这意味着process里调用shape.area()走的是Shape::area多态彻底失效。结论很硬只要涉及多态函数参数一律用引用或指针不要用值。可以这么记想要多态行为就用Shape或Shape*一旦用Shape值多态当场失效。如果你确实想做值语义的多态副本标准做法是在基类里加一个clone()虚函数返回一个堆上的副本智能指针。第二坑为了复用代码而滥用继承。我维护过一个订单系统有人让RefundOrder继承了NormalOrder因为退款单也是订单字段大部分一样。结果NormalOrder里有十几个业务流程函数退款单只用到其中两个剩下八个继承进来后语义上根本不对——调用RefundOrder.confirm()会发生什么没人知道。改NormalOrder的任何一个函数都得考虑会不会波及RefundOrder两个类死死绑在一起。这就是典型的**实现复用伪装成接口继承**。判断方法如果你能列出派生类对象永远不能执行的基类函数那这个继承关系多半是错的应该改用组合——把公共数据抽成一个结构体成员。第三坑基类析构漏加 virtual。这个前面提过但我要再强调一次因为它造成的 bug 极其隐蔽程序不崩只是资源悄悄泄漏跑上几天内存才涨起来排查时你很难第一时间怀疑到析构上。养成一个习惯写基类的时候只要加了一个 virtual 函数就顺手把析构函数也标成 virtual或者 protected 非虚如果确定不会被多态删除。第四坑在构造函数里调用虚函数。前面第 3 节详细说过这里只补一句实战场景。很多人喜欢在基类构造函数里调用一个init()虚函数想让派生类去定制初始化逻辑。这个设计从根上就是错的因为在基类构造期间对象还不是派生类类型init()只会调到基类版本。正确的做法是把初始化逻辑放到独立的init()里由对象创建者在构造完成之后显式调用或者用工厂模式。第五坑忘记using引入基类重载。这个在第 4 节讲过实战里的典型症状是你在派生类里重载了log(const std::string)结果发现原先能用的log(int)突然编译不过了报参数不匹配。解决办法就是在派生类里加一句using Base::log;。把这几坑总结成一张排查表出问题时对着看症状最可能的原因处理方式派生类指针 delete 后资源泄漏基类析构不是虚函数基类加virtual ~Base()多态调用走到了基类实现参数用了值传递 / 函数没标 virtual改引用或指针 / 补 virtual override基类重载函数突然调不到派生类同名函数隐藏了基类版本加using Base::func;构造/析构期间虚函数行为异常构造析构期对象类型固定为当前层把虚调用移出构造/析构多重继承成员访问歧义菱形继承产生多份基类子对象用虚继承或改组合/接口类写继承相关的代码我个人的习惯是每加一个派生关系就先问自己三个问题这是 is-a 还是只是复用代码基类析构该不该虚这个派生类会不会被当成基类指针使用把这三问答清楚后面八成不会出大问题。剩下那两成多半是多重继承和虚继承挖的坑能避就避。实际动手写的时候还有个小技巧派生类重写虚函数时返回值、const 限定、参数列表最好直接从基类声明里复制粘贴过来再手动核对一遍别凭记忆敲。签名差一点多态就没了而且这种 bug 编译期一点提示都不给你。加上override之后编译器就成了你的第二双眼睛。