C++ 继承:从概念到实战的全方位指南
在面向对象程序设计的世界里,继承是实现代码复用、构建清晰类层次的核心机制。很多初学者在接触继承时,容易被访问权限、隐藏规则、菱形继承等概念绕晕。今天,我们就结合《01. 继承.pdf》的核心内容,从基础到进阶,手把手带你掌握 C++ 继承的精髓,避开那些容易踩的坑。
一、继承是什么?解决了什么痛点?
先从一个真实场景说起:如果要设计Student(学生)和Teacher(教师)两个类,你会发现它们有大量重复成员 —— 姓名(_name)、地址(_address)、电话(_tel),还有身份认证(identity())这样的重复函数。直接在两个类里分别定义这些内容,不仅代码冗余,后续修改时还要 “两处同步改”,维护成本极高。
这就是继承要解决的核心问题:将多个类的公共成员抽取到 “基类(父类)”,让 “派生类(子类)” 继承基类的特性,同时扩展自己的独有成员。比如我们可以先定义Person基类,把_name、identity()等公共部分放进去,再让Student和Teacher继承Person,只新增学号(_stuid)、职称(_title)等独有成员。这样一来,代码复用率大幅提升,结构也更清晰。
从定义上看,继承(inheritance)是面向对象程序设计中 “类设计层次的复用”—— 区别于函数层次的复用,它让类之间形成 “从简单到复杂” 的层次结构,完美契合人类的认知规律。
二、继承的基础语法:3 个核心要素
掌握继承,首先要搞懂 “基类 / 派生类”“继承方式”“访问权限” 这三个核心要素,以及它们之间的关系。
1. 基本定义格式
继承的语法非常直观,派生类定义时通过:指定基类和继承方式,格式如下:
// 基类(父类):存储公共成员class Person {public:// 公共函数:身份认证(学生、教师共用)void identity() {cout << "身份认证:" << _name << endl;}protected:// 保护成员:子类可访问,类外不可访问string _name = "张三"; // 姓名string _address; // 地址string _tel; // 电话int _age = 18; // 年龄};// 派生类(子类):public继承Personclass Student : public Person {public:// 子类独有函数:学习void study() {cout << _name << "正在学习" << endl;}protected:// 子类独有成员:学号int _stuid;};// 派生类(子类):public继承Personclass Teacher : public Person {public:// 子类独有函数:授课void teaching() {cout << _name << "正在授课" << endl;}protected:// 子类独有成员:职称string _title;};
这里Person是基类(也叫父类),Student和Teacher是派生类(也叫子类),public是继承方式 —— 这是实际开发中最常用的继承方式。
2. 3 种继承方式与访问权限的关系
继承方式(public/protected/private)决定了 “基类成员在派生类中的访问权限”,这是继承的核心考点之一。
基类成员类型 | public 继承后 | protected 继承后 | private 继承后 |
public 成员 | 派生类 public | 派生类 protected | 派生类 private |
protected 成员 | 派生类 protected | 派生类 protected | 派生类 private |
private 成员 | 不可见 | 不可见 | 不可见 |
必须记住的 4 个关键结论:
- 基类 private 成员 “不可见”≠“不继承”:基类的 private 成员会被继承到派生类对象中(占用内存),但语法上限制派生类无论在类内还是类外都无法访问。
- protected 成员是为继承而生:如果基类成员想 “不让类外访问,但允许子类访问”,就定义为protected—— 这是protected和private的核心区别。
- 权限计算规则:基类非 private 成员在派生类的访问权限 = Min(成员在基类的访问限定符, 继承方式),权限优先级:public > protected > private。比如基类 public 成员 + protected 继承,在派生类中就是 protected 权限。
- 默认继承方式要注意:用class定义类时,默认继承方式是private;用struct时默认是public。但建议显式写出继承方式,避免歧义。
3. 实际开发的继承选择
实际开发中几乎只使用 public 继承。因为protected/private继承下来的成员,只能在派生类内部使用,后续无法进一步扩展(比如派生类的子类无法访问),维护性极差。
三、继承中的 “坑”:3 个核心规则
掌握了基础语法后,还要警惕继承体系中的特殊规则 —— 这些是面试和实战中最容易出错的地方。
1. 基类与派生类的对象转换:切片规则
在public继承下,派生类对象和基类对象之间有特定的转换规则,形象地称为 “切片(切割)”:
- 允许的转换:
- 派生类对象可以赋值给基类指针 / 引用:比如Student s; Person* p = &s;,此时基类指针 / 引用指向的是派生类对象中 “基类那部分”,就像把派生类 “切” 出基类的部分赋值过去。
- 派生类对象可以赋值给基类对象:本质是调用基类的拷贝构造函数,只拷贝基类部分的成员。
- 禁止的转换:基类对象不能赋值给派生类对象。比如Person p; Student s = p;会编译报错,因为基类没有派生类的独有成员(如_stuid),无法完成赋值。
注意:基类指针 / 引用可以通过强制类型转换赋值给派生类指针 / 引用,但只有当基类指针指向的是派生类对象时才安全。如果是多态场景,建议用dynamic_cast进行安全转换。
2. 同名成员的隐藏规则:不是重载!
在继承体系中,基类和派生类有同名成员(变量或函数)时,会触发 “隐藏” 规则 —— 这是很多初学者混淆的点:
- 隐藏的定义:派生类成员会屏蔽基类对同名成员的直接访问,即使函数参数不同(这和重载不同,重载要求 “同一作用域”,而继承是不同作用域)。
- 函数隐藏的关键:只要函数名相同,就构成隐藏,无需参数列表匹配。比如基类有void fun(),派生类有void fun(int i),派生类的fun(int)会隐藏基类的fun()。
- 访问隐藏成员的方式:如果要在派生类中访问基类的同名成员,必须显式加基类作用域,比如Person::_num。
实战建议:尽量不要在继承体系中定义同名成员,容易混淆出错。比如基类Person有int _num(身份证号),子类Student有int _num(学号),子类的_num会隐藏父类的_num,访问时必须显式指定作用域。
3. 派生类的默认成员函数:必须调用基类!
C++ 类有 6 个默认成员函数(构造、析构、拷贝构造、赋值重载、取地址重载、const 取地址重载),其中前 4 个在继承中有特殊规则,核心是 “派生类必须依赖基类完成初始化和清理”:
(1)构造函数:先基类后派生类
- 派生类的构造函数必须调用基类的构造函数,初始化基类部分的成员。
- 如果基类有默认构造函数(无参或全缺省),派生类可以省略调用,编译器会自动隐式调用;
- 如果基类没有默认构造函数,派生类必须在初始化列表中显式调用基类的构造函数,否则编译报错。
示例:
// 基类:无默认构造函数(只有带参构造)class Person {public:Person(const char* name) : _name(name) {}protected:string _name;};// 派生类:必须显式调用基类构造函数class Student : public Person {public:// 初始化列表中显式调用基类构造Student(const char* name, int num) : Person(name), _num(num) {}protected:int _num;};
(2)析构函数:先派生类后基类
- 派生类的析构函数无需显式调用基类析构,编译器会在派生类析构函数执行完毕后,自动调用基类析构函数。
- 这样做是为了保证 “先清理派生类成员,再清理基类成员” 的顺序,避免资源泄漏。
注意:析构函数名会被编译器特殊处理为destructor(),所以基类析构不加virtual时,派生类析构和基类析构是 “隐藏” 关系,不是重载。
(3)拷贝构造与赋值重载:需显式调用基类
- 派生类的拷贝构造函数必须调用基类的拷贝构造,否则基类部分的成员不会被正确拷贝;
- 派生类的赋值重载必须调用基类的赋值重载,因为派生类的赋值重载会隐藏基类的赋值重载,需要显式加基类作用域调用(如Person::operator=(s))。
四、多继承与菱形继承:C++ 的 “痛点”
多继承是 C++ 的一个特性,但也带来了复杂的问题 —— 尤其是菱形继承,这是 C++ 继承中最容易踩坑的部分。
1. 多继承的定义
- 单继承:一个派生类只有一个直接基类(如Student继承Person);
- 多继承:一个派生类有两个或以上直接基类(如Assistant同时继承Student和Teacher)。
- 多继承对象的内存模型:先继承的基类在内存前面,后继承的基类在后面,派生类成员放在最后。
2. 菱形继承的问题:数据冗余与二义性
菱形继承是多继承的特殊情况:类 A 派生出类 B 和类 C,类 D 同时继承类 B 和类 C,形成 “菱形” 结构。比如Person派生出Student和Teacher,Assistant(助教)同时继承Student和Teacher,此时会出现两个严重问题:
- 数据冗余:Assistant对象中会有两份Person的成员(如_name),浪费内存
- 二义性:访问_name时,编译器不知道是Student继承的_name还是Teacher继承的_name,编译报错。
示例:
class Assistant : public Student, public Teacher {protected:string _majorCourse; // 主修课程};int main() {Assistant a;a._name = "peter"; // 编译报错:对“_name”的访问不明确// 需显式指定作用域:a.Student::_name = "xxx";return 0;}
即使显式指定作用域能解决二义性,数据冗余问题依然存在。
3. 解决方案:虚继承
为了解决菱形继承的问题,C++ 引入了 “虚继承(Virtual Inheritance)”:在间接基类的继承处加上virtual关键字,让间接派生类只保留一份间接基类的成员。
修改后的代码:
// 虚继承:Student和Teacher继承Person时加virtualclass Student : virtual public Person { ... };class Teacher : virtual public Person { ... };// Assistant继承Student和Teacherclass Assistant : public Student, public Teacher { ... };int main() {Assistant a;a._name = "peter"; // 正确:无二义性,仅一份_namereturn 0;}
虚继承能同时解决数据冗余和二义性问题,建议:尽量不要设计菱形继承。因为虚继承底层实现复杂,会有性能损失,且代码可读性降低。很多语言(如 Java)直接不支持多继承,就是为了规避这个问题3。
五、继承与组合:该怎么选?
除了继承,组合也是实现代码复用的重要方式。两者的适用场景不同,选对了能大幅降低代码耦合度。
1. 继承与组合的核心区别
特性 | 继承(Inheritance) | 组合(Composition) |
关系 | is-a(是一个):如BMW是Car的一种 | has-a(有一个):如Car有Tire(轮胎) |
复用方式 | 白箱复用:基类内部细节对子类可见 | 黑箱复用:被组合对象仅通过接口提供功能 |
耦合度 | 高:基类修改会直接影响子类 | 低:组合类之间依赖弱,维护性好 |
适用场景 | 类间明确是 is-a 关系,或需实现多态 | 类间是 has-a 关系,追求低耦合 |
- 继承示例:BMW和Car是 is-a 关系,BMW是Car的一种,适合用继承。
- 组合示例:Car和Tire是 has-a 关系,Car有 4 个Tire,适合用组合。
2. 实战建议:优先使用组合
原因如下:
- 组合耦合度低,代码维护性好:即使被组合对象(如Tire)内部修改,只要接口不变,Car类就无需修改;
- 继承耦合度高,基类的任何修改(如成员变量名变化)都会影响所有子类;
- 例外情况:如果类之间明确是 is-a 关系(如Student是Person),或需要实现多态(后续章节讲解),则必须用继承。
比如stack(栈)和vector(向量)的关系:既可以用继承(stack是一种vector),也可以用组合(stack有一个vector成员)。此时优先选组合,因为组合能避免暴露vector的不必要接口(如push_back),且耦合度更低。
六、实战技巧:不能被继承的类怎么设计?
有时候我们需要设计一个 “不能被继承的类”(如工具类、单例类),《01. 继承.pdf》提供了两种方法:
1. C++98 方法:基类构造函数私有化
原理:派生类构造函数必须调用基类构造函数。如果基类构造函数私有化,派生类无法调用,也就无法实例化对象。
示例:
class Base {private:// 构造函数私有化Base() {}public:void func() { cout << "Base::func" << endl; }};// 编译报错:无法访问Base的私有构造函数class Derive : public Base { ... };
2. C++11 方法:final 关键字
C++11 新增final关键字,用final修饰基类后,任何子类继承该基类都会编译报错,简洁高效。
示例:
// final修饰基类,禁止继承class Base final {public:void func() { cout << "Base::func" << endl; }};// 编译报错:无法从“final”基类“Base”继承class Derive : public Base { ... };