继承是面向对象程序设计三大核心特性之一,其本质是实现类级别的代码复用,在保留原有类特性的基础上进行扩展,构建出层次化的类结构。很多初学者对继承的理解仅停留在 "子类继承父类" 的表层,对访问权限规则、同名隐藏、对象切片、菱形继承与虚继承底层等细节理解模糊。
本文从基础语法到底层原理,逐层拆解 C++ 继承的所有核心知识点,并补充大量代码外的底层细节与面试考点。
一、继承的本质:类级别的代码复用
1.1 为什么需要继承
在没有继承的场景下,如果要设计Student(学生)、Teacher(老师)两个类,会发现二者存在大量重复逻辑:都有姓名、年龄、地址等成员变量,都有身份认证等成员函数。重复编写不仅冗余,还会带来维护一致性问题。
继承的核心价值就是把公共成员抽取到Person(人)基类中,Student和Teacher直接继承复用,只需要扩展自己独有的成员(学号、职称等),大幅减少重复代码。
1.2 基本语法与核心概念
cpp
// 基类(父类)
class Person {
public:
void identity() { cout << "身份认证:" << _name << endl; }
protected:
string _name = "张三";
int _age = 18;
};
// 派生类(子类):public 方式继承 Person
class Student : public Person {
public:
void study() { /* 学习逻辑 */ }
protected:
int _stuid; // 学号:派生类独有
};
- 基类 / 父类:被继承的类,提供公共成员
- 派生类 / 子类:继承基类的类,扩展新的成员
- 继承后派生类包含两部分:基类继承下来的成员 + 自己扩展的成员
二、三种继承方式与访问权限控制
继承方式决定了基类成员在派生类中的访问权限,是继承的基础考点。
2.1 访问限定符回顾
| 访问限定符 | 类内访问 | 类外访问 | 派生类内访问 |
|---|---|---|---|
public |
✅ | ✅ | ✅ |
protected |
✅ | ❌ | ✅ |
private |
✅ | ❌ | ❌ |
关键补充:
protected是专门为继承设计的访问级别。它让基类成员可以在派生类中访问,但对类外封闭,既保证了复用性,又维护了封装性。
2.2 三种继承方式的权限变化
继承方式有 public、protected、private 三种,基类成员在派生类中的最终访问权限 = Min (基类成员的访问级别,继承方式的级别) ,权限优先级:public > protected > private。
| 基类成员 \ 继承方式 | public 继承 | protected 继承 | private 继承 |
|---|---|---|---|
| 基类 public 成员 | 派生类 public 成员 | 派生类 protected 成员 | 派生类 private 成员 |
| 基类 protected 成员 | 派生类 protected 成员 | 派生类 protected 成员 | 派生类 private 成员 |
| 基类 private 成员 | 派生类不可见 | 派生类不可见 | 派生类不可见 |
2.3 核心细节补充
- 基类 private 成员的 "不可见":不是没被继承,而是语法限制派生类无法直接访问。它仍然存在于派生类对象的内存中,只是被编译器隐藏。
- 默认继承方式 :使用
class关键字时默认private继承,使用struct时默认public继承。工程中强烈建议显式写出继承方式。 - 工程实践 :99% 的场景使用
public继承。protected/private继承会让基类成员无法在类外使用,扩展性极差,本质是 "用继承实现组合",不推荐使用。
三、基类与派生类的对象转换:切片现象
public 继承中,派生类对象可以赋值给基类的指针、引用或对象,这是 C++ 的特殊规则,也是多态的基础。
3.1 向上转换(派生类 → 基类)
cpp
Student sobj;
// 1. 基类指针指向派生类对象
Person* pp = &sobj;
// 2. 基类引用绑定派生类对象
Person& rp = sobj;
// 3. 基类对象 = 派生类对象(发生对象切片)
Person pobj = sobj;
- 指针 / 引用转换 :只是让指针 / 引用指向派生类对象中的基类子对象部分,不会修改内存,没有额外开销。
- 对象赋值(切片 / 切割):会调用基类的拷贝构造函数,只拷贝派生类里的基类部分,把派生类独有的成员 "切掉",最终得到一个纯基类对象。
Person* 指向 Student 对象的头部基类部分,派生类自己的 _stuid 在基类部分之后,基类指针无法访问。
3.2 向下转换(基类 → 派生类)
基类对象不能直接赋值给派生类对象,会编译报错。 基类指针 / 引用可以通过强制类型转换赋值给派生类指针 / 引用,但只有当基类指针本来就指向派生类对象时才安全。
补充:如果基类是多态类型(有虚函数),推荐使用
dynamic_cast进行安全的向下转换,转换失败会返回空指针。
四、继承体系的作用域:同名隐藏规则
基类和派生类有各自独立的作用域,当派生类中出现和基类同名的成员时,会发生隐藏------ 派生类成员屏蔽基类同名成员的直接访问。
4.1 隐藏的两种场景
-
同名成员变量 :派生类变量隐藏基类变量
cppclass Person { protected: int _num = 11; /* 身份证号 */ }; class Student : public Person { protected: int _num = 99; /* 学号 */ }; // Student内直接访问_num,得到的是派生类的99 // 要访问基类的,必须显式指定:Person::_num -
同名成员函数 :只要函数名相同就构成隐藏,和参数列表无关
cppclass A { public: void fun() { cout << "A::fun()"; } }; class B : public A { public: void fun(int i) { cout << "B::fun(int)"; } }; int main() { B b; b.fun(10); // 调用B的fun(int) b.fun(); // 编译报错!B的fun(int)隐藏了A的fun(),不是重载 b.A::fun(); // 显式指定类域,调用基类版本 }
4.2 隐藏 vs 重载:核心区别
很多人混淆这两个概念,本质区别在于作用域:
- 重载:同一作用域下,函数名相同、参数不同
- 隐藏:不同作用域(基类 / 派生类),只要函数名相同就屏蔽基类版本
面试高频考点:判断基类和派生类的同名函数是重载还是隐藏,答案一定是隐藏。
4.3 访问被隐藏成员
使用 基类名::成员名 显式指定作用域,即可访问基类被隐藏的版本。工程中建议尽量避免继承体系中出现同名成员,降低代码混淆。
五、派生类的默认成员函数:构造与析构顺序
C++ 类有 6 个默认成员函数,在继承体系中,编译器生成的默认版本会自动处理基类部分的初始化与清理。
5.1 构造函数:先基类,后派生类
派生类构造函数执行时,必须先调用基类构造函数初始化基类部分,再初始化派生类自己的成员。
- 如果基类有默认构造函数,派生类构造函数可以不写,编译器自动调用
- 如果基类没有默认构造,派生类必须在初始化列表中显式调用基类构造
cpp
class Student : public Person {
public:
// 初始化列表调用基类构造
Student(const char* name, int num)
: Person(name) // 调用基类构造
, _num(num) // 初始化自己的成员
{}
};
5.2 拷贝构造与赋值重载
派生类的拷贝构造和赋值重载,必须调用基类的对应版本完成基类部分的拷贝 / 赋值。
cpp
// 拷贝构造
Student(const Student& s)
: Person(s) // 切片:派生类对象传给基类拷贝构造
, _num(s._num)
{}
// 赋值重载
Student& operator=(const Student& s) {
if (this != &s) {
Person::operator=(s); // 显式调用基类赋值(因为被隐藏了)
_num = s._num;
}
return *this;
}
注意:赋值运算符会构成隐藏,所以调用基类版本必须加
基类名::限定。
5.3 析构函数:先派生类,后基类
析构顺序和构造完全相反:先执行派生类析构,再自动调用基类析构。
- 派生类析构函数执行完后,编译器会自动插入基类析构的调用,不需要手动调用
- 析构函数也构成隐藏:编译器会把所有析构函数统一处理为
destructor()名字,因此基类和派生类析构函数同名,形成隐藏
5.4 实现 "不能被继承的类"
两种经典实现方式:
- C++98 方式:把基类的构造函数设为 private。派生类构造必须调用基类构造,但基类构造不可见,无法实例化。
- C++11 方式 :用
final关键字修饰类,直接禁止继承,语法更简洁。
cpp
class Base final { /* 该类不能被继承 */ };
六、继承中的特殊成员:友元与静态
6.1 友元关系不能继承
基类的友元函数 / 类,不能自动访问派生类的私有和保护成员。
cpp
class Student;
class Person {
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; // 合法:Person的友元
cout << s._stuNum; // 编译报错:友元不能继承,无法访问Student的保护成员
}
解决方案:把 Display 同时声明为 Student 的友元。
6.2 静态成员全体系共享
基类定义的静态成员,整个继承体系只有一份实例,所有基类、派生类对象共享。
- 静态成员不属于某个对象,和普通成员不同,不会随继承复制多份
- 既可以通过对象访问,也可以通过
类名::静态成员访问
cpp
class Person { public: static int _count; };
int Person::_count = 0;
class Student : public Person {};
int main() {
Person p; Student s;
// 两个地址完全相同,说明是同一个静态变量
cout << &p._count << endl;
cout << &s._count << endl;
}
七、多继承与菱形继承:虚继承的底层
7.1 单继承与多继承
- 单继承:一个派生类只有一个直接基类
- 多继承:一个派生类有两个或以上直接基类
cpp
// 多继承:同时继承Student和Teacher
class Assistant : public Student, public Teacher {
protected:
string _majorCourse;
};
多继承对象的内存布局:先继承的基类在前,后继承的基类在后,最后是派生类自己的成员。
7.2 菱形继承的两大问题
菱形继承是多继承的特殊情况:两个派生类继承同一个基类,最终派生类同时继承这两个派生类,形成菱形结构。 典型场景:Student 和 Teacher 都继承 Person,Assistant 同时继承 Student 和 Teacher。
问题 1:数据冗余 Assistant 对象中会包含两份 Person 成员(一份来自 Student,一份来自 Teacher),造成内存浪费。
问题 2:二义性 直接访问 _name 时,编译器不知道访问哪个路径下的 _name,报 "访问不明确" 错误。
cpp
Assistant a;
a._name = "peter"; // 编译报错:二义性
a.Student::_name = "xxx"; // 显式指定路径可以解决,但冗余仍在
7.3 虚继承:解决菱形继承
使用 virtual public 虚继承方式,让中间类共享同一份虚基类成员,最终派生类只保留一份基类实例。
cpp
class Student : virtual public Person {};
class Teacher : virtual public Person {};
class Assistant : public Student, public Teacher {};
虚继承后,Assistant 中只有一份 Person 成员,既解决了冗余,也解决了二义性。
7.4 虚继承的底层原理
虚继承通过虚基类表指针 + 虚基类表实现:
- 每个虚继承的派生类对象,会增加一个虚基类表指针(vbptr),指向一张虚基类表
- 虚基类表中存储虚基类相对于当前类的偏移量,通过偏移量计算找到唯一的虚基类成员
- 虚基类由最派生类(最终实例化的类)负责构造,保证只初始化一次
代价:虚继承会增加内存开销(多了指针和表),也会增加成员访问的计算开销,因此工程中不推荐设计复杂的菱形继承结构。
7.5 多继承的指针偏移问题
多继承中,不同基类指针指向同一个派生类对象时,地址可能不同。
cpp
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; // 指向d的Base1部分,地址和d相同
Base2* p2 = &d; // 指向d的Base2部分,地址 = d地址 + Base1大小
Derive* p3 = &d; // 和p1地址相同
}
结论:p1 == p3 != p2,这也是高频选择题考点。
补充:C++ 标准库的 IO 流体系就是典型的菱形虚继承:
istream和ostream都虚继承ios,iostream同时继承两者。
八、继承 vs 组合:如何选择
8.1 核心关系区别
| 关系 | 语义 | 复用类型 | 耦合度 | 封装性 |
|---|---|---|---|---|
| 继承 | is-a(是一个) | 白箱复用 | 高 | 弱(基类细节对派生类可见) |
| 组合 | has-a(有一个) | 黑箱复用 | 低 | 强(只通过接口交互) |
- 白箱复用:继承中基类的内部细节对派生类可见,一定程度破坏了封装,基类改动对派生类影响大。
- 黑箱复用:组合只通过对象的公共接口交互,内部细节不可见,类之间耦合度低。
8.2 设计原则:优先使用组合
工程实践的通用准则:优先使用对象组合,而不是类继承。
- 类之间是明确的 is-a 关系、或者需要实现多态时,才使用继承
- 其他复用场景优先用组合,维护性、扩展性更好
典型例子:STL 的
stack和vector既可以用继承(is-a)也可以用组合(has-a),但标准库最终选择了组合(容器适配器),就是为了降低耦合。
九、进阶:类模板的继承
当基类是类模板时,派生类继承会遇到一个经典编译坑,代码中也明确标注了这个问题。
9.1 模板继承的语法
cpp
namespace bit {
template<class T>
class stack : public std::vector<T> {
public:
void push(const T& x) {
// 必须加 vector<T>:: 限定,否则编译报错
vector<T>::push_back(x);
}
};
}
9.2 为什么必须加类域限定
C++ 模板采用两阶段查找:
- 第一阶段:解析模板定义时,只查找不依赖模板参数的名字
- 第二阶段:模板实例化时,才查找依赖模板参数的名字
基类 vector<T> 依赖模板参数 T,所以第一阶段不会去基类里查找 push_back,直接报 "找不到标识符"。 加上 vector<T>:: 显式指定后,编译器会知道这是基类的成员,推迟到实例化阶段再查找。
这是模板继承的高频易错点,也是面试中考察模板理解深度的经典问题。
十、高频面试考点总结
- 三种继承方式下基类成员的访问权限变化
- 派生类的构造、析构调用顺序
- 隐藏和重载的区别
- 对象切片的本质与场景
- 菱形继承的问题与虚继承的原理
- 友元、静态成员在继承中的特性
- 继承和组合的区别与选择
- 类模板继承中为什么要加基类类域限定
继承是面向对象的基石,也是理解多态的前提。吃透这些语法细节与底层原理,不仅能写出更健壮的代码,也能在面试中建立更深的技术优势。