很多人学 C++ 学到类之后,紧接着就会遇到继承。这东西刚接触时会觉得有点绕:明明可以把数据成员和函数都写在同一个类里,为什么一定要拆成基类和派生类?是不是为了炫技?我在带模拟项目X的开发时,也经常看到新同事把一堆乱七八糟的类写成一个大类,改一个地方崩一片。这节课就把继承基础拆开揉碎讲清楚,弄清楚它解决什么问题、怎么写、有哪些坑,你后面看多态、模板、设计模式都会顺很多。
适合正在学 C++ 语法、准备做小项目或者刷面试题的人。这篇博文不假设你已经很懂类的全部细节,只要你写过 class、用过构造函数和析构函数,就能跟得上。
1. 继承要解决的问题:别把代码复制三遍
继承的本质是“复用”加“扩展”。最典型的生活类比就是分类体系:动物是个大分类,狗是动物的子分类,狗一定拥有动物的共同特征,同时又额外有自己独特的行为。程序世界也一样,你有一批类,它们共享某些数据和行为,但又在细节上各不相同。如果没有继承,最原始的做法就是把公共部分复制粘贴到每个类里。今天加一个字段,明天就得改五个地方,漏改一处,编译也不报错,跑起来就诡异。
继承带来的第一个好处是纵向复用。把公共的字段和方法放到基类里,派生类自动获得它们,不用重新声明。第二个好处是表达的语义准确。代码里有明显的“is-a”关系时,继承能把这个关系直接建模进类型系统。比如 Cat 是 Animal 的一种,manager 是 employee 的一种,这种关系写成代码,别人读起来一眼就懂。第三个好处是为多态铺路。有了继承,才能用基类指针或引用统一操作各种派生类对象,这是后面 Day 10 讲虚函数的前提。
反过来也有一个容易踩的认知误区:只要有两个类长得像,就急着让它们继承。不是这样的。必须存在真实的“是一种”关系才能用继承。如果只是“用到了相同代码”但没有亲缘关系,正确做法是组合或者抽工具函数。判断方法很简单:对着派生类问自己一句话——“它本质上就是一个基类吗?”如果能理直气壮说“是”,才考虑继承;如果只能说出“它包含一部分基类的信息”,那大概率应该用组合。
class Animal { public: int age; void eat() { /* 实现在这里 */ } }; class Dog : public Animal { public: void bark() { /* 狗狗专属行为 */ } };上面这段是继承最朴素的样子。Dog 继承了 Animal 的 age 属性和 eat 方法,同时扩展出 bark。Dog 的实例可以调用 eat,因为这块代码是从 Animal 那里拿来的;也可以调用 bark,因为这是它自己的。简单说一句:写继承语法时,类名后面加冒号,加访问限定符,加基类名。访问限定符的作用下一节马上讲。
2. 访问控制符:public、protected、private 的定位完全不同
早期接触继承的人最容易混乱的就是继承方式。public、protected、private 作为继承方式使用时,规则说复杂不算复杂,但确实需要从原理上理解。
继承方式决定的是基类中的成员在派生类中的可访问性。记住一个简化结论:public 继承保持原有访问级别不变;protected 继承把基类的 public 成员降级为 protected;private 继承把基类所有成员都变成 private。这里的“降级”只影响派生类外面的访问方式,基类内部自己怎么访问不受影响,派生类内部能访问哪些成员也不完全变。
三种继承方式里,实际工程中用得最多的是 public 继承。C++ 里如果没有特殊设计动机,默认就应该写 public 继承。protected 继承偶尔出现在框架类库内部,private 继承多数是模拟“组合”的效果,日常业务代码很少出现。我们练习时期只需要深入理解 public 继承,另外两种知道含义即可。
基类内部的访问限定符是前置约束:public 成员所有地方都能用;protected 成员只能在自己类内部和派生类内部用,外部一律禁止;private 成员只能在自己类内部用,派生类也不行。这个设计非常好理解——private 是“连儿子都不给看的核心隐私”,protected 是“只给儿子们看的家底”,public 是对外完全开放的窗口。
class Base { public: int pub; protected: int prot; private: int pri; }; class Derived : public Base { void func() { // 能访问 pub 和 prot // 不能访问 pri } };这个例子说明一个很多人写代码时踩过的坑:以为派生类能访问基类所有成员。实际上派生类访问不到基类的 private 成员,这是语言层面的硬性约束。很多初学者在派生类里直接写 base.pri 或者直接试图使用基类的 private 字段,编译立刻报错。解决方式是基类把需要让子类访问的成员声明成 protected,或者提供对应的 protected/public 接口方法。
顺便说一个关于“权限和需求不匹配”的典型问题。假设基类有个字段 id,需求是派生类内部要读它,但外部任何代码都不能碰。那就把 id 放在 protected 里,派生类能读,外部不能读。但如果派生类之间、外部都想只读不能改,那得写个受保护的 getter 或者公有 getter 加上私有 setter。访问控制不是玄学,就是“谁能拿到钥匙”的规矩,想清楚数据流再定级别,比东挪一个 public 西挪一个 private 要稳得多。
3. 构造与析构的顺序:先父后子,先子后父
继承的第二个核心难点是对象生命周期。
派生类构造时,基类的那部分数据必须先完成构造。原因很直观:派生类的构造函数里可能会用到基类的数据成员,基类部分没有先成型,派生类等于站在没搭好的地基上干活。因此编译器会在进入派生类构造函数体之前,自动调用基类的默认构造函数。如果你希望基类使用带参数的构造函数,就得在派生类的初始化列表里显式写基类构造调用。
class Base { public: Base(int x) { /* ... */ } }; class Derived : public Base { public: Derived(int x, int y) : Base(x) { /* ... */ } };这个例子很关键。因为 Base 没有默认构造函数,Derived 不能在初始化列表里省略对 Base 的调用。如果漏写了 Base(x),编译器报错,提示没有匹配的构造函数。刚开始接触这行代码的人容易懵:明明我只是想在 Derived 的构造函数里给基类传个参数,为什么要专门写“: Base(x)”?原因就是前面说的——基类部分必须在派生类构造函数体之前构造完成,而你若不指定参数,编译器只能找默认构造,找不到就报错。
析构的顺序完全相反。派生类析构先执行,基类析构后执行。这个设计也合理:派生类可能定义了自己的资源,比基类更“接近外围”。销毁时要先处理最外围的壳,再拆内部的基座。如果把顺序反过来,基类先没了,派生类再析构时访问基类成员就会操作一块已经销毁的区间,程序就危险了。
class Base { public: ~Base() { std::cout << "Base destroyed\n"; } }; class Derived : public Base { public: ~Derived() { std::cout << "Derived destroyed\n"; } };运行结果永远是先打印 Derived destroyed,再打印 Base destroyed。这个顺序规律简单,但代码里一旦出现资源管理问题,排查时往往要先确认是不是析构顺序引发了悬垂引用或重复释放。还有一种常见错误是在基类析构函数里调用虚函数,结果在对象销毁过程中调用到的不是预期版本。这个问题等讲到虚函数时再展开,现在只需要记住构造和析构顺序是固定约定,凡是依赖这个约定的代码才有机会稳定。
另一个重要的坑是构造函数里不能调用虚函数,即使写了虚函数,此时动态绑定也还没生效。这种坑会很隐蔽,因为编译不报,运行结果又不是直觉预期。继承构造执行顺序的完整链条是:先基类构造,再成员对象构造,最后执行构造函数体;析构则颠倒过来。第一次听到这个顺序最好顺着思路写个小程序验证一下,比死记硬背可靠得多。
4. 成员函数隐藏:同名函数是个大坑
派生类一旦声明了一个和基类同名同参的函数,就会把基类的同名版本隐藏掉。这个隐藏不是重载,不是覆盖(后续讲多态时细说),就是“挡在你面前”。通过派生类对象或派生类作用域调用这个名称时,编译器只看到派生类版本,不再考虑基类版本。
场景很典型:
class Base { public: void print() { std::cout << "Base\n"; } }; class Derived : public Base { public: void print() { std::cout << "Derived\n"; } };调用 Derived d; d.print(); 只会输出 Derived。你想输出 Base,只能显式用 Base::print(),写成 d.Base::print()。这种隐藏容易造成误解,因为有时你写了一个明明表达“子类加强版”意义的同名函数,结果断点调试时调错了层,半天没发现。
大多数时候,如果派生类想重新实现基类的行为,语法上我们期待的是关键字 override。但这里有个前提:基类那个函数必须先声明为 virtual。一旦遇上 virtual,问题就复杂了。隐藏和覆盖的区别,可以用一句话概括:覆盖发生在虚函数和指针/引用调用时,运行时决定用哪个版本;隐藏发生在普通同名函数之间,编译期就按“谁在作用域内”决定。Day 10 会详细分析虚函数表和多态绑定,现在只需要先把“隐藏会掩盖基类函数”这个直觉建立起来。
真正坑人的是重载叠加隐藏。基类有两个同名重载版本,例如一个 print() 和一个 print(int)。如果派生类只重新实现了 print(),那么实际上 print(int) 也被隐藏了。这个行为不符合很多人的直觉——我只改了一个版本,怎么另一个版本也没了?解释是 C++ 的隐藏规则是针对同名函数族,不是单个函数。想保留基类版本,可以在派生类里加 using Base::print; 把基类那些被隐藏的函数拉回作用域。
class Base { public: void print() {} void print(int x) {} }; class Derived : public Base { public: using Base::print; void print() {} };这个例子里的 using 声明有实际意义:不加这行,外部代码 Institute d; d.print(5); 就会编译失败,因为基类的 print(int) 被隐藏了。加上之后,重载集合重新纳入基类全部同名函数。这个技巧在项目代码里出现过很多次,尤其是你在子类里重写某个函数但还想保留父类其他重载时,非常实用。
5. 动手写一个完整的继承 Demo:员工与管理者
前面几节都在讲规则,这一节咱们实实在在写一个能编译运行的完整程序。模拟一个简单的员工信息管理系统,基类 Employee 放公共字段和公共行为,派生类 Manager 在继承的基础上扩展出自己的属性。
#include <iostream> #include <string> class Employee { public: Employee(std::string name, int id, double salary) : name_(name), id_(id), salary_(salary) {} void showInfo() { std::cout << "Name: " << name_ << ", ID: " << id_ << ", Salary: " << salary_ << '\n'; } protected: double getSalary() const { return salary_; } private: std::string name_; int id_; double salary_; }; class Manager : public Employee { public: Manager(std::string name, int id, double salary, double bonus) : Employee(name, id, salary), bonus_(bonus) {} void showBonus() { // getSalary 是 protected,所以这里能读 std::cout << "Bonus: " << bonus_ << ", Total: " << getSalary() + bonus_ << '\n'; } private: double bonus_; }; int main() { Manager m("某开发者", 1001, 8000.0, 2000.0); m.showInfo(); // 来自基类 m.showBonus(); // 来自派生类 return 0; }代码很短,但该讲的都在里面。基类构造函数是带参数的,所以 Manager 的构造函数必须用初始化列表向 Employee 传参。这个动作单独拿出来看,其实就是两行代码之间的一次普通构造调用,但很多初学者写 Manager 时容易忘记,编译器提示也看不懂,直到明白“基类子对象必须在派生类对象构造时同步完成”才恍然。
代码里 showInfo 是公共行为,被 Manager 直接继承使用了。showBonus 是派生类专属行为,里面通过 protected 方法 getSalary 获取基类私有字段的数值。注意基类字段 name_、id_、salary_ 是 private,Manager 内部访问不到,反而通过 protected 接口可以间接读取。这个设计在真实项目里也常见:字段私有化,派生类由受保护的接口读取。
如果需求变成 Manager 要显示自己的完整信息,除了基本员工信息还要显示 bonus,就不能只是继承 showInfo 了。此时可以考虑在 Manager 里写一个新函数 showAllInfo,内部复用基类的 showInfo:
class Manager : public Employee { public: void showAllInfo() { showInfo(); std::cout << "Bonus: " << bonus_ << '\n'; } };复用基类的公共方法比直接复制字段访问代码要安全得多。因为基类的字段访问逻辑如果变化了,你只需改基类一个地方,派生类不用动。这就是继承复用提升维护性的一个直接体现。项目代码里最怕那种“每个子类都把公共逻辑重新抄一遍”的做法,早晚出问题。
6. 常见坑与排查思路:切片、菱形继承与二义性
讲完基本语法,说说实际操作里最常遇到的几个问题。经验老练的人也会间歇性踩中,知道排查思路能省很多时间。
对象切片是第一个高频坑。按值把派生类对象赋给基类变量时,派生类独有的部分会被切掉,只有基类部分被复制过去。比如 Manager 对象的 bonus_ 字段,在赋值给 Employee 变量时就丢了。这是因为编译器在按值赋值时调用基类的复制构造函数,它并不知道派生类的额外字段。
Manager m("某开发者", 1001, 8000.0, 2000.0); Employee e = m; // 切片 e.showInfo(); // 没问题,但 bonus 已经不在 e 里避免切片的核心原则是:多态相关的场景不要按值传递对象,统一用指针或引用。函数参数如果设计成按基类值接收,那实际传入派生类对象时也一定会发生切片。这个坑在写代码时不会报错,结果却可能和预期不符。排查时如果发现派生类特有属性莫名丢失,第一反应就是检查是不是发生了切片。
菱形继承是第二个经典问题。一个类从两个中间类继承,而这两个中间类共同继承同一个顶层基类,继承关系画出来就是菱形。直接后果是顶层基类的成员在最终的类中会出现两份,访问时产生二义性。解决一般用虚继承,关键字就是 virtual。虚继承让顶层基类在整个继承体系中只保留一份公共实例,后续访问就没有歧义。实际项目里真正的多继承场景本身就少,菱形继承更是罕见,面试题里出现得多,知道原因和应对方案就足够。
class Top { public: int x; }; class Left : virtual public Top {}; class Right : virtual public Top {}; class Bottom : public Left, public Right {};如果没有 virtual,Bottom 里会有两份 x,直接访问 x 报告二义性编译错误。加了 virtual 后,只有一份 x,Bottom 对象可以被正常构造和访问。理解虚继承的构造顺序时,最深的坑是:虚基类的构造函数最终是由最派生类直接调用,而不是中间类逐个传递。这个规则容易记混,遇到复杂继承构造问题,优先看最派生类的初始化列表。
二义性不止菱形继承才有。两个基类都定义了同名成员函数,派生类不重写时,通过派生类对象调用这个同名函数,编译器无法判断你到底想用哪一个,直接报错。排查方法就是显式用基类名限定:Left::func() 或者 Right::func()。如果是一个类里同时出现这种冲突,设计上就应该反思继承结构是不是太复杂,很多实际项目在重构时会果断放弃多继承,改用组合加接口的方式。
构造函数里调用虚函数也容易出问题。前面原则说过,对象构造期间虚调用不会绑定到派生类版本。派生类还没构造完毕时,运行时信息不完整,调用结果往往不符合预期。如果你确实需要在构造期间根据某种状态走不同逻辑,通常的做法是改成普通函数,或者将初始化参数通过构造函数传入,避免虚函数机制在半成品对象上运行。
7. 继承实战中的几条经验
写到这里,补充几条从实践中沉淀出来的建议,虽然听起来简单,但每条都能让你少走弯路。
第一,优先组合你的类关系。继承不是万能的。在“is-a”关系成立时才有必要,否则优先使用组合。组合的含义是类里面持有另一个类的对象,表达“has-a”关系。代码可维护性往往组合更好。实际开发模拟项目X时,最大的继承设计问题出现在过度抽象上:一开始觉得很多类都像同一类东西,于是不断往上提取基类,最后基类变得又大又抽象,子类各自带着无关方法。重构时我把整棵树拆掉,改成组合加接口,反而清晰很多。
第二,基类析构函数最好写成 virtual 的。如果你定义一个基类,并计划通过基类指针删除派生类对象,那么基类析构函数必须是 virtual。否则 delete 基类指针时,只会调用基类析构函数,派生类析构函数不执行,资源就泄漏了。这是一个标准经验,也是很多面试题最爱考的知识点。实际项目里,凡是作为多态基类使用的类,析构函数都会标记为 virtual。
第三,不要乱用 protected 字段。protected 字段提供给派生类读写,很方便,但也会破坏封装。频繁使用 protected 数据成员,派生类和基类之间的数据耦合会变得极强,基类一改,子类全崩。稳妥的做法是基类字段保持私有,通过受保护或公有的接口提供给子类使用。哪怕短短几行的 getter,也是一种稳定的封装边界。
第四,继承层次控制在两层左右最合适。三层继承不是不能写,而是每多一层,理解和调试成本就大幅上升。服务端开发中,如果某个基类下面派生了两层仍然需要继续抽象,我一般会怀疑整体抽象是否走偏,也许是该引入接口或者重新整理职责了。
8. 写进项目的继承自检清单
最后分享一个自检清单,每次写完继承代码对照着问一遍,能避免许多低级错误。
- 基类构造函数是否被正确调用?如果基类没有默认构造函数,派生类初始化列表里是否显式传参?
- 基类析构函数是否声明为 virtual?如果不是多态基类,是否确认没有通过基类指针删除派生类对象?
- 访问控制是否合适?对外暴露的接口是否真的需要 public?只有子类需要的成员是否已设置为 protected?
- 派生类中的同名函数是想要“隐藏”还是“覆盖”?如果想要动态绑定,基类函数是否声明了 virtual,且派生类函数是否加上了 override?
- 是否发生了意外的对象切片?按值传递参数或赋值时是否产生了形状不符的复制?
- 菱形继承是否用了 virtual 去共享顶层基类?
这六条如果每条都能答得上来,继承基础这一课就算真正过关了。先把这些语法和设计规则吃透,后面讲虚函数表、多态、模板时,你会发现许多概念都能串联到一起。你自己多写几个小例子,尤其是专门用来触发那些坑的代码,比任何教程都管用。