【C++】第11章:继承
文章目录
一、继承的概念
1、继承的初识
在写有相同功能的同名函数的时候,C++语法为了简化代码就造出了「函数重载」「函数模板」等语法。但是如果两个类有很多相似的地方,也有不同的地方,那么C++语法为了简化代码也有针对性的语法解决这个问题,就是今天要说的**「继承」**。
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特 性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构, 体现了由简单到复杂的认知过程。
我们下来看看继承到底长啥样?
#include <iostream>
#include <string>
using namespace std;
class Student // 基类
{
public:
void print() {
cout << "I am a student !" << endl;
}
protected:
string _name;
int _age;
};
class Junior : public Student // 公有继承Student类
{
protected:
int _junId;
};
class Senior : public Student // 公有继承Student类
{
protected:
int _senId;
};
int main()
{
// 派生类可以使用基类的成语函数
// 并且拥有基类的成员变量
Junior junior_stu;
junior_stu.print(); // 输出I am a student !
Senior senior_stu;
senior_stu.print(); // 输出I am a student !
return 0;
}
讲解:
继承之后父类Student
类中的成员(包含成员函数和成员变量)都会成为子类的一部分。这就体现出了使用继承可以复用代码并简化代码的作用。
上面在main
函数中,可以看出子类可以使用父类的成员函数。
下面这幅图可以说明子类中包含着父类的成员变量。
2、继承的定义方式
2.1.定义继承的格式
2.2.继承方式和访问限定符的对比
2.3.派生类中成员变量在继承后的访问方式的变化
类成员 \ 继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类的public成员 | (派生类中)public成员 | (派生类中)protected成员 | (派生类中)private成员 |
基类的protected成员 | (派生类中)protected成员 | (派生类中)protected成员 | (派生类中)private成员 |
基类的private成员 | (派生类中)不可见 | (派生类中)不可见 | (派生类中)不可见 |
3、总结
-
基类中的
private
成员在继承之后都不可见,这里不可见的意思不是在派生类中没有基类的成员变量,只是在语法上限制了该成员在继承后不能访问了。 -
总结访问方式变化的表格,可以发现
- 基类中**
private
成员**,在继承过后都不可见 - 基类中
pritected
成员和public
成员,在继承过后去继承方式和基类成员中限制更小的一个(public > protected > private
)。比如基类中的public
成员在protected
继承后,变成了protected
成员,是因为protected < public
。(可以是这样方便记忆)
- 基类中**
-
如果想要一个类中的成员变量可以在派生类中访问,而不想要在类外的对象中访问,就可以使用
protected
成员,这就是protected
成员的价值。 -
int main() { // Junior类public继承Student类 Junior junior_stu; junior_stu.print(); // print()在基类中是public,所以可以在类外访问 junior_stu._age; // _age在基类中是protected,所以可以在类中访问,不可在类外访问 return 0; }
-
一般情况下需要显式的写出类的继承方式,但是如果不写继承方式的话,
class
定义的类默认是private
继承,而struct
定义的类默认是public
继承。class Junior : Student // 默认私有,不建议这样写 { protected: int _junId; }; struct Senior : Student // 默认公有,不建议这样写 { protected: int _senId; };
-
一般情况下,继承方式都是
public
继承。
二、基类和派生类对象的赋值转换(切片)
了解完了继承的概念。下面我们来了解一下,基类对象和派生类对象之间是否可以互相赋值的问题。
需要注意的概念:
1.派生类的对象可以赋值给基类的对象,而基类的对象不可以赋值给派生类对象,这种现象就好像是将派生类对象中基类对象的部分切割下来,所以叫做「切片」。
#include <iostream>
#include <string>
using namespace std;
class Student // 基类
{
protected:
string _name;
int _age;
};
class Junior : public Student // 公有继承Student类
{
protected:
int _junId;
};
int main()
{
Junior j;
Student s = j; // 派生类对象赋值给 基类
// Junior tmpJu = s; //基类不可以赋值给派生类
return 0;
}
2.派生类的地址可以赋值给基类的指针,派生类的对象也可以赋值给基类的引用。基类指针不可以直接指向派生类对象的指针,但是基类的指针可以通过强制转换成派生类对象的指针,然后赋值给派生类指针,基类对象也不能引用派生类的对象
#include <iostream>
#include <string>
using namespace std;
class Student // 基类
{
public:
void print() {
cout << "I am a student !" << endl;
}
protected:
string _name;
int _age;
};
class Junior : public Student // 公有继承Student类
{
protected:
int _junId;
};
int main()
{
Junior j;
Student* ps = &j; // 基类对象指针可以指向派生类对象
Student& rs = j; // 基类对象引用可以引用派生类对象
Junior* tmpJu = (Junior*)ps; // 在基类指针强转之后可以赋值给派生类指针
return 0;
}
三、继承中的作用域
需要注意的概念:
1.在继承体系中基类和派生类都有独立的作用域。
2.子类和父类中有同名函数,子类成员将屏蔽父类的同名成员的访问,这种现象叫做「隐藏」,也叫作「重定向」。
关于隐藏要注意几点:
- 隐藏和函数重载要区别开来,函数重载是两个同名函数在同一作用域下,有不同的函数参数列表,那么这两个同名函数之间构成「重载」。而隐藏是两个同名的函数(对函数参数列表没有要求),在不同的作用域下,那么这两个同名函数构成「隐藏」。
- 两个同名的成员变量之间也会构成隐藏,可以使用
::
直接显式的访问成员变量即可。(一般不要出现同名的成员变量,否则继承的意义就不大了)。
#include <iostream>
#include <string>
using namespace std;
class Student // 基类
{
public:
void print() {
cout << "I am a student !" << endl;
}
string _name;
int _age = 1;
};
class Junior : public Student // 公有继承Student类
{
public:
void print() {
cout << "I am a Junior !" << endl;
}
int _junId;
int _age = 2;
};
int main()
{
Junior j;
j.print(); // 隐藏了基类的print()函数,输出 I am a junior !
cout << j._age << endl; // 隐藏了基类的_age,输出2
j.Student::print(); // 显示调用,输出 I am a student !
cout << j.Student::_age << endl; // 显示调用,输出 1
return 0;
}
四、派生类中的默认成员函数
了解完了派生类对象的使用,下面我们深入一点,了解派生类对象的内部的默认的成员函数。
每一个类都有6大默认成员函数,也就是类会在创建对象的时候,自动的生成6个默认的成员函数。
1.构造函数:派生类的构造函数必须调用基类的构造函数来完成派生类中基类成员变量的部分。如果基类中没有默认的构造函数可以调用,则必须在派生类的构造函数的初始化列表中显式的调用。
2.拷贝构造函数:类似构造函数,派生类的拷贝构造函数必须调用基类的拷贝构造函数完成派生类中基类对象的部分的赋值。
3.赋值运算符的重载:类似构造函数,必须要调用基类的operator=
来完成派生类对象中基类对象部分的赋值。
4.析构函数的设计就很特殊了。
- 在继承的关系下,子类和父类的析构函数构成隐藏,这是因为在底层这两个不同名的函数都会被转换成为叫做
destory()
的函数,所以会构成隐藏,所以如果想要调用父类的析构函数就要用::
来显式的调用父类的析构函数。 - 但是在类中的析构顺序,一般在构造的时候都是先构造基类在构造派生类,根据先进后出的概念,所以在析构的时候都是先析构子类,然后再析构基类。所以默认在默认的情况下,子类调用完析构函数之后,会自动的去调用父类的析构函数。
#include <iostream>
#include <string>
using namespace std;
class Student
{
public:
Student(string name, int age):
_name(name), _age(age) {}
Student(const Student& s):
_name(s._name), _age(s._age) {}
Student& operator= (const Student& s) {
if (this != &s) {
_name = s._name;
_age = s._age;
}
return *this;
}
~Student() {}
protected:
string _name;
int _age;
};
class Junior : public Student
{
public:
Junior(string name, int age, int id):
Student(name, age),
_id(id) {}
Junior(const Junior& j):
Student(j), // 派生类对象赋值给基类对象,进行切片
_id(j._id) {}
Junior& operator= (const Junior& j) {
if (this != &j) {
// 这里要给派生类中的基类部分赋值,所以需要显式的调用父类的operator=
(*this).Student::operator=(j);
_id = j._id;
}
return *this;
}
~Junior() {} // 调用完派生类的析构函数之后,自动调用基类的析构函数
void print() {
cout << _name << ' ' << _age << ' ' << _id << endl;
}
protected:
int _id;
};
五、在继承中友元
友元关系不能继承,也就是说父类中的友元函数不能访问子类中的私有或者保护成员。
class Junior;
class Student
{
public:
friend void Display(const Student &s, const Junior &j);
protected:
string _name; // 姓名
};
class Junior : public Student
{
public:
// friend void Display(const Student &s, const Junior &j);
protected:
int _stuNum; // 学号
};
void Display(const Student& s, const Junior& j)
{
cout << s._name << endl;
cout << j._stuNum << endl;
}
int main()
{
Student s;
Junior j;
// 如果只有父类中有友元函数,则不能访问到Junior类对象中的成员
Display(s, j); // 不能调用
return 0;
}
六、在继承中的静态成员
基类中定义了static
静态成员,则整个继承体系中只有这一个今天成员变量,也就是无论派生出多少个子类,都只有一个static
成员的实例。
class Student
{
public:
Student() {
++ count ;
}
static int count;
};
int Student::count = 0;
class Junior : public Student
{
protected:
int _id;
};
int main()
{
Student s1;
Student s2;
Junior j1;
cout << "创建了: " << Student::count << " 个对象"<< endl; // 输出:创建了3个对象
return 0;
}
七、继承方式
根据父类的多少,将继承分为单继承和多继承。
1.单继承
一个子类只有一个直接父类称这种继承叫做「单继承」。
2.多继承
一个子类有两个及以上的直接父类称这种继承叫做「多继承」。
2.1.多进程的“祸害”菱形继承
在多继承中有一种特殊的情况叫做「菱形继承」。
class Student
{
public:
string _name; // 姓名
};
class Junior : public Student
{
protected:
int _num; // 学号
};
class Senior : public Student
{
protected:
int _id; // 编号
};
class Graduate : public Junior, public Senior
{
protected:
string course; // 课程
};
菱形继承的问题:从对象成员模型构造,可以看出菱形继承有「数据冗余」和「二义性」的问题。
Graduate
对象是多继承与Junior
和Senior
类的,而Junior
和Senior
都继承的了Student
类,所以在Junior
和Senior
类中都有_name
成员,所以在Graduate
对象中会有两份Student
对象的成员。
因为存在了两份Student
对象的成员,所以在使用Graduate
对象中的_name
成员的时候,会出现歧义的问题,到底是使用Junior
中成员还是使用Senior
中的成员?所以这就是二义性的问题。
这个问题其实可以解决掉,我们可以使用域作用符显示的调用成员:
Graduate person;
// 使用::显示的使用对象的成员
person.Junior::_name = "张三";
person.Senior::_name = "张三";
但是对于存储了两份的对象成员,造成的数据冗余就没有办法解决了。
但是对于这种继承方式,C++语法中使用了虚拟继承来专门解决这个问题。
2.2.解决菱形继承的方法-虚拟继承
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Junior和Senior的继承 Student时使用虚拟继承,即可解决问题。
class Student
{
public:
string _name; // 姓名
};
class Junior : virtual public Student // 虚继承
{
protected:
int _num; // 学号
};
class Senior : virtual public Student // 虚继承
{
protected:
int _id; // 编号
};
class Graduate : public Junior, public Senior
{
protected:
string course; // 课程
};
int main()
{
Graduate person;
person._name = "张三";
// 解决了二义性的问题
cout << person._name << endl; // 打印张三
// 解决了数据冗余的问题
cout << &person._name << endl; // 0x61fdf0
cout << &person.Senior::_name << endl;// 0x61fdf0
cout << &person.Junior::_name << endl;// 0x61fdf0
return 0;
}
在Junior
和Senior
在virtual
继承之后,可以不用使用::
就可以直接对Graduate
对象中的_name
成员进行操作了,这就已经解决了二义性的问题,并且根据打印出的这三个成员有相同的地址,可以推断出在Graduate
对象中只有一份_name
成员了,这也解决了数据冗余的问题。
注意:使用virtual
继承的时候,是Junior
和Senior
虚拟继承Student
,而不是Graduate
虚拟继承Junior
和Senior
。
2.3.虚拟继承的原理
下面我们使用一个比较简单的例子来说明一下虚拟继承的原理。
首先我们来看看不用虚拟继承的一个小例子:
代码如下:
#include <iostream>
#include <string>
using namespace std;
class A {
public:
int _a;
};
class B : public A {
public:
int _b;
};
class C : public A {
public:
int _c;
};
class D : public B, public C {
public:
int _d;
};
int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
这也是一个典型的菱形继承的模型:
其内存模型如下:
下面我们来看看在内存当中,到底_a
,_b
,_c
,_d
是怎么存放的,有没有什么规律。
我们发现在编译器的调试内存窗口中可以看到,在d
对象的内部真的是存在两份_a
,而且都是在B/C
对象中的。
下面我们再来看看经过
virtual
进程后,对象的内存布局是什么样子的。
代码如下:
#include <iostream>
#include <string>
using namespace std;
class A {
public:
int _a;
};
class B : virtual public A { // 虚拟继承
public:
int _b;
};
class C : virtual public A { // 虚拟继承
public:
int _c;
};
class D : public B, public C {
public:
int _d;
};
int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
继承的模型:
实际在内存中是这样存储的:
解释上图:
我们可以发现原来D对象继承下来的B对象成员和C对象成员中的_a
都变成了一段奇怪的地址,而_a
成员却存放一份在D对象的最后。这也就virtual
为什么可以解决二义性问题和数据冗余问题的原因。而原来_a
位置上的地址其实叫做「虚基表指针」,该指针指向「虚基表」。而虚基表中存放了两个数据,第一个是为多态的虚表预留的存放的偏移量,第二个是在子类中基类对象(这里指B
类和C
类)距离公共基类(这里指A
类)的偏移量。
如图:
补充:
这个虚基表是很有用处的,不仅可以使得B
类和C
类对象都共同拥有一个A
类成员变量。我么在来举一个例子。
当D
类对象赋值给C
类对象的时候,此时发生切片行为。但是原本应该存放_a
的位置被虚基表指针替代了,此时切片的话这个虚基表指针就会发挥作用了,他会通过偏移量来找到_a
,然后再切片给C
基类对象。
D d;
C c = d; // 发生切片
3.总结
虽然虚拟继承可以解决菱形继承的问题,但是使得对象模型变得异常的复杂对学习者不友好,而且也会对时间效率造成一定程度上的损失。所以在一般情况下,不要设计出菱形继承。
八、继承和组合
继承是一种is-a
的关系,这只会发生在子类和父类当中。而组合是一种has-a
的关系,也就是在一个对象中可以存在另一个对象。
例如:
可以说大众是一种汽车,这就是is-a
的关系,所以最好使用继承。
class Car {
protected:
string _color; // 汽车颜色
string _num; // 车牌号
};
class SVW : public Car {
public:
void show() {
cout << "I am SVW" << endl;
}
};
而汽车上有四个轮胎,这就是has-a
的关系,所以最好使用组合。
class Tyre {
protected:
string _size; // 大小
};
class Car {
protected:
string _color; // 汽车颜色
string _num; // 车牌号
Tyre _tpres; // 轮胎
};
如何选择继承和组合?
需要观察两个类或者多个类之间的关系。
如果构成is-a
的关系,即A
是B
的时候,最好使用继承。
如果构成has-a
的关系,即A
中有B
的时候,最好只用组合。
如果两个关系都可以,既可以是is-a
也可以是has-a
的话,优先使用组合。
继承和组合的特点对比
1、继承允许你根据基类的实现来定义派生类的实现,因为对于子类来说基类是透明的。**这种通过生成派生类的复用通常被称为「白箱复用」。**在继承方式中,基类的内部细节对于派生类可见,继承一定程度破坏了基类的封装,基类的改变对派生类有很大的影响,派生类和基类间的依赖性关系很强,耦合度高。
2、组合是类继承之外的另一种复用选择,很多的功能可以通过组合对象来获得。对象组合要求被组合的对象具有良好定义的接口,这种复用风格被称之为黑箱复用,因为对象的内部细节是不可见的,对象只以“黑箱”的形式出现,组合类之间没有很强的依赖关系,耦合度低。
3、在**软件工程设计中,我们强调「高内聚,低耦合」,即两个类之间尽可能的没有关联,这样在最后重构代码的时候,不用遭受牵一发而动全身的痛苦。**所以如果可以使用组合的话,优先使用组合。但这不意味着继承不重要,只是我们心中要有一个价值排序而已。
常见笔试面试题
什么是菱形继承?菱形继承的问题是什么?
菱形继承是多继承的一种特殊情况,两个子类继承同一个父类,而有一个类同时继承这两个子类,这种继承就是菱形继承。
菱形继承后因为子类对象当中会有两份父类的成员,因此会导致数据冗余和二义性的问题。
什么是菱形虚拟继承?如何解决数据冗余和二义性?
虚拟继承是指在菱形继承的腰部的继承中,我们使用virtual
去继承同一个基类。这样在最后继承的一个子类中只会存在一份基类的基类对象。
这是采用虚基表指针和虚基表完成的,虚基表中记录了B
类和C
类到A
类对象成员的距离,总而只用在D
类中存放一份A
类的成员即可,这样就解决了二义性和数据冗余的问题。
继承和组合的区别?什么时候用继承?什么时候用组合?
继承是一种is-a的关系,而组合是一种has-a的关系。如果两个类之间是is-a的关系,使用继承;如果两个类之间是has-a的关系,则使用组合;如果两个类之间的关系既可以看作is-a的关系,又可以看作has-a的关系,则优先使用组合。