C++中的继承与多态
目录
前言
一、类继承
1.1什么是类继承?
1.2类继承的语法格式
1.2.1继承方式分类与访问权限
1.3.基类与派生类的赋值兼容转换
1.3.1基类数据在派生类中的存储
1.3.2内存切片
1.3.3继承中的作用域
1.4菱形继承
1.4.1菱形继承导致的问题
1.4.2如何解决菱形继承问题
二、多态
2.1静态多态
2.2动态多态
2.2.1虚函数
2.2.2重写
2.3多态的原理
前言
本文要讲解的C++语言中一个极大提高编码扩展能力的机制——继承,这种机制使得我们在原有自定义结构上扩展其它功能提供了极大的遍历,接下来我们就由前入深的了解一下继承以及其涉及到的原理以及继承的使用!
一、类继承
1.1什么是类继承?
在C++中继承通俗的讲是一个子结构对一个父结构中方法与变量的继承。继承的出现使得原有类的拓展性变强。在工程中,假设(实际上大概率没有这个需求)我们要使用[]来访问链表(list)中的数据,C++11库中并没有更新这一功能,这时假如我们自己从头搭建一个库是不是一个十分坐牢的过程(当然你也可以复制粘贴),我们知道一些基本的list操作在库中都已经给出,这些无需我们去实现,但是我们想扩展list的功能,这个时候就可以使用继承的方式,来在库中提供的list操作的基础上增添新的功能(这意味着我们无需考虑list基本操作的正确与否,因为库中的实现已经经历了相当长时间群众的检验)。

1.2类继承的语法格式

1.2.1继承方式分类与访问权限

在谈类继承方式对访问权限的影响前,我们先要回顾一下访问限定符,public修饰的部分可以在类外访问,private修饰的部分只可在本类中访问,而protected修饰的部分在继承的子类(也称派生类)与父类(也称基类)中可以被访问,但是在不具备继承关系的类间不可访问。

注:不要将“访问限定符”与“继承方式”相混淆:

注意:当我们再谈访问权限的时候,要弄清楚权限是谁对谁的权限,这里特别声明一下,这个权限指的是外部(除继承关系的类外,要访问派生类的数据的目标)对派生类数据的访问权限

在上文解释过访问权限的确定后,我们思考一个问题:每一种访问权限在工程可能的使用。
实际上我们可以从日常对类的使用中看出一些端倪:比如在不涉及继承的类之间的关系时,似乎从来没有提到过protected这一“访问限定符”/“继承方式”,这时你也许会猜想protected这一限定符是为继承而生的,它必然在类继承中应用更广。实际上,protected确实就是应“继承”而生,但是在大工程中,protected并不像public继承那样泛用性更强。我们要思考一下,我们使用类继承的初心是什么?我们希望继承已有类并对其进行功能扩展,但是如果过分使用private访问权限控制,这就意味着我们将降低继续使用继承扩展派生类的扩展性,因为成为派生类的private成员后,这一派生类再继承时其子类是无法访问这一部分的数据的,对于protected也是相似,protect修饰的内容,只可在这一级父子类中使用而无法在继承关系外的部分使用。
总而言之,public更加开放,对访问数据申请的请求来者不拒,protected更加“护犊子”,并愿意在“族”(这只是个比喻的说法,并未得到证实)内分享其资源,private是个吝啬鬼,只允许自己使用自己的数据。

1.3.基类与派生类的赋值兼容转换
在这上文中,我们总是说派生类继承了父类,那么在内存中,派生类继承来的内容放在了哪里,又如何管理呢?基类的默认构造函数等又是如何继承到子类的。这一部分着重解决解释这些问题。
1.3.1基类数据在派生类中的存储

我们执行图中代码,启动调试,打开内存监视,输入tmp变量的地址。此时我们可以看到:基类的成员在更低的地址存储。这是否意味着,在实例化对象时,基类的构造函数调用要先于子类对象构造函数的调用,这时我们控制执行令其完毕执行,查看输出结果

从结果上来看,我们不难发现确实父类的构造函数的调用要先于子类构造函数的调用,同时我们还发现,我们并没有实例化parent对象但是,其仍然调用了构造函数。这意味着子类调用构造函数的时候会调用父类的构造函数。
总结一下:子类在实例化后,会按照继承规则,继承父类的数据,而后会现调用父类的构造函数,最后调用其自己的构造函数,完成派生类的初始化。
1.3.2内存切片
首先我们要知道指针类型的意义,指针类型表明了该指针能够管理的数据地址的范围,对于类来说也是如此,此外我们还要知道指针超出管理的地址范围存取数据是一件非常严重的问题,对于类的指针来说也是如此。
那么,现在请你想一个问题,是子类指针能够管理的地址范围更大还是父类指针能够管理的地址范围更大;显然,子类指针能够管理的地址范围一定是大于等于父类的指针能够管理的地址范围,因为子类一定是继承于父类,子类一定是在父类的基础上作功能的“加法”。所以父类的指针如果取存取子类对象的数据,那么在正常进行操作的前提下一定不会越界,因为子类数据的存储的地址范围一定大于或等于父类指针能够管理的地址范围,但是如果使用子类的指针存取父类对象的数据,那么非常有可能造成越界问题。为了尽可能避免此类问题导致的指针越界访问,C++编译器不允许使用子类的指针去管理或操作父类对象的数据。


所谓的内存切片就是只我们可以使用父类的指针去管理或操作子类中的父类数据,这点其实也比较好理解,当我们完成一个大项目后,假如我们在相关接口传递的都是父类的指针,那么就意味着我们使用继承扩展功能后,只要不更改原来代码的逻辑,我们传递新的子类到原来的接口中依旧可以运行,因为父类指针可以使用子类中与父类有关的数据或方法。
那么,接下来我们看看编译器是如何实现这一过程的。

切片的作用是在基类指针指向派生类的时候,只读取指针指向处基类字节大小的数据,内存切片可以发生在:派生类对象赋值给基类对象、基类对象指针、基类的引用

1.3.3继承中的作用域
继承中的每一个类的作用域是相互独立的,这也就意味着,假设此时在父子类中是可以存在同名数据和函数的,但也要注意在跨作用域使用其他类的函数是要注意加域作用限定符(父子类中含有同名函数/同名数据时)

1.4菱形继承
1.4.1菱形继承导致的问题
有些时候,某个类中的方法和数据可能不足以支持我们完成某一功能的扩展,这个时候需要我们继承多个父类的数据和方法的。如果继承的父类是毫不相干的父类,那么子类继承这两个父类显然是没有问题的,但是如果继承的多个父类中有来自于同一祖宗类时就会出现问题。

菱形继承的主要问题就是,存在多个相同的同名方法和数据导致继承发生错误,编译器不知道到底要继承哪一个父类中的哪一个族类方法,进而导致无法正确继承。

1.4.2如何解决菱形继承问题
关于这一点C++也给出了解决方案就是将导致菱形继承的类进行虚拟继承。

关于解决方案其实只要弄清楚一个点就可以了,那就是如何避免重复的字段或方法如何被继承,我们在刚开始讲解继承的时候也提到了,继承的意义是扩展原来不具备的功能,那么当我们没有进行特殊的操作的时候,我们只需要择二留一即可,但是我们在进行类功能的扩展时,也会进行一些特殊的操作比如”重写“(什么是重写我们稍后再说),这个时候祖类中的方法行为会在父类中重新定义。所以这个时候随机二选一是不可以的。
所以C++综合考虑,当子类继承时,出现继承方法或字段不明确的时候,如果确定要继承操作,那么就必须将发生菱形继承的类设置为虚拟继承。函数的行为和字段的数据均与继承顺序中靠后的父类一致。
#include <iostream>class grandfather
{
public:int dataA = 10;int dataB = 11;int funcA(int x,int y){return x + y;}
};class father1 : virtual public grandfather
{
public:int dataC = 12;int funcC(int x,int y){return x - y;}
};class father2 : virtual public grandfather
{
public:int dataD = 13;int funcD(int x, int y){return x * y;}
};class child : public father1, public father2
{
public:int dataE = 14;
};
以该代码为例,如果我们确定发生菱形继承,那么就必须在父类继承祖类时设置为虚拟继承,此时father1、father2类中所有冲突的代码都不会执行而是交给child统一执行,以消除
二、多态
多态是一个针对函数的概念,即拥有相同名字的函数却有着不同的行为就是一种多态。多态可以分为两类,一种是静态多态,即在编译时期就可以确定的多态。一种是动态多态,即在运行时期才可以确定的多态。
在讲解多态之间我们还是先回到继承的意义上,继承是为了对原有功能进行扩展,基类的方法只支持基类下的数据,但是如果我想让基类中的方法也支持派生类中的数据应该怎么办呢?如果我们可以重新定义基类继承下来的方法,让其支持派生类是不是就可以了呢!是的!这就是多态给我们带来的便利!
2.1静态多态
在C++中,所谓的静态多态就是指重载,这一点从C++类的构造函数就可以看出来,比如一个类中允许同时出现多个构造函数,它们函数名相同,但是参数不同,传入参数的数量和类型不同就会导致同一种方法却有不同的行为。由于这部分不是本文的主要内容所以这里一笔略过。
class A
{
private:int a;
public:A(){/*...*/}A(int val){/*...*/}A(const A& val){/*...*/}void Set_A(int val){a = val;}
};
2.2动态多态
在C++中,动态多态就是用来解决我们在派生类中想要更新基类中方法行为的利器。不过我们必须要遵守一定的规则才可以触发动态多态。
- 必须满足继承关系。
- 想要触发动态多态的基类函数必须是虚函数。
- 派生类必须重写基类函数。
- 必须使用基类的指针或者引用来调用重定义的方法
2.2.1虚函数
使用virtual关键字修饰的函数就叫做虚函数。如下就是示例代码所示就是一个虚函数,虚函数相较于其它函数就是多了一个关键字修饰而已,普通虚函数的核心价值就是为了实现动态多态。
virtual void func()
{std::cout << "i am parent!" << std::endl;
}
2.2.2重写
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。下面是一个有关重写的示例。
#include <iostream>
class parent
{
public:virtual void func(){std::cout << "i am parent!" << std::endl;}
};class child :public parent
{
public:void func(){std::cout << "i am child!" << std::endl;;}
};void who_are_you(parent& obj)
{obj.func();
}
int main()
{parent p;child ch;who_are_you(ch);who_are_you(p);return 0;
}
我们运行一下代码查看一下代码运行后的效果。

如果把parent中func前virtual修饰去掉,则派生类与基类中的func就构成了重定义的关系,我们去掉virtual修饰后再运行一下代码看看重定义代码的运行结果。(重定义与本文的相关行不大,这里主要是为了提醒读者区别“重定义”与“重写”)


2.3多态的原理
动态多态的问题中心就是:多态被触发了吗?如果被触发了,那我应该执行哪个函数行为,如果没被触发我又该执行哪个函数行为。我们又知道动态多态是与虚函数有关的,所以只要管理好所有虚函数,编译器就可以明确应该执行哪个函数行为。
在C++中,一个类所有可以访问的虚函数都被一个虚函数表指针管理起来,这个指针指向的区域保存着该类可以访问的所有虚函数的地址。虚函数表指针位于一个类地址的起始处。

接下来我们看看几种继承的情况。
子类继承单个父类虚函数但不发生重写:

子类继承单个父类虚函数并发生重写:

子类继承多个父类虚函数但不发生重写:

子类继承多个父类虚函数发生重写:
