目录
前言
大家好鸭~欢迎莅临小鸥的博客,今天我们将学习cpp继承的相关内容。
我们知道,面向对象有三大特性:封装、继承和多态。对于封装,类就是一封装,所以之前我们学习的大部分内容其实都和封装有着很多联系。而今天我们将开始学习另一个大特性:继承
本篇将较为全面的介绍继承及其相关特性内容,结合代码学习,内容较多希望大家多多支持~
继承概念及定义
继承是面向对象编程的重要的组成部分,它是提高代码复用的重要手段,继承允许我们++在一个类的基础上(基类/父类),对其进行扩展,增加属性(成员变量)和方法(成员函数),从而产生新的类++的同时,不用再实现其中相同的部分,而这个新的类称为子类(派生类)。
假设我们有两个类Student和Teacher
cpp
class Student{
public:
void identity() {
cout << "Student identity():" << _name << endl;
}
//学习
void study() {
cout << "Student study():" << _name << endl;
}
protected:
string _name = "李华";
string _address;
string _tel;
int _age;
int _stuId;//学号
};
class Teacher{
public:
void identity() {
cout << "Teacher identity():" << _name << endl;
}
//授课
void teach() {
cout << "Teacher teach():" << _name << endl;
}
protected:
string _name = "李华";
string _address;
string _tel;
int _age;
int _teaId;//工号
string title;//职称
};
我们可以发现,对于这两个类,一大部分的属性和方法都是相同的,它们都拥有名字、地址、电话、年龄的属性,都有identity()方法,这样分开实现会导致代码冗余。
所以我们此时就可以使用继承来解决这个问题,使用一个Person类来实现相同的部分,再用Student和Teacher来继承Person类,这样就可以避免这个问题了。
cpp
class Person {
public:
void identity() {
cout << "Person identity():" << _name << endl;
}
protected:
string _name = "李华";
string _address;
string _tel;
int _age;
};
class Student : public Person {
public:
//学习
void study() {
cout << "Student study():" << _name << endl;
}
protected:
int _stuId;//学号
};
class Teacher : public Person {
public:
//授课
void teach() {
cout << "Teacher teach():" << _name << endl;
}
protected:
int _teaId;//工号
string title;//职称
};
继承之后,Student和Teacher再分别根据自身需要,再添加独特的属性和方法即可。
继承定义方式
从上文可以看出Person是基类,也称父类。Student和Teacher为派生类,也称子类
创建一个类时,用" : "跟上继承方式(继承方式可以不声明,此时class默认private,struct默认public)和继承的基类,就创建出一个派生类,派生类会继承基类的属性和方法



继承的访问权限

-
基类中的private成员,在派生类中不论使用哪种继承方式都不可见(继承了,但不论在派生类的内外部都都不能访问);
-
当想要派生类能够使用基类属性的同时,又防止其被派生类在外部使用,就可以在基类中使用protected。由此也可以看出,保护成员限定符就是因为继承出现的。

-
除开基类中的private成员在派生类中都不可见外,基类中其他的成员,在派生类中的访问权限 = Min(该成员在基类中的访问权限,继承方式),即取小的权限,其中public > protected > private.
-
对于class关键字的默认继承方式为private,对于struct关键字的默认继承方式为public,但继承方式一般都显示标明为好。

-
由于继承的目的就是为了代码的复用,让派生类能不重复相同代码的同时,可以控制这些成员,所以实际使用中,绝大多数情景下都会使用public继承方式。
继承模板类
以此前我们模拟实现的stack为例:
我们之前是通过在stack内部放置一个deque,再通过复用deque的接口功能来实现stack的。
而通过继承,也可以复用deque等容器的接口来模拟出stack
cpp
#define CONTAINER std::deque // vector list
template<class T>
class stack : public CONTAINER<T> {
public:
void push(const T& x) {
//push_back(x);
CONTAINER<T>::push_back(x);
}
void pop() {
CONTAINER<T>::pop_back();
}
T& top() {
return CONTAINER<T>::back();
}
size_t size() {
return CONTAINER<T>::size();
}
bool empty() {
return CONTAINER<T>::empty();
}
};
需要注意的是:
继承的类是模板时,使用其中的成员需要指定类域,这是因为模板的按需实例化:
当我们使用上述stack来定义一个对象时,它会自动调用默认构造函数(会同时调用基类的默认构造),此时不论对于stack还是其模板基类CONTAINER<T>都只有构造函数被实例化了出来
此时调用push(),会实例化stack的push()成员,但若push()中的push_back()没有指定类域(如下图),就会导致编译器会先在stack的作用域中查找push_back(),找不到,再去基类中查找,但此时基类为一个模板类,其中的push_abck()此前并未被实例化过,从而导致编译器找不到成员push_back(),致使编译出错。
而我们指定类域CONTAINER<T>::就相当于告诉编译器,要在CONTAINER<T>所代表的类域(此处即为deque<T>)中去获取指定成员。
基类与派生类间的转换(赋值兼容规则)
-
public继承的派生类对象可以赋值给基类的指针和基类的引用。这种现象有一个形象的叫法叫做切片或者切割。指的就是,将派生类中属于基类的那部分切出来,基类指针或者引用指向的就是派生类中被切出来的属于基类的这部分。
-
基类对象反之不能赋值给派生类对象。因为派生类多出来的属性无法对应到基类中。
-
基类的指针或者引用可以通过强制类型转换,来使其被赋值给派生类的指针或者引用(因为基类的指针和引用本身就可以指向派生类)。但需要保证基类的指针和引用是指向派生类对象时才安全。(此处牵扯后面知识点(虚函数和多态),暂做了解即可)
cpp
class Person {
protected:
//虚函数
virtual void func()
{}
string _name;
string _sex;
int _age;
};
class Student : public Person {
public:
int _stuId;
};
void test2() {
Student s;
Person p = s;//此处实际上为调用基类的拷贝构造
Person* pp = &s;
Person& rp = s;//派生类对象给基类时,不产生临时变量
//相当于此处pp和rp指向的就是s中属于基类的那一部分。若此时通过pp和rp来修改,修改的其实就是s的内容
int i = 1;
double d = i;//隐式类型转换产生临时变量
const double& rd = i;//临时变量具有常性,此处只能使用const修饰的引用来指向它
//基类对象不能赋值给派生类对象
//s = p;//强转也不行
//但指针和引用可以(强转),但要保证此时pp确实指向Student的对象。
Student* ps = (Student*)pp;
Student& rs = (Student&)rp;
Student* ps = dynamic_cast<Student*>(pp);//需要是多态类型
}
简单理解:
派生类的指针或者引用赋值给基类时,就是让基类的指针或引用,指向了这个派生类对象中,属于基类的那一部分。
继承中的作用域
相关规则:
- 在继承体系总,基类和派生类的作用域是相互独立的;
- 当派生类中存在与基类同名的成员时,派生类的成员将会屏蔽来自基类的同名成员,这种情况叫做隐藏(在派生类中使用基类::基类成员 的方式可以显示访问);
- 隐藏的情况只需要成员名相同即可构成,即同名成员函数就算参数类型不同,也是构成隐藏关系;
cpp
class A {
public:
void print() {
cout << "print A" << endl;
}
protected:
int a = 0;
int b = 1;
};
class B : public A {
public:
//隐藏了A中的print()
void print(int a = 0) {
cout << "print B" << endl;
}
//隐藏了A中的a
int a = 2;
};
int main() {
B().A::print();
B().print();
}
派生类的默认成员函数
在类和对象的学习过程中我们知道,常见的默认成员函数有四个:默认构造,拷贝构造,赋值重载,析构。那么在派生类中,这几个默认成员函数会有什么行为呢?
我们先定义一个基类Person
cpp
class Person {
public:
//默认构造
Person(const string& name = "Lina")
:_name(name)
{
cout << "Person()" << endl;
}
//拷贝构造
Person(const Person& p)
:_name(p._name)
{
cout << "Person(const Person& p)" << endl;
}
//赋值重载
Person& operator=(const Person& p) {
cout << "Person& operator=(const Person& p)" << endl;
if (&p != this) {
_name = p._name;
}
return *this;
}
//析构
~Person() {
cout << "~Person()" << endl;
}
protected:
string _name;
};
在此基础上我们来一步步分析并实现派生类Student的四个默认成员函数
默认构造and普通构造
首先我们回顾一下,类和对象中,默认构造相关知识点:
- 不传参就可以调用的构造函数,都称为默认构造函数;
- 对于内置类型,编译器自动生成的默认构造对其初始化情况不确定(取决于编译器),可能是随机值(VS2022);
- 对于自定义类型,编译器自动生成的默认构造会去调用这个自定义类型的默认构造函数。
当我们不在Student中显示实现构造,让编译器自动生成时:
cpp
class Student : public Person {
protected:
int _ID;
string _address = "xxx市xxx区";//地址
};
此时创建一个Student对象,运行结果为:


由此我们可以推测,编译器自动生成的Student默认构造,会自动调用基类Person的默认构造函数,就像自定义类型一样,会去调用默认构造;
所以我们在自己实现构造时,也要和编译器一样,需要对来自基类的部分看作一个整体来初始化。也就是说,我们显示实现的派生类构造函数也需要调用基类的构造函数。
cpp
//默认构造and普通构造
//基类没有默认构造即Person(const string& name)不给缺省值"Lina"时,
//或者需要在构造时对基类属性进行修改时:
Student(const string& name,int id,const string& addr)
:Person(name)
,_ID(id)
,_address(addr)
{
cout << "Student() " << endl;
}
//基类有默认构造,或者构造时不需要对基类属性进行修改时:
Student(int id,const string& addr)
:_ID(id)
,_address(addr)
{
cout << "Student() " << endl;
}
对于我们自己实现的构造函数来说:
- 当基类具有默认构造时,可以不用显示调用,++编译器会自动去调用基类的默认构造++。
- 但当基类没有默认构造时,则必须在派生类的初始化列表中++显示调用基类的构造函数++。
- 而在派生类的初始化列表中显示调用基类构造函数的方法,则是在派生类的构造函数参数列表中,加入基类构造需要的参数,再在初始化列表中像函数调用一样,将参数传给基类构造进行初始化。
简单理解:
就是把++派生类中属于基类的那一部分,当作一个"自定义类型"来看待++,这样就很好理解了,因为自定义类型在类中做属性时,即便没有在初始化列表显示初始化,编译器也是会自动调用该自定义类型的默认构造的。
总结:
- 派生类的普通构造函数,必须调用基类的构造函数来初始化属于基类的那部分成员,若基类有默认构造,则可以编译器自动调用;若没有,则必须在派生类构造函数的初始化列表中显示调用。
- 构造顺序:先调用基类的构造,再调用派生类的构造。
拷贝构造
由上面普通构造的实现过程即相关知识点我们就可以推出:实际上派生类的默认成员函数,直接用编译器生成的默认构造函数就足够用了,这与类与对象中的知识相同:++对于没有动态资源空间需要管理的类型(即需要深拷贝的资源),默认成员函数不太需要我们自己显示实现 。++
但不代表我们不需要知道原理~
原理类似,出了派生类自己的属性自己构造,基类则调用基类的拷贝构造:
cpp
//拷贝构造
//实际上Student的拷贝构造编译器默认生成的就够用了
//只有当有需要深拷贝的动态空间资源时,才需要我们自主实现
Student(const Student& s)
:Person(s)
,_ID(s._ID)
,_address(s._address)
{
cout << "Student(const Student& s)" << endl;
}
这里需要注意:
基类的拷贝构造直接被我传入了派生类的类型对象,这就是基类与派生类间的转换,此处就是将派生类对象的引用给到了基类的引用。
当我们++显示实现拷贝构造时,必须要显示调用编译器的拷贝构造++,因为拷贝构造不是默认构造函数,编译器在你显示实现之后,就不会再做关于拷贝构造的任何默认行为。
如果此时你不显示调用基类的拷贝构造,将导致拷贝构造结果出错:
总结:派生类的拷贝构造函数,显示实现时必须手动调用基类的拷贝构造来初始化属于基类的那部分成员。
赋值重载
赋值重载和拷贝构造类似,显示实现时,必须手动调用基类的赋值重载来修改属于基类的那部分属性
cpp
//赋值重载
//实际上编译器默认生成的也够用了
Student& operator=(const Student& s) {
cout << "Student& operator=(const Student& s)" << endl;
if (&s != this) {
//注意构成隐藏,要指定类域
Person::operator=(s);
_ID = s._ID;
_address = s._address;
}
return *this;
}
注意点:
- 调用基类的赋值重载时,由于函数名相同(都是operator=)构成了隐藏,所以要指定类域来显示调用,不然就会调用到Student自己的赋值重载,导致无限递归栈溢出;
- 此处调用基类的赋值重载时也使用到了,基类与派生类之间的转换(赋值兼容)。
总结:
派生类的operator=显示实现时,必须调用基类的operator=,而两者构成隐藏,所以基类的operator=在调用时必须指定基类作用域来调用。
析构函数
相同的原理,结合类和对象中析构函数的知识,派生类的析构就很好实现了:
- 内置类型不需要特殊处理;
- 自定义对象会自动调用其自身的析构。
- 调用基类的析构来处理属于基类的那部分属性;
但此时却出问题了:

很明显,我们调用基类的析构函数失败了,字符'~'被识别为了位运算符'~',这是为什么呢?
这是因为:
编译器为了兼容其他的一些语法的使用场景(多态中会讲到),会将所有析构函数的函数名都进行特殊处理,处理为++destructor++;
所以此处在编译器看来,~Student()和~Person()的函数名是相同的,这就又构成了隐藏。所以我们需要指定类域来调用基类的析构:

此时虽然不报错,但实际上还是有问题:

可以看到,我们析构次数和对象个数对不上,这在此处没有报错,这是因为我们的例子中没有动态空间的申请和释放,但实际上这样肯定是不行的。可这是为什么呢?
出错原因:
经推断和调试我们发现:

每次~Student析构结束后,又会自动跳转到基类的析构函数,导致一个Student类型的对象被析构时,就会调用两次~Person()。
由此++我们推断编译器会在派生类的析构结束后,再自动调用基类的析构函数++。所以我们Student的析构函数即便显示实现,也实际上啥也不用写:
cpp
//析构
~Student() {
cout << "~Student() " << endl;
//内置类型无需处理
//自定义类型自动调用其自身析构
//基类属性调用基类析构
}
总结:
- 编译器为了兼容其他某些语法场景,对析构函数名特殊处理为了destructor,所以此时在编译器看来,基类和派生类析构函数构成隐藏关系。
- 派生类的析构函数会在结束时,再自动调用基类的析构函数来清理属于基类的那部分成员;
- 析构顺序:先调用派生类的析构,再调用基类的析构。
默认成员知识点总结
- 派生类的普通构造函数,必须调用基类的构造函数来初始化属于基类的那部分成员,若基类有默认构造,则可以编译器自动调用;若没有,则必须在派生类构造函数的初始化列表中显示调用。
- 派生类的拷贝构造函数,显示实现时必须手动调用基类的拷贝构造来初始化属于基类的那部分成员。
- **派生类的operator=**显示实现时,必须调用基类的operator=,而两者构成隐藏,所以基类的operator=在调用时必须指定基类作用域来调用。
- 派生类的析构函数会在结束时,再自动调用基类的析构函数来清理属于基类的那部分成员;
- 构造顺序:先调用基类的构造,再调用派生类的构造。
- 析构顺序:先调用派生类的析构,再调用基类的析构。
- 编译器为了兼容其他某些语法场景,对析构函数名特殊处理为了统一了destructor(),所以此时在编译器看来,基类和派生类析构函数构成隐藏关系。
继承与友元
继承中的友元关系不可继承,基类的友元,对派生类不是友元。
cpp
class Student;//此时需要在前面声明一下派生类,因为基类友元函数Print()需要使用到
class Person {
public:
friend void Print(const Person& p, const Student& s);
protected:
string _name;
};
class Student : public Person {
public:
//友元关系不继承,需要单独声明
friend void Print(const Person& p, const Student& s);
protected:
int _ID = 000;
string _address = "xxx市xxx区";//地址
};
void Print(const Person& p, const Student& s) {
cout << p._name << endl;
cout << s._ID << endl;
}
int main(){
Student s;
Person p;
//需要在派生类Student中也声明友元,不然会报错
Print(p,s);
return 0;
}

继承与静态成员
基类中的普通成员,继承到派生类之后,就生成了独属于派生类的一份;
基类中定义的static静态成员,对于整个继承体系中来说都只有这一个静态成员,后续所有的派生类和基类都共用这一个静态成员。
cpp
class Person {
public:
string _name;
static int _count;
};
int Person::_count = 0;
class Student : public Person {
protected:
int _stuId;
};
int main() {
Student s;
Person p;
//由结果可以看出,非静态成员变量地址不同,说明基类对象和派生类对象各有一份
cout << &p._name << endl;
cout << &s._name << endl;
//此处打印地址相同,说明基类对象和派生类对象共用了同一个_count
cout << &p._count << endl;
cout << &s._count << endl;
//公有的静态成员还可以在外部使用类域来访问
cout << Person::_count << endl;
cout << Student::_count << endl;
return 0;
}
多继承及其菱形继承问题
继承模型
-
单继承:一个派生类只有一个直接基类时称这种继承关系为单继承;

上文中演示的都是单继承
-
多继承:一个派生类有两个以上的直接基类时,称这种继承关系为多继承;
-
菱形继承:菱形继承是多继承的一种特殊情况:
当一个多继承派生类的多个基类又继承自同一个类时,称为菱形继承;
菱形继承有**++数据冗余和二义性++**的问题,在下图中的Assistant类中,将包含有两份来自Person的成员。
菱形继承问题演示:
cpp
class Person {
public:
string _name;//姓名
};
class Student : public Person {
protected:
int _stuId;//学号
};
class Teacher : public Person {
protected:
int _teaId;//职工号
};
class Assistant : public Student, public Teacher {
protected:
string _Course;//课程
};
int main() {
Assistant a;
//编译报错,_name不明确,a中由于菱形继承有两个_name
a._name = "perter";
//可以显示指明类域来解决二义性,但数据冗余无法解决(此处不需要两个名字)
a.Student::_name = "stuName";
a.Teacher::_name = "teaName";
return 0;
}


调试中的监视窗口就可以帮助我们看到,继承自两个基类中各有一份Person的内容,指定类域修改的结果也不同。
由此问题我们引出虚继承的概念:
虚继承
有多继承就有菱形继承,而++菱形虚拟继承可以解决数据冗余和二义性的问题++ ,但底层实现较为复杂,性能也有一定损失,这里只做使用的介绍。我们在实践中也最好不要设计出菱形继承。
cpp
class Person {
public:
string _name;//姓名
};
//在继承会产生二义性和数据冗余的那个类是Person,所以在它的派生类继承它时,加上关键字virtual
class Student : virtual public Person {
protected:
int _stuId;//学号
};
class Teacher : virtual public Person {
protected:
int _teaId;//职工号
};
class Assistant : public Student, public Teacher {
protected:
string _Course;//课程
};
int main() {
Assistant a;
//使用虚继承后,a中就只包含有一个_name成员
a._name = "perter";
//还是可以指定类域调用,但修改的是同一个_name成员
a.Student::_name = "stuName";
a.Teacher::_name = "teaName";
return 0;
}

可以看到使用菱形虚拟继承后,a对象可以直接修改_name,因为此时已经没有二义性,而后续即便指定类域修改,实际上修改的也是a中的同一个成员_name。需要注意的是,监视窗口显示的并不是底层的实际内容,此处是为了便于观察,才看起来像是有三份来自Person的_name,实际上只有一份。
多继承指针偏移问题
cpp
//问题:以下三个指针的关系正确的是?
//A:p1 == p2 == p3
//B:p1 < p2 < p3
//C:p1 == p3 != p2
//D:p1 != p2 != p3
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;
}
正确答案为C,解析:
由于Derive类多继承了Base1和Base2,p1p2分别为其基类指针,所以可以将d对象的地址赋值给p1p2,而p1则会指向d中继承Base1的那部分地址,p2则会指向d中继承Base2的那部分地址;而Derive先继承Base1,再继承Base2,所以有关系图:

结合继承中构造相关的知识,派生类会先调用基类的构造函数,再执行自己的,所以*++多继承时先构造先继承的的基类,再构造后继承的基类,再构造自己。++*
继承和组合
-
public继承是一种is-a(是)关系。指每个派生类对象都是一个基类对象;
-
组合是一种has-a(拥有)关系。即B组合A,每个B类对象中都有一个A类对象(比如stack中以一个vector容器来实现);
-
继承允许我们根据基类的实现来定义扩展派生类的实现,这种通过生成派生类的代码复用方式被称为白箱复用(white-box reuse),这是因为继承中对于基类的内部实现是可见,且可以修改的。
-
对象组合是类继承之外的另一种代码复用选择。新的更复杂的功能通过组装或者组合其他类对象来获得。组合要求被组合的对象具有良好的定义的接口,这种代码复用的方式被称为黑箱复用(black-box reuse)
-
"白箱/盒","黑箱/盒"都是相对可见性而言的:
- 继承中,基类的内部实现细节对派生类可见且,所以称为"白箱"复用。继承还一定程度上破坏了基类的封装性,基类的改变对于派生类的影响也较大。所以**++继承中派生类和基类间的依赖关系很强,代码耦合度高++**。
- 组合中,复用其他类型时,对其复用的对象内部是不可见的,所以称为"黑箱"复用。组合类之间没有很强的依赖关系,即便被组合对象内部有所改变,只要对外提供的接口调用方式不变,就不会有影响,所以++组合的代码耦合度低++。
-
在实际运用中要优先使用组合。这样代码的耦合度低,代码可维护性高。不过并不是绝对的,要根据实际需要来使用,并且如果要使用多态,那么就必须使用继承。
cpp
//轮胎
class Tire {
protected:
int _size;//尺寸z
};
//车
class Car {
protected:
string _color = "白色";//颜色
string _CarID = "川A12345";//车牌号
//此时Tire与Car就更符合has-a关系,即Car中"有"四个Tire对象
Tire _Rfront;//右前轮
Tire _Lfront;//左前轮
Tire _Rback;//右后轮
Tire _Lback;//右前轮
};
//宝马
class BMW : public Car {
public:
//此时BMW与Car就更符合is-a,即宝马"是"一种车,在Car的基础上有自己的特点
void Drive() {
cout << "操控性高,手感好" << endl;
}
};
//奔驰
class Benz : public Car {
public:
//同理
void Drive() {
cout << "时速快,开着爽" << endl;
}
};
后记
感谢大家阅读本篇博客,如有疑问可在评论区活着私信指出,我们下篇再见~

