文章目录
- 引言
-
- 一、继承的概念与定义
-
- [1.1 继承的概念](#1.1 继承的概念)
- [1.2 继承定义](#1.2 继承定义)
- [1.3 继承的类模板](#1.3 继承的类模板)
- 二、基类和派生类间的转换(切片)
- 三、继承中的作用域
-
- [3.1 隐藏规则](#3.1 隐藏规则)
- [3.2 考察继承作用域相关选择题](#3.2 考察继承作用域相关选择题)
- 四、派生类的默认成员函数
-
- [4.1 六个默认成员函数](#4.1 六个默认成员函数)
- [4.2 实现一个不能被继承的类](#4.2 实现一个不能被继承的类)
- 五、继承与友元
- 六、继承与静态成员
- 七、多继承及其菱形继承
-
- [7.1 多继承模型](#7.1 多继承模型)
- [7.2 虚继承](#7.2 虚继承)
- [7.3 多继承中指针偏移问题](#7.3 多继承中指针偏移问题)
- [7.4 IO 库里的菱形虚拟继承](#7.4 IO 库里的菱形虚拟继承)
- 八、继承和组合

Ciallo(∠・ω< )⌒☆
C语言专栏
数据结构专栏
C++专栏
作者仓库
引言
学完类和对象,我写类的时候常碰到一种情况:学生类里有姓名、地址、电话、学号,老师类里也有姓名、地址、电话,再多一个职称。同一批成员在两个类里各写一遍,又啰嗦又难看。继承就是干这个的,把公共的成员抽到一个基类里,其他类继承它,直接复用。
不过继承是我个人觉得 C++ 里最容易"以为会了、实际全是坑"的一块。切片、隐藏、菱形继承的二义性、默认成员函数在派生类里行为大变样,每一样都值得单独画图讲清楚。这篇把继承整个串一遍,重点放在三道选择题、派生类的默认成员函数和多继承三块。
注意:本篇文章需要 C++ 类和对象、默认成员函数的基础(可先看《类和对象全解》)。继承的坑比较多,文章若有误恳请读者见谅。
一、继承的概念与定义
1.1 继承的概念
先看这两个类,Student 和 Teacher 里有一大堆重复的公共成员:
Student Teacher
+---------------------+ +---------------------+
| _name 姓名 | | _name 姓名 |
| _address 地址 | | _address 地址 |
| _tel 电话 | | _tel 电话 |
| _age 年龄 | | _age 年龄 |
|---------------------| |---------------------|
| _stuid 学号 | | _title 职称 |
+---------------------+ +---------------------+
Student 独有:学习 study() Teacher 独有:授课 teaching()
公共成员:姓名/地址/电话/年龄,还有 identity() 身份认证
继承的认知过程是从简单到复杂:公共成员放进 Person,Student 和 Teacher 都继承它,各自只需要写独有部分:
cpp
class Person
{
public:
void identity() // 校园/图书馆/实验室刷二维码身份认证
{
// ...
}
protected:
string _name = "张三"; // 姓名
string _address; // 地址
string _tel; // 电话
int _age = 18; // 年龄
};
class Student : public Person
{
public:
void study() // 学习
{
// ...
}
protected:
int _stuid; // 学号
};
class Teacher : public Person
{
public:
void teaching() // 授课
{
// ...
}
protected:
string title; // 职称
};
继承是面向对象程序设计里代码复用的重要手段,体现的是类层次的复用。有了继承,就不用把 identity() 和那堆成员在两个类里各写一遍。
1.2 继承定义
语法就是 class 派生类 : 继承方式 基类:
cpp
class Student : public Person
{
// ...
};
public 是继承方式。继承方式有三种:public、protected、private,它决定基类成员在派生类里是什么访问属性。这张表值得背下来,考试也常考:
基类成员 public 继承 protected 继承 private 继承
--------------- ------------ ------------- ------------
public 成员 派生类 public 派生类 protected 派生类 private
protected 成员 派生类 protected 派生类 protected 派生类 private
private 成员 不可见 不可见 不可见
学这块有四个点容易踩:
- 基类的 private 成员仍然被继承到了派生类对象里,只是语法上访问不到。 不管哪种继承方式,基类 private 成员在派生类里都不可见。这里的"不可见"是看都看不到、访问不了,不是"不存在"。
- 如果基类成员不想在类外被访问 ,但又想在派生类里能用,就把它定义为
protected(protected 就是为了继承出现的)。 - 实际开发里 public 继承用得最多,protected/private 继承很少用,因为继承下来的成员大多只能在派生类里用,扩展维护性不强。
class默认继承方式是 private,struct默认是 public。不过最好显式写出来,别靠默认。
1.3 继承的类模板
继承也能用在模板类上。比如用 stack 继承 vector,堆栈的 push/pop 直接复用 vector 的 push_back/pop_back。这里有个坑:基类是模板时,调基类成员要指定作用域 ,否则编译报 error C3861: "push_back": 找不到标识符。
cpp
namespace xjw
{
template<class T>
class stack : public vector<T>
{
public:
void push(const T& x) { vector<T>::push_back(x); }
void pop() { vector<T>::pop_back(); }
const T& top() { return vector<T>::back(); }
bool empty() { return vector<T>::empty(); }
};
}
为什么一定要写 vector<T>::?因为 stack 是模板,只有它实例化(比如 stack<int>)时 vector<int> 才会真正实例化出来。在类定义里直接写 push_back,编译器还没实例化 vector<T>,根本找不到这个符号,所以必须显式指明作用域。
二、基类和派生类间的转换(切片)
public 继承下,派生类对象可以赋值给基类对象、基类指针、基类引用,这叫切片。形象点说,就是切出一块给基类用,只拿基类那部分。
Student sobj Person pobj = sobj
+---------------------+ +---------------------+
| Person 部分 | | Person 部分 |
| _name | | _name |
| _sex | | _sex |
| _age | | _age |
|---------------------| +---------------------+
| Student 部分 | 只挪走基类那块,Student 独有部分不带走
| _No |
+---------------------+
Person* pp = &sobj; Person& rp = sobj; // 都指向 sobj 里的 Person 部分
cpp
Student sobj;
Person pobj = sobj; // 1. 子类对象赋值给基类对象
Person* pp = &sobj; // 2. 子类对象赋值给基类指针
Person& rp = sobj; // 3. 子类对象赋值给基类引用
// 反过来,父类对象不能赋值给子类对象,这里会编译报错
// sobj = (Student)pobj;
反过来不行。这点和类型转换很像:int 可以赋给 double,但 double 不能直接赋给 int。子类对象更丰富,切给基类没问题;基类对象缺了子类那部分,硬塞给子类对象会编译报错。至于为什么基类指针指向派生类对象是安全的、反向需要 dynamic_cast 做运行期类型识别,这里先了解,后面多态章节会细讲。
三、继承中的作用域
3.1 隐藏规则
继承体系里,基类和派生类都有自己独立的作用域。如果派生类里出现了和基类同名的成员,默认访问到的都是派生类的,基类的那个会被隐藏。 这就是隐藏。
cpp
class Person
{
protected:
string _name = "小李子"; // 姓名
int _num = 111; // 身份证号
};
class Student : public Person
{
void Print()
{
cout << _name << endl; // 访问子类自己的 _name
cout << _num << endl; // 访问子类自己的 _num
cout << Person::_num << endl; // 显式访问基类的 _num
}
protected:
int _num = 999; // 学号
};
这里 Student 里有一个 _num,Person 里也有一个 _num,二者构成隐藏关系。访问规则是按名字查找,从当前作用域往上找,找到哪个用哪个。所以 _num 默认拿到的是 Student 的 999,要拿 Person 的 111 必须 Person::_num。这也是为什么继承体系里最好别定义同名成员 ,容易把自己绕晕。注意成员函数隐藏只看函数名,同名即隐藏,可能问出大问题,正好看下面两题。
3.2 考察继承作用域相关选择题
下面这两道题几乎把隐藏的坑掏空了,面试经常变着形出。
第一题:A 和 B 里的两个 func 构成什么关系?
cpp
class A
{
public:
void fun() { cout << "func()" << endl; }
};
class B : public A
{
public:
void fun(int i) { cout << "func(int i)" << i << endl; }
};
int main()
{
B b;
b.fun(10);
return 0;
}
选项:A. 重载 B. 隐藏 C. 无关系
class A (基类) class B : public A (派生类)
+----------------+ +----------------+
| void fun() | | void fun(int) |
+----------------+ +----------------+
^
| 隐藏(B 里出现同名 fun,A 的 fun() 被整体隐藏)
不是重载:重载要求同一作用域,而 fun() 和 fun(int) 分属 A、B 两个类域
答案是 B. 隐藏。 关键在"重载要求同一作用域"。A::fun() 和 B::fun(int) 一个在 A 类域、一个在 B 类域,不在同一个作用域,所以根本不是重载。B 里 fun(int) 一出现,就用同一个名字把基类 A 里所有 叫 fun 的版本全屏蔽了,不管参数类型、不管重载。这就是隐藏和重载最本质的区别。
第二题:下面程序的编译运行结果是什么?
cpp
class A
{
public:
void fun() { cout << "func()" << endl; }
};
class B : public A
{
public:
void fun(int i) { cout << "func(int i)" << i << endl; }
};
int main()
{
B b;
b.fun(); // 有问题
b.fun(10);
return 0;
}
选项:A. 编译报错 B. 编译运行 C. 正常运行
B b; b.fun(); // 调用不了!
因为 B 里只有一个 fun(int),A 的 fun() 被隐藏了
名字查找:从 B 作用域找 fun -> 找到 fun(int) -> 参数不匹配 -> 编译报错(不会回 A 里找)
b.A::fun(); // 必须显式指定基类作用域,才能调到 A::fun()
b.fun(10); // 这个正常
答案是 A. 编译报错。 b.fun() 找不到匹配的函数。因为 B 里的 fun(int) 已经把 A 的 fun() 隐藏了,编译器按名字找到了 B::fun,可它需要一个 int 参数,你传了 0 个,于是报错。它不会好心回退到 A 里找 fun()。这也是隐藏最容易犯的错:子类只要定义了一个同名函数,父类所有同名函数就全用不了了 ,要调只能 b.A::fun() 显式指定作用域。
四、派生类的默认成员函数
4.1 六个默认成员函数
类和对象那篇讲过普通类的 6 个默认成员函数:构造、析构、拷贝构造、赋值重载、取地址重载(普通 + const)。关键是,在派生类里,这 6 个成员的默认生成行为变了一大截。这是《类和对象全解》的衔接补充,也是面试重灾区。
(1)构造函数
派生类构造函数必须调用基类构造函数来初始化基类那部分成员。如果基类没有默认构造,你必须在初始化列表里显式调用某个基类构造。
cpp
class Student : public Person
{
public:
Student(const char* name, int num, const char* addrss)
: Person(name) // 必须先初始化基类部分
, _num(num)
, _addrss(addrss)
{}
protected:
int _num = 1;
string _addrss = "西安市高新区";
int* _ptr = new int[10];
};
构造顺序是先基类、再派生类。C++ 保证先调用基类构造,再构造派生类自己的成员。内存布局里基类部分在低地址,派生类成员在高地址。
补充一个"默认构造"的细节:如果派生类不写构造函数,编译器会生成默认构造。这个默认构造的行为有三点:① 内置类型成员不初始化(不确定);② 自定义类型成员调用它的默认构造;③ 继承来的父类成员看成一个整体对象,要求调用父类的默认构造。
(2)拷贝构造
派生类拷贝构造必须调用基类的拷贝构造 来拷贝基类部分。写法上,把父类看成一个自定义类型,在初始化列表里用 Person(s) 调它:
cpp
Student(const Student& s)
: Person(s) // 用基类的拷贝构造拷贝基类部分
, _num(s._num)
, _addrss(s._addrss)
{
// 深拷贝(有指针等深拷贝资源才需要)
}
严格说,像上面这种 Student 有 int* _ptr(new int[10]),那就必须自己实现拷贝构造做深拷贝;如果没有这类动态资源,默认生成的拷贝构造其实就够了。判断标准和普通类一样,只是派生类多了个"别忘了先拷基类部分"的职责。
(3)赋值运算符重载
派生类 operator= 必须显式调用基类 operator= 。这里有个坑:父类和子类的 operator= 构成隐藏关系 ,直接写 Person::operator= 才行。
cpp
Student& operator=(const Student& s)
{
if (this != &s)
{
Person::operator=(s); // 显式调用基类赋值,注意是隐藏关系
_num = s._num;
_addrss = s._addrss;
}
return *this;
}
如果漏掉 Person::operator=(s),基类部分就拷不到,对象里基类成员是旧的、甚至未初始化。这种 bug 很难查。
(4)析构函数
派生类析构函数不需要显式调用基类析构 ,编译器会自动在子类析构函数执行完之后再调用基类析构。这保证了先析构子类、再析构基类 (和构造顺序相反)。注意基类析构函数和子类析构函数也构成隐藏关系 ,但这是特例,编译器对析构函数做了特殊处理,统一转成 destructor() 来处理。
# 构造顺序(先基类后派生) # 析构顺序(先派生后基类)
+------------------+ +------------------+
| 基类 Person() | | ~Student() |
| 构造 | | 析构 |
+------------------+ +------------------+
| ^
v |
+------------------+ +------------------+
| 派生 Student() | | ~Person() |
| 构造 | | 析构 |
+------------------+ +------------------+
为什么不能显式调用?因为"先子后父"这个顺序是编译器保证的,你显式调用
Person::~Person()反而可能破坏顺序。显式调用是否可靠取决于具体实现,没人能保证,所以干脆交给编译器,它一定按"先子后父"来。
(5)取地址重载
跟普通类一样,默认生成的取地址重载(普通对象和 const 对象)就够了,很少自己实现。这 6 个里取地址几乎不用管,重点全在构造、拷贝、赋值、析构。
4.2 实现一个不能被继承的类
有时希望一个类不能被继承,两种方法:
- 构造函数设为私有:派生类构造必须调用基类构造,基类构造私有,派生类就无法实例化,也就继承不了。
- C++11 用
final关键字 :直接class Base final,编译器就不允许继承它了。
cpp
class Base final
{
public:
void func5() { cout << "Base::func5" << endl; }
};
class Derive : public Base // 编译报错:无法从 final 类继承
{
// ...
};
五、继承与友元
友元关系不能继承。 基类的友元函数不是派生类的友元,派生类里如果还需要,就得在派生类里也声明一次。
cpp
class Student; // 前置声明
class Person
{
public:
friend void Display(const Person& p, const Student& s);
protected:
string _name; // 姓名
};
class Student : public Person
{
friend void Display(const Person& p, const Student& s); // 必须再声明
protected:
int _stuNum; // 学号
};
void Display(const Person& p, const Student& s)
{
cout << p._name << endl;
cout << s._stuNum << endl; // 没有这行声明就会编译报错 error C2248
}
如果不在 Student 里也声明 friend,Display 只能访问 Person 的 _name,访问 Student 的 _stuNum 就报 error C2248。我刚开始常忽略这点:友元是授出去的,不会自动传给子类 ,子类如果还要用,得自己再授一遍。另外提一句,编译器遇到不认识的类型只会向上查找,所以要提前前置声明 Student。
六、继承与静态成员
基类定义了 static 成员,整个继承体系里只有这一个 static 成员,不管派生多少层,派生类和基类共用同一份。
cpp
class Person
{
public:
string _name;
static int _count; // 静态成员
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuNum;
};
int main()
{
Person p;
Student s;
cout << &p._name << endl; // 非静态成员:地址不一样
cout << &s._name << endl; // 说明子类继承下来了,父子类对象各有一份
cout << &p._count << endl; // 静态成员:地址一样
cout << &s._count << endl; // 说明子类和父类共用同一份静态成员
Person::_count++; // 公有的情况下,父子类指定类域都能访问
cout << p._count << endl; // 1
cout << s._count << endl; // 1
}
静态成员属于类,不属于具体哪个对象,所以不管 Person 还是 Student 访问的都是同一个 _count 实例。这也是静态成员和普通非静态成员最大的不同:普通成员是每个对象一份,静态成员是整条继承链一份。
七、多继承及其菱形继承
7.1 多继承模型
单继承是一个派生类只有一个直接基类;多继承是一个派生类有两条或两条以上的直接基类。多继承对象在内存里的模型是:各个基类子对象按声明顺序排列,最后放派生类自己的成员。
class Derive : public Base1, public Base2
+------------------+
| Base1 子对象 | 偏移 0
| _b1 |
+------------------+
| Base2 子对象 | 偏移 8
| _b2 |
+------------------+
| Derive 独有 | 偏移 16
| _d |
+------------------+
菱形继承是多继承的一种特殊情况,也是 C++ 里最麻烦的一种。看这个结构:
+----------+
| Person |
+----------+
| |
+-----+ +-----+
| |
+--------+ +--------+
| Student| | Teacher|
+--------+ +--------+
| |
+--------+--------+
|
+-----------+
| Assistant |
+-----------+
Assistant 既继承 Student 又继承 Teacher,而 Student、Teacher 都继承 Person。结果 Assistant 对象里会有两份 Person 成员:
Assistant 对象(普通多继承)
+---------------------+
| Student 的 Person | 数据冗余:Person 出现两次
| _name |
+---------------------+
| Teacher 的 Person | 第二份 _name
| _name |
+---------------------+
| Assistant 部分 |
| _majorCourse |
+---------------------+
a._name = "peter"; // error C2385: 对 _name 的访问不明确
a.Student::_name = "小李"; // 解决二义性,但数据冗余还在
a.Teacher::_name = "老李";
这里两个坑,一个都跑不掉:数据冗余 (Person 在对象里出现两次,sizeof 会变大)和二义性 (_name 到底指哪个 Person,编译器报 error C2385)。显式指定 Student::_name/Teacher::_name 能解决二义,但数据冗余解决不了。所以老师课上反复说:别去玩菱形继承。Java 干脆不支持多继承,就是为了绕开这些。
7.2 虚继承
虚继承是解决菱形继承的手段。Student 和 Teacher 改为 virtual public Person 继承,Person 在 Assistant 对象里就只存一份了。
cpp
class Person
{
public:
Person(const char* name) : _name(name) {}
string _name;
};
class Student : virtual public Person
{
public:
Student(const char* name, int num = 0)
: Person(name), _num(num) {}
protected:
int _num; // 学号
};
class Teacher : virtual public Person
{
public:
Teacher(const char* name, int id = 1)
: Person(name), _id(id) {}
protected:
int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
public:
Assistant(const char* name1, const char* name2, const char* name3)
: Student(name1), Teacher(name2), Person(name3) {}
protected:
string _majorCourse;
};
Assistant 对象(虚继承)
+---------------------+
| Student 部分 |
| _num (学号) |
| [虚基类指针] | --> 指向共享的 Person
+---------------------+
| Teacher 部分 |
| _id (职工编号) |
| [虚基类指针] | --> 指向共享的 Person
+---------------------+
| Assistant 部分 |
| _majorCourse |
+---------------------+
| Person (唯一一份) |
| _name |
+---------------------+
虚继承下 _name 不再有二义性,数据冗余也没了,a._name = "peter" 能直接写。代价是内存里多了几个虚基类指针 ,实现变复杂,还要靠虚基类指针去定位那份唯一的 Person。
这里有个初始化陷阱 :Assistant("张三","李四","王五") 里,Student、Teacher 传给 Person 的张三、李四都无效,只有最派生类(Assistant)初始化列表里 Person(name3) 的王五真正生效。因为虚基类只构造一次,由最派生类负责初始化。这也是虚继承最容易踩的坑。
C++ 的多继承本身实现就很复杂,性能也有损失,虚继承更是如此。所以实际开发原则是:可设计出多继承,但不建议设计菱形继承,能用组合就用组合。
7.3 多继承中指针偏移问题
最后一道题,看这个程序。
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;
Base2* p2 = &d;
Derive* p3 = &d;
return 0;
}
问 p1、p2、p3 的关系,正确的是( ) A. p1==p2==p3 B. p1<p2<p3 C. p1==p3!=p2 D. p1!=p2!=p3
class Derive : public Base1, public Base2 // Base1 在前
+--------------------+
| Base1 : _b1 | 偏移 0
+--------------------+
| Base2 : _b2 | 偏移 4
+--------------------+
| Derive : _d | 偏移 8
+--------------------+
Derive d; Base1* p1 = &d; Base2* p2 = &d; Derive* p3 = &d;
p1 = 0x00 (对象首地址,Base1 在偏移 0)
p3 = 0x00 (&d 本身就是这个首地址,所以 p3 == p1)
p2 = 0x04 (Base2 在偏移 4,地址更大)
所以 p1 == p3 != p2 -> 答案 C
答案是 C。 这里最值钱的不是背下"p1==p3",而是理解为什么 :多继承对象里,基类子对象按声明顺序排列,第一个写的基类位于偏移 0,指向它的指针和指向派生类对象本身的指针同址。后一个基类子对象排在后头,地址更大。
这道题真正常考的反例 :如果声明顺序反了,写成 class Derive : public Base2, public Base1(Base2 在前),那结果就变成 p2==p3!=p1。所以别死记"p1 等于 p3",要看哪个基类写在最前面。面试官换一换声明顺序,答案就翻过来,考验的是你有没有真懂对象布局。
7.4 IO 库里的菱形虚拟继承
标准库的 iostream 就是个典型的菱形虚拟继承。basic_ios 作为虚基类,basic_istream 和 basic_ostream 都虚继承它,iostream 再同时继承这两个,cout/cin 就是靠这套才只含一份 basic_ios,避免了冗余和二义性。这只是个成熟案例,理解菱形虚拟继承怎么解决冗余,比记住具体类名更有用。
八、继承和组合
学完继承,另一个经常被拿来比较的是组合。区别很清晰:
- 继承是 is-a 关系 :
Student是一个Person,学生是一种人。 - 组合是 has-a 关系 :
Car有一个Tire,车有一个轮胎。
cpp
// 组合:Car 里有 Tire 成员
class Tire
{
protected:
string brand = "米其林";
};
class Car
{
protected:
Tire _t1; // 车里有轮胎
Tire _t2;
};
优先用组合,而不是继承。 继承会破坏封装:基类内部细节对派生类是可见的,基类一改,派生类受很大影响,耦合度高。组合的对象里有黑盒一样的成员,内部不可见,耦合度低,代码维护性好。那什么时候用继承?类之间确实符合 is-a 关系(比如轿车和车),或者需要多态 ,才用继承;否则优先组合。这也是为什么容器适配器(stack、queue)明明可以继承容器,STL 偏用组合。组合更稳、更黑盒。