在之前的CPP大作业中,为了应付期末(是这样的)关于继承和多态部分的内容只是草草过了一遍,并没有深挖背后的实现原理,以及使用的时候一些注意事项。
本篇博客是对类和对象继承多态部分的深化!
[TOC]
1.继承派生关系
继承是提高代码复用性的一个重要手段。它允许我们在保持基类原有属性的基础上,对其进行一定的扩张,增加不同的功能以应对实际情况。
与其相似的增加代码复用性的语法,还有模板
1.1 基本用法
继承和派生是父与子的关系,其中子类拥有父类成员的同时,还会拥有自己的成员
- 继承是一个特殊的语法,用于多个类有公共部分的时候
- 父类:基类
- 子类:派生类
1 | //举例:网站的公共部分 |
在上面的情况中,ART和LINK类中都有网站的公共部分,这时候就出现了代码的重复。继承的出现就是用于解决这个问题的
1 | //下面使用继承的方式来写,WEB类是网站的公共部分 |
测试可以发现,ART和LINK作为派生类,在继承了基类WEB的成员的基础上,还拥有了它们独特的单独成员
同一个类可以同时继承多个基类
1 | class C : public A,public B{ |
1.2 权限问题
继承有3中类型:public、private、protected。这里会显示出类中protected权限和private权限的区别
1 | class A{ |
当我们分别用上面三种方式对类A进行继承的时候,得到的结果是不同的
- 用什么继承方式,派生类中继承的成员就变成什么类型
- 不管用什么继承方式,都无法访问基类中的私有成员
关于权限问题,我们还需要了解下面几点:
- 基类的私有成员在派生类中不可见,但实际上它也被继承过去了。但是编译器和语法的限制让我们无法访问。
- 保护限定符由此出现,如果在基类中的成员不想被外界直接访问,则可以定义为保护
- class默认继承方式为私有,struct默认继承方式为保护
- 实际中我们一般使用public继承,保护/私有方式不利于维护和拓展
1.3 同名问题(作用域)
在继承体系中,基类和子类都有自己独立的作用域
当基类和派生类中出现同名成员函数或者同名成员变量时,会出现冲突。这时候编译器会做一定的处理:直接访问变量名和函数名的时候,优先访问派生类自己的成员,而屏蔽掉基类的。
这种情况被称之为隐藏
:
- 函数名相同构成隐藏(并非重载)
- 成员变量名相同构成隐藏
1 | //继承同名成员的处理 |
实际操作中,不建议写同名的成员
1.4 静态成员
在继承体系中,基类的静态成员有且只能有一个。即所有的子类和他们的对象,都是只有那一个静态成员的。我们可以用这个特性来对继承派生中出现的对象进行计数。
1 | class Person |
如果出现了与静态成员同名,访问方法就有所变化
1 | //访问同名的静态成员 |
1.5 友元
友元关系不会被继承,基类的友元函数无法访问派生类的私有/保护
成员
1.6 默认成员函数
我们知道,C++类和对象中有6个默认成员函数
在派生类中,这些默认成员函数有新的使用方法
- 派生类的构造函数必须在初始化列表中调用基类的构造函数,初始化父类的一部分成员。如果你没有写,编译器会自动调用默认构造函数(先调用基类,在调用子类)
- 派生类的拷贝构造同上,必须显式调用基类拷贝构造函数
- 派生类的赋值重载也需要调用基类赋值重载完成操作
- 派生类的析构函数编译器会自动调用基类,先析构派生类,再析构基类成员(符合栈后进先出原则)
- 在基类析构函数不是虚析构的时候,子类析构和父类析构构成
隐藏
关系
构造和析构顺序
子类继承父类后,当创建子类对象,也会调用父类的构造函数
- 继承中先调用父类构造函数
- 再调用子类构造函数
析构顺序与构造相反
显示调用构造函数
如何显示调用构造函数呢,下面是一个代码示例
1 |
|
运行结果如下,可见子类正常调用了基类构造函数并进行了初始化
1.7 基类和派生类赋值问题
派生类成员可以赋值给基类的 对象/指针/引用。一般我们把这种情况称为切片
,形象地表示把派生类中父类那部分切来赋值过去。
但是!反过来是不行的哦,你不能把基类对象赋值给派生类对象。
基类的指针/引用可以用强制类型转换给基类的指针/引用。但是这样不够安全,除非基类的指针指向的是对应的派生类。
如果基类是多态类型,可以使用
dynamic_cast
来进行安全处理
1 |
|
关于最后提到的越界访问问题,我们知道,指针变量的大小都是相同的,其指针类型的区别主要在访问能力的不同。比如char*
指针解引用只能访问1个字节,int*
指针解引用可以访问4个字节,以此类推,Student*
指针解引用可以访问sizeof(Student)
个字节的空间。
而子类对象的大小都是大于等于基类对象的大小的。这就导致子类指针访问基类对象内容时,一次解引用访问的空间超长,造成了越界访问
实际上,当我们切片讲子类对象赋值给父类对象的时候,编译器会进行切片操作,即新的父类对象中的内容只会包含父类的成员。子类多出去的那一部分成员会被剔除。
这一点我们可以在VS的调试中证实
因为基类的成员变量被设置成了保护,所以我们不能直接在外部进行修改。需要显式调用基类的构造函数
1.8 虚继承(菱形继承问题)
有的时候,继承会出现下面这种情况:一个子类继承了两个基类,而这两个基类又同时是一个基类的派生类
这时候,D里面就会有两份A的内容,相当于两份公共部分。这是我们不想看到的,因为会造成空间浪费。而且直接访问的时候,编译器会报错“对变量X的访问不明确”
比如:intel和amd联合推出的NUC小电脑中,有一款CPU是他们合作开发的
如何解决同时继承AMD和INTEL的问题?
- 这时候会出现两个同名变量,一个是AMD里面有的,另外一个是INTEL里面有的
因为他们是从CPU里面继承来的。 - 虽然我们可以指定作用域来分别修改和访问。但是实际上这个公共部分就出现了浪费(比如是网站的公共部分,多给你一份没有啥意义)
和前面说道的同名问题一样,我们可以指定作用域来访问特定的变量,但是这样是治标不治本的方法,并没有解决空间浪费的问题。
1 | //解决方法1(治表不治本) |
这就需要我们使用虚继承来操作:给B和C对A的继承加上virtural
关键字
1 | class CPU { |
这时候直接访问变量就不会报错了。因为这时候,B和C中的该变量指向了同一个地址,修改操作会同步。
继承模型
这种虚继承的模型是什么样子的呢?进入调试窗口,可以看到这里分别分为了3个模块,保存了不同基类的成员。而它们之中的_Stucture
成员只有一个,不会出现异义
那这里空着的空间是用来做什么的呢?是内存对齐吗?
非也!
虚基表
下图能帮你了解这个内存的区块是怎么划分的
这样做就有一个好处,即便我们使用不同的基类指针(比如amd或者intel)来指向nuc的子类对象,它们都可以通过虚基表里面存放的偏移量来计算CPU成员的位置。从而避免了出现异义的问题。
而如果我们在继承的时候去掉virtual
关键字,即使用普通继承,它的继承模型又会不同
这里就因为内存对齐的问题,我们无法看清楚它的全貌。不过在菱形继承问题中,不使用虚继承会造成两个CPU对象的继承,导致访问不明确的特性是可以看出来的。
1.9 继承和组合
- 继承:上述所说。每一个派生类对象都是一个基类对象
is-a
- 组合:在一个类里面包含另外一个类的对象成员。每一个B对象中都包含了一个A
has-a
实际情况中,建议优先选择组合,而不是继承。
- 继承增加了代码的复用性,但是在一定程度上破坏了基类的封装性。派生类和基类的关联很强,耦合度高。
- 对象组合是另外一种复用的选择,这时候,对象A的内部结构是不得而知的。这样就减小了对象之间的关联性,耦合度低,保护了封装,更方便代码的维护
不过,继承还有另外一种用途,那就是多态。我们下边会讲解的!
总结
多继承所导致的菱形继承问题,在一定程度上让C++的语法变得复杂了。比如java是没有多继承的。在实际使用情况中,不建议使用多继承。
2.多态
- 静态多态:运算符重载
- 动态多态:派生类和虚函数组成的多态
多态通俗地讲就是多种形态,当不同的对象去完成相同的事情的时候,会产生不同的状态。
2.1 虚函数
2.1.1 基本使用以及动态多态
虚函数,并不代表这个函数是虚无的。而表示这个函数在一定情况下会被替换(就好比继承中的虚继承问题)。要实现动态多态,就需要借助虚函数来实现。以下面这个动物说话的代码为例
1 |
|
当基类Animal中的Talk函数没有用virtual修饰时,不管给这个函数传参什么类的对象,它都会调用Animal自己的Talk函数
当我们用虚函数进行修饰后,就会调用派生类CAT和DOG的Talk函数,这就实现了一个简单的动态多态。
对于虚函数,有几点需要注意:
- 当基类的指针或引用指向派生类的对象时,就会触发动态多态,派生类中的同名函数会覆写基类中的虚函数
- 不能定义静态虚函数——因为静态函数是属于整个类的,而不是属于某一个对象
- 不能定义虚构造函数——总不能用派生类的构造来覆写基类的构造吧?
- 析构函数可以是虚函数
2.1.2 虚析构函数
有的时候,我们需要析构一个子类对象时,往往会给基类的析构函数加上virtual修饰,这样只要传派生类的对象给基类的指针/引用,就可以直接调用派生类对应的析构函数,完成不同的析构操作。而不是都呆呆的调用基类的析构函数——那样就会产生内存泄漏
1 | class Queue { |
2.2 纯虚函数
在虚函数的基础上,C++定义了纯虚函数:有些时候,在基类里面定义某一个函数是没有意义的,这时候我们可以把它定义为纯虚函数,具体的实现让派生类去同名覆写。
纯虚函数的基本形式如下
1 | //virtual 函数返回类型 函数名()=0; |
派生类中必须重写基类的纯虚函数,否则该类也是抽象类
1 | class A { |
当我们在派生类中覆写了该函数后,即可实例化对象并调用该函数
和虚函数一样,使用基类的引用或指针来接收派生类的对象,即可调用对应的函数
2.3 抽象类
包含纯虚函数的类就是抽象类,抽象类有下面几个特点:
- 抽象类无法实例化对象
- 抽象类的派生类必须重写基类的纯虚函数,不然派生类也是抽象类
- 如果在基类中定义的纯虚函数是const修饰的,则派生类中对应的函数也需要用const修饰
- 本文标题:【C++】继承多态详解(未完成)
- 创建时间:2022-07-23 21:10:46
- 本文链接:posts/3933786088/
- 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!