c++ - 第13节 - c++中的继承

目录

1.继承的概念及定义

1.1.继承的概念

1.2.继承定义

1.2.1.定义格式

1.2.2.继承关系和访问限定符

1.2.3.继承基类成员访问方式的变化

2.基类和派生类对象赋值转换

3.继承中的作用域

4.派生类的默认成员函数

5.继承与友元

6.继承与静态成员

7.复杂的菱形继承及菱形虚拟继承

8.继承的总结和反思


1.继承的概念及定义

面向对象三大特性:封装、继承、多态
注:面向对象不止这三个特性,还有其他特性,比如反射(Java中的概念)、抽象等
封装的理解:
(1)将c++设计的stack类(进行了封装)和c语言设计的stack类(没有进行封装)对比,用c语言设计的stack类没有封装可能导致他人随意访问,不规范、过于自由。这里从类和类中访问限定符的角度来看,封装更好。(狭义的角度)
(2)以迭代器的设计为例,如果没有迭代器,那么容器的访问只能暴露底层结构,如果暴露底层结构,那么使用起来会很复杂、使用成本很高,并且对使用者的要求极高(每个人都要去剖析源码)。像迭代器这样封装了容器底层结构,在不暴露底层结构的情况下,提供了统一的访问容器方式,降低使用成本,简化使用。
(3)以stack、queue、priority_queue这样的适配器模式设计为例,如果不用适配器这种模式,那么我们就需要自己去完全实现stack等,这样有很大的冗余性,我们设计成适配器模式,不仅降低了冗余性,而且可以自己选择容器来封装实现stack等,这也是一种封装。

1.1.继承的概念

继承(inheritance) 继承机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
例如:设计一个图书管理系统,管理系统的角色类可以分为学生、老师、保安等,这些角色的许多数据和方法是大家都有的,比如说名字、年龄、电话等,也有很多数据和方法是每个角色独有的,如果对每个角色都创建一个类初始化这些信息那很多大家都有的信息就设计重复了,写代码要进行复用。
我们可以设计一个person类,person类里面是每个角色都有的数据和方法,然后再设计每一个角色的类,这些角色的类继承person类,也就是这些角色类也具有person类的数据和方法,如下图所示。

从上面这个例子可以看出,继承的本质是类设计角度的复用。我们把上面例子中的person类称为父类或基类,把student和teacher类称为子类或派生类

1.2.继承定义

1.2.1.定义格式

继承概念中的例子,我们看到Person是父类,也称作基类。Student是子类,也称作派生类。下图就是继承的定义格式:

如下图所示,继承后父类的Person的成员(成员函数+成员变量)都会变成子类的一部分。这里体现出了Student和Teacher复用了Person的成员。

我们使用监视窗口查看Student和Teacher对象,可以看到变量的复用。调用Print可以看到成员函数的复用。可以看出派生类继承基类,不仅继承了基类的成员变量,也继承了基类的成员函数。

1.2.2.继承关系和访问限定符

1.2.3.继承基类成员访问方式的变化

类成员/继承方式
public继承
protected继承
private继承
基类的public成员
派生类的public成员
派生类的protected成员
派生类的private成员
基类的protected成员
派生类的protected成员
派生类的protected成员
派生类的private成员
基类的private成员
在派生类中不可见
在派生类中不可见
在派生类中不可见
总结:
1. 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的 不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
2. 基类private成员在派生类中是不能被访问, 如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
可以看出protected/private成员对于基类是一样的,类外面不能访问,类里面可以访问;protected/private成员对于派生类, private成员不能用,protected成员类里面可以用。
3. 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。
4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过 最好显示的写出继承方式。
5. 在实际运用中父类成员基本都是保护和公有,继承方式基本都是公有继承,几乎很少在父类里面用私有和使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。

2.基类和派生类对象赋值转换

\bullet  派生类对象可以赋值给 基类的对象 / 基类的指针 / 基类的引用(向上转换)。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
派生类对象赋值给基类的对象:相当于是把派生类中基类那部分切来赋值过去,如下图所示
派生类对象赋值给基类的引用:相当于基类引用了派生类中属于基类的部分,如下图所示

派生类对象赋值给基类的指针:相当于指针指向了派生类中属于基类的部分(准确的说是指向派生类中属于基类部分的首字节地址),指针解引用出来的也是派生类中属于基类的部分,如下图所示

注:子类对象赋值给父类的对象/指针/引用,语法天然支持,没有类型转换,不产生临时对象,所以下面的代码中可以Person& rp=s不需要加const。

\bullet  基类对象不能赋值给派生类对象,即使将基类对象强转成派生类型赋值给派生类对象也不行。
\bullet  基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic_cast 来进行识别后进行安全转换。(ps:这个我们后面再讲解,这里先了解一下)

3.继承中的作用域

1. 在继承体系中 基类派生类都有 独立的作用域
2. 子类和父类中有同名成员, 子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。在子类中,可以 使用 基类::基类成员 显示访问父类的同名成员,如下图所示。

3. 派生类和基类中同名的成员函数不构成重载,因为不是在同一作用域。派生类和基类中同名的成员函数构成隐藏,只要派生类和基类的成员函数函数名相同就构成隐藏。子类成员函数 将屏蔽父类对同名成员函数的直接访问,在子类中,可以 使用 基类::基类成员函数 显示访问父类的同名成员函数,如下图一二所示。

4. 注意在实际中在 继承体系里面最好 不要定义同名的成员(成员变量和成员函数)

4.派生类的默认成员函数

6个默认成员函数, “默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?
1.派生类的构造函数:
派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。
注:
1.如果要对继承父类的成员变量显式初始化,要在派生类构造函数的初始化列表阶段显示调用父类构造函数初始化。
2.如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用父类构造函数。
子类构造函数原则:
a、调用父类构造函数初始化继承自父类成员
b、自己再初始化自己的成员 -- 规则参考普通类
派生类默认生成的构造函数:如下图一所示,对于继承父类的成员变量,默认生成的派生类构造函数调用父类的默认构造函数处理,对于自己的成员变量,自己的内置类型不处理,自己的自定义类型调用其默认构造函数处理。(如果父类没有默认构造函数,那么派生类必须自定义的构造函数在派生类构造函数的初始化列表调用父类构造函数进行初始化,如下图二所示)
派生类自定义的构造函数:如果要对继承父类的成员变量显式初始化,则在派生类构造函数的初始化列表阶段显示调用父类构造函数初始化,如下图三所示
注:这里初始化列表初始化的顺序是按照成员变量声明的顺序进行的,在派生类里面默认继承基类的成员变量是先声明的因此基类的成员变量先初始化,下图所示在派生类Student初始化列表中会先调用Person的构造函数初始化name,再初始化_num。
2.派生类的拷贝构造函数
派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
子类拷贝构造函数原则:
a、调用父类拷贝构造函数拷贝继承自父类成员
b、自己再调用自己的拷贝构造函数拷贝自己的成员 -- 规则参考普通类
派生类默认生成的拷贝构造函数:如下图一所示,对于继承父类的成员变量,默认生成的派生类拷贝构造函数调用父类的拷贝构造函数处理,对于自己的成员变量,自己的内置类型浅拷贝处理,自己的自定义类型调用其拷贝构造函数处理。
派生类自定义的拷贝构造函数:需要在派生类拷贝构造函数的初始化列表阶段显示调用父类拷贝构造函数对继承父类的成员变量进行拷贝,然后再对自己本身的成员变量进行拷贝处理,如下图二所示(这里Student s2(s1)先将Student对象s1传给Student对象s2的拷贝构造函数的参数s,拷贝构造函数里面person(s)调用父类的拷贝构造函数,先将const Student&类型对象s传给const Person&类型对象p,再使用父类的拷贝构造函数对继承父类的成员变量进行拷贝,然后其他部分是对自己本身的成员变量进行拷贝处理)
3.派生类的operator=函数
派生类的operator=必须要调用基类的operator=完成基类的赋值。
子类赋值运算符重载函数原则:
a、调用父类赋值运算符重载函数赋值继承自父类成员
b、自己再调用自己的赋值运算符重载函数赋值自己的成员 -- 规则参考普通类
派生类默认生成的赋值运算符重载函数:对于继承父类的成员变量,默认生成的派生类赋值运算符重载函数调用父类的赋值运算符重载函数处理,对于自己的成员变量,自己的内置类型浅拷贝处理,自己的自定义类型调用其赋值运算符重载函数处理。
派生类自定义的赋值运算符重载函数:需要在派生类赋值运算符重载函数内显示调用父类赋值运算符重载函数对继承父类的成员变量进行赋值,然后再对自己本身的成员变量进行赋值操作,如下图所示。(这里s1=s3先将Student对象s3传给Student对象s1的赋值运算符重载函数的参数s,赋值运算符重载函数里面operator=(s)调用父类的赋值运算符重载函数,先将const Student&类型对象s传给const Person&类型对象p,再使用父类的赋值运算符重载函数对继承父类的成员变量进行赋值,然后其他部分是对自己本身的成员变量进行赋值处理)
注:派生类Student和基类Person都有operator=函数,Student继承Person之后Person类的operator=函数会被隐藏,这里调用Person类的operator=函数需要显式的指定,代码为Person::operator=(s)
4.派生类的析构函数
派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
父子类的析构函数虽然函数名字不同,但是也会构成隐藏关系(特例),原因是下一节多态的需要,析构函数名统一会被处理成destructor(),因此在派生类中如果要调用基类的析构函数需要显式的指定,代码为Person::~Person()
c++编译器为了保证先析构子再析构父的析构顺序,子类的析构函数完成后会自动调用父类的析构函数,所以不需要我们显式的调用,如下图所示
问题:如何设计一个不能被继承的类?
答案:将类的构造函数设置成private私有即可。因为将父类A的构造函数私有化,而B的构造函数必须要去调用A的构造函数(规定死的,即使显式的写也是显式的去调用),A的构造函数是私有的不可见,子类B就无法构造对象了。
注:如果将类的构造函数设置成私有,那么该类自己也无法构造对象,解决方法是在类里面定义一个公有的函数,函数内返回部分为构造函数即可(私有的构造函数在类里面是可以调用的),如下图一所示。下图一的代码其实还是无法构造对象,是因为调用成员函数需要对象,没有对象不能调用CreateObj成员函数(先有的鸡还是先有的蛋的问题),而下图一中直接用类去调用是不行的,解决方法是将CreateObj成员函数设置成静态的,这样就可以使用类去调用,如下图二所示
  

5.继承与友元

友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员
如下图一的代码所示,子类Student继承了父类Person,Display函数是父类Person的友元,Display函数可以访问父类Person的私有成员变量但是无法访问子类Student的私有成员变量,这就说明Display函数是父类Person的友元不是子类Student的友元,友元关系不能继承。要让Display函数访问子类Student的私有成员,将Display函数设置成子类Student的友元即可,如下图二所示

6.继承与静态成员

基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子
类,都只有一个static成员实例
如下图所示可以在Person类里面定义一个静态的成员变量,构造函数里面对该成员变量++,这样可以统计继承Person类的子类定义了多少个对象,如下图所示

注:任意继承了父类的子类都可以使用子类名或子类对象直接访问父类的静态成员,例如上面可以使用Student和s3._count访问Person里面的静态成员变量


7.复杂的菱形继承及菱形虚拟继承

单继承:一个子类只有一个直接父类时称这个继承关系为单继承

多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承

菱形继承:菱形继承是多继承的一种特殊情况

菱形继承的问题:从下面的对象成员模型构造可以看出菱形继承有数据冗余和二义性的问题,在Assistant的对象中Person成员会有两份。

上面对象成员模型的代码实现如下图所示,Assistant的对象中会有两份Person的成员(数据冗余),此时用Assistant的对象去访问的话不知道要访问的是哪一个(二义性),需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决。

虚拟继承可以解决菱形继承的二义性和数据冗余的问题。
如上面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题,如下图所示,要设置成虚拟继承则在继承方式的前面加上关键字virtual即可。需要注意的是,虚拟继承不要在其他地方去使用。

注:

1.在监视窗口Assistant对象a中有多个_name成员变量,但是实际上使用虚拟继承后a中只有一个_name成员变量,这里可以看出监视窗口有时候显示的是不准确的,这里监视窗口显示不准确是因为编译器对监视窗口优化过。

2.上面菱形继承的形式如下图一所示,但是菱形继承的形式不一定非得是这样,可能会有很多种形式,如下图二所示,也是菱形继承,下图二所示的这种菱形继承需要在两个蓝色箭头继承的位置加上关键字virtual

   

虚拟继承解决数据冗余和二义性的原理
如下图一所示展现了菱形继承的存储模式(没有加virtual),下图二中我们将内存窗口和类进行了对应。可以看出先继承谁谁的成员变量就在前面,后继承谁谁的成员变量就在中间,最后是自己的成员变量。
注:这里因为监视窗口编译器进行了优化所以我们使用内存窗口来进行观察

如下图一所示展现了菱形虚拟继承的存储模式(加了virtual),下图二中我们将内存窗口和类进行了对应。可以看出菱形虚拟继承调整了存储模型,其中B类和C类部分原本存储A类成员变量的地方现在存储的是指针数据即两个地址,观察这两个地址位置存储的数据,如下图三所示,这两个地址位置存储的数据是整型数据0,两个地址对应整型的下一个整型数据存储的是现在存储A类成员变量位置和原本存储A类成员变量位置(也就是指针位置)之间的偏移量。

原本存储A类成员变量的地方现在存储的是指针,指针指向位置的后一个位置存储的是现在存储A类成员变量的地方和此处的偏移量,那么就可以通过原本存储A类成员变量的地方找到现在存储A类成员变量的地方。

这么做是因为如果要将D类的对象赋值给B类的对象,那么D类对象要切片,去找D类对象中B类对象的那一部分赋值给B类的对象,D类对象中B类对象有B类自己的成员变量也有继承A的成员变量,其中继承A的成员变量没有存在B类存储空间中,因此需要能找到现在存储A类成员变量的地方来取A类成员变量的数据。

注:

1.上面例子中原本存储A类成员变量的地方现在存储的是指针,准确的说指针指向位置其实是一个偏移量数组,偏移量数组的第一个元素是预留出来给多态用的,第二个元素是上面讲的偏移量,上面例子中的A叫做虚基类,这个偏移量数组叫做虚基表,指向续集表的指针(原本虚基类A成员变量位置存储的指针数据)叫做虚基表指针

2.如过直接将A类里面的成员变量a设置成静态的,那么D类的对象里面也相当于只有一个a,但是这样做的话D类定义出的不同的对象d1和d2,d1和d2里面的成员变量a都是同一个a,而我们要的d1和d2对象里面应该分别有一个a,所以这样做是不行的

3.如下图一所示的代码,下图二中我们将内存窗口和B类进行了对应,可以看出菱形虚拟继承下B类的对象也调整了存储模型。那么下图一代码中,B*类型的指针ptr1指向了D类型对象d的地址,B*类型的指针ptr2指向了B类型对象b的地址,编译器中ptr1和ptr2并不知道自己指向的是B类型的对象b还是D类型的对象d,在d对象里面,A成员a的新位置距离B中原本a位置远为20,在b对象里面,A成员a的新位置距离B中原本a位置近为8,其实ptr1和ptr2不知道也不关心自己指向的是B类型的对象b还是D类型的对象d,它们都使用同样的方式去找A成员,方式是先找到虚基表中偏移量,然后计算成员a的位置。

  


8.继承的总结和反思

1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
2. 多继承可以认为是C++的缺陷之一,很多后来的OO语言都没有多继承,如Java。
3. 继承和组合
如下图所示,左边A和B是继承关系,右边C和D是组合关系。继承和组合其实都是复用关系,但是复用的程度不一样。 public继承是一种 is-a的关系。也就是说每个派生类对象都是一个基类对象。 组合是一种 has-a的关系。假设B组合了A,每个B对象中都有一个A对象。

  

继承关系的例子:人 <- 学生    动物 <- 狗
组合关系的例子:车->轮胎    头 -> 眼睛
继承和组合都可以的例子:宝马  车
可以看出有些关系天生适合用继承,有些关系天生适合用组合,两个类到底用继承还是组合去观察其关系适合什么即可,适合is-a关系建议使用继承,适合has-a关系建议使用组合,都可以的情况下建议使用组合。
优先使用组合,而不是继承的原因如下:
\bullet 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响,以上面ABCD四个类的例子为例,A任何成员修改都可能会影响B的实现。派生类和基类间的依赖关系很强,耦合度高。
\bullet 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现,以上面ABCD四个类的例子为例,C只要不修改公有的部分,就不会对D有影响,组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
\bullet以上面ABCD四个类的例子为例,解释白箱复用和黑箱复用,如下图所示

\bullet 在实际中模块间的关系要符合高内聚低耦合,因此尽量多去用组合,组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

随风张幔

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值