ARTICLE DETAIL

资讯详情

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

C++继承与派生详解:构造析构、虚函数、多态与虚继承

C++继承与派生详解:构造析构、虚函数、多态与虚继承 1. 为什么类的继承与派生是绕不过去的一关写 C 写久了你会发现真正卡住初学者的从来不是指针也不是数组而是当代码规模涨起来之后怎么把一堆相似的类组织得既不出错又能复用。C 类的继承与派生就是解决这个问题的核心机制它让一个类可以基于另一个已经写好的类去扩展能力把公共的属性与行为沉淀到父类把差异化的部分留给子类。说白了继承就是让类与类之间形成一种儿子继承老子家产、同时还能自己添置新东西的关系。这一章要讲清楚的就是继承怎么写、三种继承方式有什么区别、构造析构顺序为什么这么关键、虚函数和多态是怎么串起来的、以及多重继承带来的菱形问题该怎么处理。不管你是刚学完类和对象的新手还是写了几年代码但一直对基类析构函数为什么要加 virtual似懂非懂的老手把这些点吃透都会让你的代码质量上一个台阶。我自己在带新人的时候发现九成的继承 bug 都集中在构造析构顺序和虚函数这两块所以这篇文章会花大力气在这两个地方。先给没接触过的读者补个背景。所谓继承inheritance指的是让一个新类去获取另一个已有类的成员被继承的类叫基类或父类继承产生的新类叫派生类或子类。而**派生derivation**强调的是从基类衍生出新类的这个动作过程两个词其实说的是一件事的两个视角。C 用冒号加访问限定符的语法来表达这层关系比如class Dog : public Animal就表示 Dog 公开继承自 Animal。这个设计背后有一个朴素的动机现实世界里本来就有大量共性加个性的关系狗、猫、鸟都是动物都吃饭都睡觉但叫声各不相同。如果每个类都从头写一遍吃饭睡觉代码会膨胀得没法维护有了继承公共逻辑放 Animal个性的叫声放各自的子类改一处就能影响全局。这背后其实是is-a是一个的语义Dog is an Animal所以 Dog 继承 Animal 是成立的。反过来如果只是想用一下别人的功能那多半该用组合而不是继承这个判断标准后面会专门展开因为它直接决定了你的类设计是干净还是拧巴。为了让后面的讨论有具体的载体先摆一套贯穿全文的示例骨架后面所有细节都围绕它来改。这套代码我建议你直接敲一遍编译跑起来看现象比看文字印象深得多#include iostream #include string class Animal { public: Animal(const std::string n) : name(n) { std::cout Animal 构造: name \n; } virtual ~Animal() { std::cout Animal 析构: name \n; } void eat() const { std::cout name 在吃东西\n; } virtual void speak() const { std::cout name 发出声音\n; } protected: std::string name; }; class Dog : public Animal { public: Dog(const std::string n) : Animal(n) { std::cout Dog 构造: n \n; } ~Dog() override { std::cout Dog 析构: name \n; } void speak() const override { std::cout name 汪汪\n; } };上面这段代码同时包含了继承、虚函数、构造析构和访问控制是理解本章的最小完整样本。你把它编译运行会看到构造顺序是 Animal 先、Dog 后析构顺序则完全反过来。为什么会这样这不是编译器随便定的背后有非常硬的道理第 3 节会讲透。1.1 从复制粘贴代码到抽公共父类的思维转变很多人第一次写面向对象程序时脑子里是平铺的——需要什么功能就写什么类类与类之间井水不犯河水。这种写法在类数量少的时候没问题但一旦出现三五个类都有相似字段和相似方法问题就来了改一个公共逻辑你得在五六个地方同步修改漏掉一处就是 bug。继承的第一价值就是消灭这种重复。我自己维护过一个老项目早期作者没有抽象出基类导致十几个业务类里各自复制了一份几乎相同的校验参数逻辑后来业务规则一变改得我头皮发麻最后花了两天时间才把它们统一抽成一个基类。从那以后我形成了习惯只要发现两个以上的类有超过三行的重复逻辑就停下来想想能不能抽公共父类。不过抽公共父类这件事本身也有讲究抽得太早、抽得太粗反而会让继承层次变得又深又绕。我个人的经验是先让相似的类各自跑通等它们稳定下来、重复逻辑确实固定了再回头做一次提取父类的重构。这比一开始就凭想象设计一套继承树要稳得多因为你对业务的理解会随着编码不断加深过早抽象出来的父类往往和最终需求对不上。这个思路其实就是大家常说的三次法则——同一个逻辑重复到第三次才值得抽象。1.2 继承到底解决了什么问题把继承带来的好处拆开看主要有三块。第一块是代码复用公共成员只写一遍子类直接拿来用第二块是接口统一所有子类都暴露基类的接口调用方可以只认基类不认具体类型这正是多态的基础第三块是可扩展性新增一种类型时只需要写一个新子类已有的调用代码一行都不用动。第三点尤其重要它是对扩展开放、对修改关闭这条设计原则能落地的前提。举个例子如果你的绘图程序里所有图形都继承自 Shape那么新增一种三角形只要写一个 Triangle : public Shape绘图循环里那句for (auto s : shapes) s.draw();就自动支持了三角形完全无需改动。但我要泼一盆冷水继承不是万能的滥用继承是 C 里最经典的坏味道之一。很多人一看到两个类有相似之处就想用继承结果继承层次越来越深最后自己都理不清谁继承谁。判断该不该继承请看下一节。1.3 is-a 与 has-a什么时候才该用继承这是继承设计里最该刻进脑子的一条准则如果两个类之间是是一个is-a的关系用继承如果是有一个has-a的关系用组合。狗是动物所以 Dog 继承 Animal但汽车有引擎所以 Car 里应该放一个 Engine 成员而不是让 Car 继承 Engine。我见过一个典型的反面案例有人让Stack继承std::vector理由是栈本来就是用数组实现的。这在实现上能跑但语义上就错了——栈不是一个向量栈是用向量。更糟的是继承 vector 会让栈暴露出 vector 的全部接口用户可以直接按下标随机访问栈中间的元素把后进先出的约束彻底破坏掉。正确的做法是让 Stack 里包含一个 vector 作为成员只暴露 push 和 pop。判断方法很简单造个句子试试。能说派生类是一个基类就对了说派生类有一个基类或者读起来别扭就说明该用组合。这条准则能帮你避开绝大多数继承误用的坑值得反复默念。2. 三种继承方式public、protected、private 的门道继承的语法里那个冒号后面跟的public、protected、private很多人囫囵吞枣地直接public了事从没深究过另外两个是干嘛的。其实这三个关键字决定了基类成员在派生类中的可见性如何变化理解它需要先搞清楚成员本身的三档访问级别public 谁都能访问protected 只有自己和子类能访问private 只有自己内部能访问。继承方式就像一层滤镜它会进一步压缩基类成员在派生类里的可见范围。下面这张表建议你记牢它是本章少数几个必须硬背的点之一基类中的访问级别public 继承后protected 继承后private 继承后publicpublicprotectedprivateprotectedprotectedprotectedprivateprivate不可访问不可访问不可访问注意最后一行不管用哪种继承方式基类的 private 成员在派生类中都是不可直接访问的。这一点让不少人困惑——我自己也踩过。基类的私有成员确实会被派生类对象包含进去它在内存里实实在在存在只是编译器不允许你在派生类代码里直接用名字去碰它。如果你需要子类访问某个成员同时又不想让外部访问那就该把它设成 protected而不是 private。这个取舍在写框架时经常遇到后面第 5 节还会回到这个话题。2.1 继承方式对成员访问权限的影响光看表格还不够直观我拿一段可编译的代码帮你把三档差异跑出来。下面这个例子里基类 Base 有 public、protected、private 三种成员派生类分别用三种方式继承你在 main 里试着访问它们的成员编译器会精确地告诉你哪个能过、哪个不能过class Base { public: int pub 1; protected: int pro 2; private: int pri 3; }; class D1 : public Base { public: void test() { pub 10; // OK仍是 public pro 20; // OKprotected 在子类内可用 // pri 30; // 编译错误基类 private 不可访问 } }; class D2 : protected Base { public: void test() { pub 10; // OK pro 20; // OK } }; // 外部代码 D2 d; d.pub; 会报错因为 pub 已被降级为 protected class D3 : private Base { public: void test() { pub 10; // OK子类内部仍可用 pro 20; // OK } }; // 外部代码 D3 d; d.pub; 同样报错被降级为 private实测下来你会发现一个规律继承方式只会让权限变严绝不会让权限变松。public 继承原样保留protected 把 public 压成 protectedprivate 把 public 和 protected 都压成 private。基类的 private 始终第六亲不认任何继承方式都救不了它。2.2 为什么绝大多数场景只该用 public 继承那为什么我们平时几乎只用 public 继承关键在于public 继承表达的是is-a的语义而 protected 和 private 继承表达的其实是用基类的实现来实现自己语义更接近组合。用 public 继承时派生类对象可以被当作基类对象来使用这才能支撑多态——你可以把 Dog 对象传给一个接收 Animal 引用的函数。而用 private 继承时外部根本无法把派生类当成基类用那继承的复用价值就只剩下借用基类的实现这时候其实用组合往往更清晰。C 社区有一条流传很广的忠告叫优先用组合慎用 private 继承这句话出自经典著作我在实际项目里验证下来也确实如此。private 继承最大的问题是耦合太紧——派生类和基类的实现细节绑死基类一改派生类就可能跟着崩。而组合只依赖对方暴露的接口基类内部怎么重写都不影响你。所以除非你确实需要访问基类的 protected 成员或者需要重写基类的虚函数但又不希望外部把你当基类用否则就用组合。我在生产代码里见过 private 继承的场合屈指可数基本都是围绕着需要定制虚函数行为但拒绝 is-a 语义这种偏底层的目的。2.3 一个容易踩的坑继承方式写错导致接口消失这里有个我亲眼见过的真实事故。有个同事写了个工具类习惯性地class A : Base忘了写 public。C 里 class 默认是 private 继承struct 默认才是 public 继承。结果这个类的所有继承来的接口在外面全部消失了编译报了一堆无法访问的错误他排查了半天才发现是漏了 public 三个字。这个坑非常隐蔽因为错误信息指向的是调用处而不是继承声明处很容易让人往错的方向查。提示写 class 继承时养成习惯冒号后面第一个词永远显式写上 public别依赖默认值。struct 默认 public 继承这个特性看着省事但混用让代码可读性变差我个人一律显式声明。另外还有一个相关的坑单继承漏写 public 编译报错还好如果是多重继承里漏写错误信息会更绕。所以记住一句话继承声明的访问限定符能不省就不省。3. 派生类对象的构造与析构次序这一节是整章的重中之重我自己带过的每个新人都在这块翻过车。先记住两条铁律构造时基类先构造派生类后构造成员变量按声明顺序构造析构时顺序完全反过来派生类先析构基类后析构。这个顺序不是编译器随意定的而是被 C 的对象模型逼出来的理解了原因你就不用死记了。道理其实很朴素派生类的方法可能会用到基类的成员所以在派生类开始初始化之前基类那部分必须先做好准备否则派生类构造时访问一个还没初始化的基类成员就出事了。反过来说析构的时候派生类的方法可能还在访问基类成员如果基类先析构派生类的析构函数再去碰基类成员就会访问到已经销毁的对象那是未定义行为。所以顺序只能这么定一个从底往上搭一个从上往下拆。3.1 构造顺序基类到派生类成员按声明顺序我把第 1 节那段示例跑一遍你能清楚看到输出的次序int main() { Dog d(旺财); d.speak(); return 0; } // 输出 // Animal 构造: 旺财 // Dog 构造: 旺财 // 旺财 汪汪 // Dog 析构: 旺财 // Animal 析构: 旺财这里的要点有三条值得逐条说。第一基类的构造函数在派生类构造函数体执行之前就会被调用即使你什么都不写编译器也会自动插入对基类默认构造函数的调用。这也是为什么基类如果只提供了带参数的构造函数、而没有默认构造函数时派生类必须在初始化列表里显式调用它否则编译不过。第二如果同时有多个基类和多个成员对象构造顺序是先基类按继承声明的顺序再成员按在类里声明的顺序最后才是自己的构造函数体注意成员对象的构造顺序只跟声明顺序有关跟你在初始化列表里写的先后无关这是一个非常容易搞错的细节。第三初始化列表的书写顺序最好和实际构造顺序保持一致虽然编译器不报错但如果成员之间有依赖比如 b 用 a 的值初始化顺序写反了就会拿到未初始化的值。3.2 析构顺序完全反过来析构顺序上同一个对象里析构函数的调用顺序和构造严格相反先是派生类的析构函数体执行再析构成员对象同样逆着声明顺序最后调用基类析构函数。这个后进先出的规律很好记——像叠盘子搭上去是一层层往上拆下来是一层层往下。我在排查内存问题时特别喜欢利用这个特性在构造和析构里各打一条日志就能立刻看出谁先谁后比翻文档快得多。但这里有个巨大的陷阱就是当对象是动态分配、通过基类指针删除时。看下面这段代码Animal* p new Dog(小黑); delete p; // 危险如果基类的析构函数没有声明为virtual那么delete p只会调用 Animal 的析构函数Dog 的析构函数根本不会被调用如果 Dog 在构造函数里申请了资源比如 new 了一块内存、打开了文件这块资源就永远泄漏了。这是 C 面试里出现频率最高的题之一也是我实际项目中真真切切踩过的坑——当年写一个图形库基类析构忘了加 virtual跑压力测试时内存一路涨最后用工具一查才发现是子类的资源没释放。所以记住只要一个类可能作为基类被使用它的析构函数就应该声明为 virtual没有例外。3.3 初始化列表里调用基类构造函数派生类构造函数可以通过初始化列表把参数传给基类构造函数这是给基类成员赋值的唯一正确姿势。为什么要强调唯一正确因为有人会想在派生类构造函数的函数体里直接给基类成员赋值比如name n;但这有两个问题一是如果 name 是基类的 protected 成员还好如果是 private 就根本编译不过二是即便能编译那也是先默认构造、再赋值多了一次无谓的操作而初始化列表是直接构造效率更高。下面是对比// 推荐初始化列表直接构造 Dog(const std::string n) : Animal(n) { /* ... */ } // 不推荐先默认构造再赋值基类若无默认构造还会编译失败 Dog(const std::string n) { // name n; // 若 name 是 private 则此处报错 }关于初始化列表还有一个经典坑和它相关值得顺手提一句成员变量的初始化顺序由声明顺序决定和初始化列表的书写顺序无关。我见过有人写出A(int x) : b(x), a(b) {}这样的代码本意是先初始化 b 再用 b 初始化 a但因为 a 声明在 b 前面实际是先初始化 a此时 b 还没初始化a 就拿到了一个垃圾值。这类 bug 在编译器开启-Wreorder警告时会被提示出来强烈建议你把编译告警级别调高很多这类问题能自动被拦截。3.4 一个实战案例资源在手顺序错了就泄漏我把上面几个知识点串成一个能实际跑的 RAII 案例。基类持有一个需要手动释放的资源派生类又额外持有一个通过基类指针删除时正确的虚析构才能保证两个资源都被释放#include iostream class Base { public: Base() { std::cout Base 申请资源\n; } virtual ~Base() { std::cout Base 释放资源\n; } // 关键virtual }; class Derived : public Base { public: Derived() { std::cout Derived 申请资源\n; } ~Derived() override { std::cout Derived 释放资源\n; } }; int main() { Base* p new Derived(); delete p; // 正确输出 // Base 申请资源 // Derived 申请资源 // Derived 释放资源 // Base 释放资源 }你把virtual去掉再跑一次会看到 Derived 的析构日志消失了——资源就这么漏了。这个实验我强烈建议你亲手做一遍比看十遍文字都管用。至于为什么加了个 virtual 就能认对类型答案在虚函数表里见第 5 节。4. 名字遮蔽、作用域与 using 声明继承里还有一类 bug 不报错、不崩溃但行为完全出乎你意料那就是名字遮蔽name hiding。它的规则是派生类中只要定义了一个和基类同名的成员不管是变量还是函数基类里所有同名的成员都会在派生类作用域里被藏起来哪怕它们的参数列表完全不同。注意这和多态里的重写override不是一回事重写针对的是虚函数遮蔽则是纯粹的名字查找问题。4.1 派生类同名成员会遮蔽基类成员看个例子你就明白了。基类有个show()派生类也写了个show(int)两个函数参数不同看起来像是重载但实际调用时class Base { public: void show() { std::cout Base::show\n; } void show(int x) { std::cout Base::show(int)\n; } }; class Derived : public Base { public: void show(int x) { std::cout Derived::show(int)\n; } }; int main() { Derived d; d.show(5); // 调用 Derived::show(int) // d.show(); // 编译错误Base 里那个无参 show 被藏起来了 }d.show()为什么报错因为编译器在 Derived 作用域里找到了一个叫 show 的成员就停止向上查找基类作用域了。它找到了show(int)发现参数对不上于是直接报没有匹配的函数而根本不会去看基类还有个无参的 show。这就是遮蔽最坑的地方它把整个名字都挡了不区分参数。4.2 using 声明恢复基类重载函数集解决办法是使用using声明把基类的名字引入到派生类作用域让两组重载重新共存class Derived : public Base { public: using Base::show; // 把 Base::show 引入重载集合并 void show(int x) { std::cout Derived::show(int)\n; } }; // 现在 d.show(); 调用 Base::show // d.show(5); 调用 Derived::show(int)这个技巧在实现继承体系时非常实用尤其是当你想在子类里扩展父类的同名重载函数时加一句 using 就能避免整组被遮蔽。我在写工具库时经常用到可以说是 C 继承里最容易被忽略却最能提升代码质量的细节之一。提示遮蔽不限于函数派生类的同名成员变量同样会遮住基类变量。如果你在派生类里定义了和基类同名的成员访问时默认拿到的是派生类的那个要访问基类的需要写Base::member。4.3 虚函数与重写override 关键字和遮蔽容易混淆的是重写override。它的前提是基类函数是虚函数virtual派生类定义了**同名、同参数、同返回类型协变返回除外**的函数这时才构成重写运行期才会走多态。为了把重写和不小心写了个同名函数区分开C11 引入了override关键字class Base { public: virtual void speak() const { std::cout Base\n; } }; class Derived : public Base { public: void speak() const override { std::cout Derived\n; } // 显式声明重写 };加上 override 之后如果你不小心把签名写错了比如漏了 const或者参数类型不一致编译器会直接报错而不是默默地当成一个新函数。这个关键字几乎是零成本的保险我强烈建议所有重写都加上。有个真实的例子我曾经把基类的virtual void draw() const写成派生类的void draw()漏了 const结果多态没生效排查了好一阵才发现。加上 override 后编译器一眼就告诉了我问题在哪。5. 多态的内核虚函数表与对象内存布局到这一节我们该掀开引擎盖了。为什么通过基类指针调用虚函数能认对实际类型为什么基类析构函数加了 virtual 才能正确析构子类这些问题的答案都指向一个东西虚函数表vtable和虚指针vptr。注意虚函数表是主流编译器的实现方式C 标准并没有强制规定但了解它能帮你建立正确的心理模型。5.1 虚函数表、虚指针是怎么回事当一个类里含有虚函数时编译器会为这个类生成一张虚函数表表里按顺序存放该类各个虚函数的地址。同时该类的每个对象在内存里会多出一个隐藏的指针叫虚指针 vptr它指向所属类的虚函数表。调用p-speak()时实际过程是先通过对象里的 vptr 找到虚函数表再按 speak 在表里的固定偏移取出函数地址然后跳转执行。因为取的是对象实际所属类的表所以即便是基类指针也能调到派生类的实现这就是多态的底层机制。用文字描述内存布局一个含虚函数的对象大概是这样的对象最前面是 vptr8 字节64 位平台后面跟着自己的数据成员。派生类对象则先放基类子对象的全部内容含基类的 vptr再放派生类自己的成员。如果派生类重写了虚函数它自己的虚函数表里对应的槽位就被替换成了派生类版本的地址。这里要注意一个细节单继承且只有一个基类有虚函数时派生类和基类通常共用同一个 vptr不会有第二个这也是为什么单继承下多态开销很小。5.2 为什么基类析构函数必须 virtual现在可以解释那个经典问题了。delete p的时候如果析构函数是虚函数编译器会通过 vptr 找到实际类型的析构函数来调用这样子类的析构就能被执行如果析构函数不是虚函数那就按指针的静态类型Base去调子类的析构被跳过资源泄漏。所以基类析构加 virtual不是可选项而是硬性要求。我这儿有个经验数据在开-Wall的情况下很多编译器对基类有虚函数但析构不是虚函数会给警告你要是看到这类警告千万别忽略它往往指向的是真实的资源泄漏隐患。注意虚析构函数会阻止编译器生成默认的移动操作拷贝/移动在大对象频繁返回的场景下会带来额外开销。所以一条实用建议是如果一个类不打算被继承就把它标记为 final如果不打算多态删除就不要写虚析构。设计时想清楚这个类会不会被继承能让性能问题提前避开。5.3 纯虚函数与抽象类只声明、不实现的虚函数叫纯虚函数写法是virtual void draw() 0;。一个类只要含有一个纯虚函数它就是抽象类不能被实例化只能作为基类被继承。派生类必须实现所有纯虚函数否则它也是抽象类。抽象类很适合用来定义接口——比如Shape抽象类规定所有图形都必须实现 draw 和 area但具体怎么画、怎么算面积交给子类。这就是所谓面向接口编程。这里有个初学者必踩的坑抽象类的纯虚析构函数必须有定义。因为派生类析构时会调用基类析构而纯虚函数一般只声明没实现于是链接期就报未定义符号。正确写法是class Shape { public: virtual ~Shape() 0; // 声明为纯虚 }; Shape::~Shape() {} // 但必须提供定义我当年就被这个坑卡了半个下午报错信息是undefined reference to Shape::~Shape()看着像语法错误其实是链接问题。记住纯虚析构函数是个例外它必须有函数体。5.4 override 与 final 的组合拳把override和final结合起来用能让你的继承体系既安全又清晰。override 保证你确实重写对了final 则明确告诉编译器和其他人到此为止别再继承了。我常用的一个套路是在析构函数上写virtual ~Base() default;在那些明确不再被继承的类上写class FinalClass final {};。这样一来编译器在有人试图继承它时会直接报错比靠注释提醒可靠得多。让类型系统帮你把设计意图表达出来这是我写 C 这些年最深刻的体会之一——很多约定其实都能翻译成编译器能检查的语法。6. 多重继承与菱形继承虚基类的必要性单继承讲完该轮到多重继承了。C 允许一个类同时继承多个基类比如class AmphibiousVehicle : public Car, public Boat。多重继承的强大之处在于能同时获得多个基类的能力但正所谓能力越大坑越大它带来两类经典问题名字二义性和菱形继承的数据冗余。6.1 多重继承的成员二义性如果两个基类有同名成员派生类访问它就会产生二义性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(); // 必须显式指定 c.B::f();编译器不会替你猜该用哪个必须用A::f()这种限定符明确指定。这个规则简单但真正麻烦的是当这种二义性隐藏在深层继承里时错误信息可能非常难读。6.2 菱形继承的内存冗余菱形继承是多继承里最著名的问题结构是这样类 D 同时继承 B 和 C而 B 和 C 又都继承自同一个基类 A。此时 D 的对象里会包含两份 A 子对象一份来自 B一份来自 C。这带来两个后果一是内存冗余A 的数据在 D 里存了两份二是访问歧义d.value到底指哪一份 A 的 value编译器会报错说对 A 的引用有歧义。class A { public: int value; }; class B : public A {}; class C : public A {}; class D : public B, public C {}; D d; // d.value 5; // 错误value 有歧义存在两份 d.B::value 5; // 只能这样指定 d.C::value 6; // 另一份独立存在实测 sizeof(D) 你会发现里面确实有两份 A 的内容。如果 A 有较多数据成员这个冗余就相当可观了。6.3 虚继承怎么解决解决办法是虚继承virtual inheritance让 B 和 C 用 virtual 方式继承 A这样 D 中就只保留一份共享的 A 子对象class B : virtual public A {}; class C : virtual public A {}; class D : public B, public C {}; D d; d.value 5; // OK现在只有一份 A没有歧义虚继承的本质是让最派生类负责初始化那个共享的虚基类。这也带来一个新规则虚基类的构造函数由最派生类直接调用中间的 B、C 对 A 的构造调用会被忽略。这个规则初看很反直觉但它是保证 A 只被构造一次的关键。举个具体例子如果 A、B、C 都有构造函数构造 D 时 A 只会被构造一次而且是由 D 直接调用的。提示虚继承会引入额外的间接层虚基类表指针对象的访问开销和内存布局都更复杂。除非确实需要共享同一份基类数据否则不要用虚继承。我见过一些同学一上来就用虚继承图省事防二义性结果引入了更难排查的性能和初始化问题。虚继承在实际项目中用得不算多最经典的场景就是 iostream 库里basic_ios被basic_istream和basic_ostream以虚继承的方式共享最终basic_iostream只有一份basic_ios。如果你以后要设计类似输入输出一体的类这个模式值得参考。7. 常见问题排查与实战避坑清单前面讲了原理这一节我专门整理实战中最容易遇到的坑和排查思路都是我这些年真正被绊过的地方。先上一张速查表把编译期和运行期的典型问题集中对照方便你以后遇到时快速定位现象常见原因排查方向编译报没有匹配的函数名字遮蔽基类同名函数被藏检查是否用了派生类同名函数考虑 using编译报无法访问 private 成员基类成员是 private 或继承方式降级改为 protected / 检查继承方式delete基类指针后子类析构不执行基类析构非 virtual给基类析构加 virtual链接报undefined reference纯虚析构函数没有函数体补上析构函数定义调用虚函数结果不对签名不匹配没构成重写加 override 让编译器检查对象大小比预期大存在多个 vptr 或虚基类用 sizeof 和打印地址分析布局7.1 编译报错速查表上面表格里最需要强调的还是名字遮蔽和纯虚析构未定义。名字遮蔽的问题在于它常常不报错只是行为变了——比如你明明想调基类的版本结果调了派生类的程序能跑但结果不对这种 bug 最耗时间。我的建议是任何在派生类里出现的、和基类同名的函数都先想想是不是会遮蔽需要的话加 using。至于纯虚析构记住那条纯虚析构必须有定义的例外规则就够了。此外如果基类只有带参构造函数派生类构造函数的初始化列表必须显式调用它否则会报没有默认构造函数这个也经常遇到。7.2 运行期诡异现象定位运行期问题里最典型的就是子类析构没执行导致资源泄漏。定位这类问题有个非常实用的办法在构造和析构里各打一条带类名的日志然后跑一遍看构造和析构是否成对出现。如果构造有两条、析构只有一条那基本可以锁定是基类析构没加 virtual。另一个常见现象是虚函数没生效多态调用始终走基类实现这通常是签名不匹配导致没构成重写加上 override 就能让编译器帮你抓出来。我还有一个私藏技巧用sizeof和成员地址来验证内存布局。比如打印派生类对象里基类成员和派生类成员的地址看它们是不是连续排布能帮你直观理解对象模型。这算不上生产调试手段但对学习继承的内存布局特别有帮助。7.3 一些经验心得说几条不成体系但很实在的经验。第一继承层次别超过三层超过三层基本就需要重新审视设计很可能该用组合或策略模式了。第二基类要么设计成抽象接口有纯虚函数 虚析构要么就干脆不给虚函数最忌讳的是半吊子基类——有虚函数但析构没虚或者有数据成员又被人多态删除。第三多用 override 和 final让编译器替你把关能省掉大量调试时间。第四写继承代码时优先想这个类会不会被当基类用如果会析构就加 virtual接口就好好设计如果不会就标记 final减少心智负担。这几条是我在无数个 debug 的夜晚里总结出来的希望你能少走点弯路。8. 一个完整可复现的小案例最后用一个相对完整、能直接编译运行的小例子把本章知识点串起来。这个例子实现一个简单的图形面积计算涵盖抽象类、虚函数、虚析构、多态和继承#include iostream #include vector #include memory #include cmath class Shape { public: virtual ~Shape() default; // 虚析构必须 virtual double area() const 0; // 纯虚接口 virtual const char* name() const 0; }; class Circle : public Shape { public: explicit Circle(double r) : radius(r) {} double area() const override { return M_PI * radius * radius; } const char* name() const override { return 圆形; } private: double radius; }; class Rect : public Shape { public: Rect(double w, double h) : width(w), height(h) {} double area() const override { return width * height; } const char* name() const override { return 矩形; } private: double width, height; }; int main() { std::vectorstd::unique_ptrShape shapes; shapes.push_back(std::make_uniqueCircle(2.0)); shapes.push_back(std::make_uniqueRect(3.0, 4.0)); for (const auto s : shapes) { std::cout s-name() 面积: s-area() \n; } return 0; }这个例子里std::vector装的是unique_ptrShape每个元素的实际类型不同但通过基类接口就能统一处理这就是多态的价值。运行结果是圆形 面积: 12.5664和矩形 面积: 12。你可以在 for 循环里加一句日志或者断点观察每个对象的 vptr在调试器里能看到感受一下基类指针是怎么定位到正确的 area 实现的。这个案例我建议你改一改比如再加个三角形看看需要动哪些代码——你会发现只需要新增一个类main 里的循环一行都不用改这正是继承加多态最迷人的地方。最后分享我自己维护继承体系时的一个小习惯每个继承体系都配一个基类指针的 vector 或者工厂函数然后写一组单元测试覆盖通过基类指针删除子类对象这条路径。这条路径是最容易漏掉虚析构、最容易泄漏资源的地方把它测到了整套继承体系的安全性就八九不离十了。踩过几次内存泄漏的坑之后你就会明白继承用得对不对不看它能不能编译而看它析构得干不干净。
返回列表