终于,经过一路的过关斩将,我们来到了继承面前。还记得在最初学习类于对象时,那个对封装概念一直模糊不清的自己,还记得被模板,被迭代器折磨的日日夜夜吗?这一路你挺过来了,你失去了一些东西,也得到一些东西,这一路,你成长了许多。不过,少年,这还不够,还不能够停下脚步,前方无疑会有更多的山,或有荆棘,或有河流,但不要怕,热爱会为你铸就铠甲,坚持会化为你的风帆
目录
什么是继承
继承关系与访问限定符
父子类之间的赋值转换
子类对成员变量的使用
子类对成员方法的使用
子类的构造和析构函数
构造
拷贝构造
赋值重载
析构
继承与友元
继承与类的静态成员
多继承&菱形继承
继承与组合
什么是继承
继承(inheritance)是类的三大特性之一,继承机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类
继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用
继承的概念并不难理解,接下来我们举个例子来看看,继承是如何实现代码的复用的。拿日常生活中玩过的植物大战僵尸这款游戏来说,假设我们现在要自己写代码实现植物大战僵尸这款游戏,那么我们需要把植物给定义好,定义植物就是把植物的各种特性给抽象成类嘛,比如植物的名字,植物的血量,植物的价格,植物是否具有攻击性,植物的攻击范围等等,我们就拿向日葵,豌豆射手和食人花来举例
class peashooter {
public:
string _name = "peashooter"; //豌豆射手
int _price = 100; //价格
int _blood = 100; //血量
bool if_attack = true; //能否攻击
int hurt_value = 20; //攻击值
int attack_range = 200; //攻击范围
};
//向日葵
class sunflower {
public:
string _name = "sunflower";
int _price = 50;
int _blood = 100;
bool if_attack = false;
};
//食人花
class corpse_flower {
public:
string _name = "corpse_flower";
int _price = 150;
int _blood = 100;
bool if_attack = true;
int hurt_value = 100;
int attack_range = 20;
};
大家有没有发现,每个植物类内部都有名称, 价格,血量,是否能攻击等变量值,这就导致代码的冗余,如果继续给植物类添加更多的属性,那么可想而知,植物类重复的代码量将会变得很大,不仅占用内容,还影响阅读代码
如果能够用一个类,把每个植物类中相同的部分给提取出来,比如植物的名称,植物的价格,植物的血量等,作为一个父亲类
然后每设计一个植物类,都算作这个父亲类的孩子,每个孩子类都可以传承父亲类的成员变量和成员方法,如此以来,孩子类就不需要自己写植物的名称,植物的价格等这些可以从父亲类那传承过来的成员变量了,孩子类只需要在父亲类的基本功能上完成自己所需要扩展的功能就可以了,这样孩子类的代码量就会大大的较少,代码阅读起来更加清晰明了
class plant {
public:
string _name;
int _price;
int _blood;
bool if_attack;
};
我们设想的植物类创建完毕之后,我们就让其它的植物去继承这个植物类,只要孩子类继承了父类,那么就拥有了父类的成员变量和方法
继承的语法格式为: class 子类名: 以何种方式继承 被继承的父类名
如下述代码,豌豆射手,向日葵,食人花都以public方式继承,继承plant类,继承植物类的共有属性之后,就不需要去重复定义植物名,价格,血量等这些变量
class plant {
public:
string _name;
int _price;
int _blood;
bool if_attack;
};
class peashooter:public plant {
public:
int hurt_value = 20;
int attack_range = 200;
};
//向日葵
class sunflower: public plant {
public:
};
//食人花
class corpse_flower: public plant {
public:
int hurt_value = 100;
int attack_range = 20;
};
如此以来,各个植物其内部的代码量就大大减少,看起来清爽多了
这种功能在C++里就叫做继承,我们可以说成父亲类与孩子类,孩子类可以继承父亲类的成员函数和成员变量,也可以说成基类与派生类,派生类就是在基类的基础上扩展的结果,这两种概念都一样,用哪个都可以,本篇文章将采用父类与孩子类的叫法
上面提到了继承方式,子类直接继承父类不就行了吗?为什么还搞出来一个继承方式呢?
继承关系与访问限定符
继承关系和访问限定符一样都是有三种,访问限定符的作用是限制类外成员对类内成员变量和方法的访问,在刚学类时,主要介绍了public和private,简单的提了一下protected,在public区域内的成员变量和方法可以被外界调用,在protected区域及private区域内的成员只能在类中访问,类外无法访问,但protected和private的区别在哪里呢?先不急着回答这个问题,我们先看看继承的三种形式
访问限定符是为了保护类内成员,保障封装性。那继承为什么也分为三种呢?这是因为我们要考虑子类继承父类的成员之后,放到哪里的问题?子类也是有public区域,protected区域,private区域的,我子类从你父类那继承成员以后,该放到我子类的哪个区域里呢?
你可能会说对应着放呗,从父类public区域继承过来的就放到子类的public区域,从父类protected区域继承过来的就放到子类的protected区域等等等
但是现在,我因为实际需要,必须把从父类继承过来的成员都放到protected区域,或者都放到private区域,这个时候对应着放就没有办法满足了
我们把前面的类拿出来举个例子演示这三种继承
class plant {
public:
string _name;
int _price;
protected:
int _blood;
private:
bool if_attack;
};
class sunflower: public plant {
};
如果sunflower以public方式继承plant,那么sunflower将从plant继承到的public成员放到自己的public区域,从protected区域继承到的成员放到自己的protected区域,也就是我们上面说的一一对应的形式存放
如果sunflower以protected方式继承plant,那么sunflower将从plant继承到的public成员放到自己的protected区域,从protected区域继承到的成员也放到自己的protected区域
如果sunflower以private方式继承plant,那么sunflower将从plant继承到的public成员放到自己的private区域,从protected区域继承到的成员也放到自己的private区域
各位同学,不知道你们是否发现,我从来没说过从plant继承到的private成员放到sunflower的哪个区域,这是因为被private修饰的成员无法被继承
到这里可以跟大家解释protected和private有什么区别了,从类外部访问类内部来看,protected和private的作用是一样的,都是防止类外访问类内成员,维护封装
两者的区别体现在继承上,位于private区域的成员,不能被子类所继承,只有位于public和protected区域的成员可以被继承(这里说private成员不能被继承不太准确,因为查看子类的数据类型,发现其确实被继承下来了,但是子类不可见,故而说成无法继承)
这样做是考虑到父类的某些成员不想被子类所继承,同时不想被外界访问,那就可以放到private区域
父类的某些成员如果想被子类所继承,但是不想被外界访问,就可以放到protected区域里
表面上看C++提供那么多种继承方式,但实用的没几个,如下是总结后的几点
1. 父类private成员在中无子类论以什么方式继承都是不可见的。这里的不可见是指父类的私有成员还是被继承到了子类对象中,但是语法上限制子类对象不管在类里面还是类外面都不能去访问它
2. 父类private成员在子类中是不能被访问,如果父类成员不想在类外直接被访问,但需要在子类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的
3. 实际上面的表格我们进行一下总结会发现,父类的私有成员在子类都是不可见。父类的其他成员在子类的访问方式 == Min(成员在父类的访问限定符,继承方式),public > protected > private
4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式
5. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡
使用protetced/private继承,因为protetced/private继承下来的成员都只能在子类的类里
面使用,实际中扩展维护性不强
父子类之间的赋值转换
父子类之间可以进行赋值转换吗?答案是可以的,但是子类可以转换成父类,而父类无法转换成子类,看下面一段代码
注意这里是赋值转换,而不是类型转换,类型转换要创建一个临时变量,是类似于把int转换成double类型的这种,先创建一个double类型的临时变量,然后把int提升成double类型
但是赋值转换就是直接的转换,没有中间创建临时变量这个过程,是类似于int转换成int这种同类型之间的转换,如下图
因为子类继承了父类的成员,父类的成员子类都有,所以子类可以赋值转换成父类成员,但是父类成员中没有子类成员中特有的成员,因此不支持父类赋值给子类。这种类型转换就像把子类中父类的那一部分切掉赋值给父类,我们也称其为切片赋值
子类对成员变量的使用
目前为止,我们只是在口头上说子类继承了父类除private区域外的所有成员变量和方法,怎么证明呢?并且子类继承了之后又该如何使用呢?
调试程序,打开监视,我们可以看到test_b这个类的成员里,确实包含了plant类的成员,那该如何使用呢,如果父类的成员和子类的成员之间不同名,那么可以直接使用父类的成员类型,如下图,子类和父类中没有重名成员,可以直接使用
如下图,子类中存在成员变量string _name和父类中的成员变量string _name同名。那么此时,父类成员和子类成员之间就会构成隐藏关系或者叫重定义,父类的成员会被隐藏掉,此时直接访问_name,你会发现使用的是子类的_name,但是这不意味着父类的_name就没法使用了,要想使用父类的成员,需要加上访问符 父类 :: ,这种形式,显示访问
若想使用父类的_name,就要加上父类的访问符,具体操作如下
子类对成员方法的使用
对成员方法的使用和成员变量的使用是一样的,如果父类的成员方法名和子类的成员方法名不一样,那么就可以直接调用父类的成员方法,但是如果父类的成员方法名和子类的成员方法名重名了,那么父类的成员方法就会被隐藏,若想使用,就要加上父类的访问符
这里大家容易困惑的地方是,父类和子类的方法即使重名了,如果方法的参数不一样,那么为什么不构成重载关系,而还是构成隐藏关系呢?
这是因为重载关系要求两个方法在同一作用域下,父类的方法在父类的作用域中,而子类的方法在子类的作用域中
两个方法虽然重名,但是不在同一作用域下,故而无法构成重载关系。想一想,我们平时写的构成重载关系的函数是不是都在全局作用域下呢,在类中构成重载关系的方法,是不是处在同一个类的作用域下呢
子类的构造和析构函数
构造
怎么使用父类的成员变量和方法已经知道了,还有问题没有解决,那就是子类和父类的构造函数,我们在继承父类时,一般是要对父类中的成员进行初始化的
这个也很简单嘛,我们既然都知道了如何使用父类的成员,那么在子类的构造函数中给父类的成员进行赋值,不就可以完成初始化操作了嘛
这个操作是有问题的,首先子类无法在初始化列表中直接使用父类的成员,因为父类此时还没有创建,其次,你在子类构造函数的函数体里引用父类的成员,说明此时父类已经创建并完成初始化了,这属于赋值操作而不是初始化
那么如何在创建父类时就对父类进行初始化呢?首先要看父类有没有默认构造,如果父类有默认构造,我们可以选择让父类创建时自动调用默认构造
如果父类没有默认构造,那么我们就需要显示的去调用父类的构造函数,如下图父类有默认构造函数,子类在构造时可以选择依靠父类自己的默认构造
同样的的可以选择去显示的调用父类的构造,如果父类没有默认构造,那么只能选择显示的调用父类的构造
拷贝构造
接下来是拷贝构造,拷贝构造我们要关心如何给子类继承下来的父类进行拷贝,同时给子类自己独有的成员进行拷贝。后者很简单,关键是我们如何调用父类自己的拷贝构造,子类拷贝构造函数传过来的参数是一个子类的对象,我们要想调用父类的拷贝构造,是不是得给父类的拷贝构造传一个父类的对象,问题是我们此时没有父类的对象
如下图(为了看的更清晰,笔者这里删掉了父子类中的几个成员)
如何解决这个问题呢?想一想我们前面提到过的父子类之间的赋值转换,我们可以直接把子类的对象传给父类的拷贝构造,这个过程会进行切片式的赋值转换,就像同类型之间的转换一样,原来我们前面学到的赋值转换,在这里起到了大作用
赋值重载
理解了拷贝构造,赋值重载并没有什么难度,就是需要注意两个点
1.赋值重载前要用this指针检查一下是否存在自己给自己赋值的情况
2.子类在调用父类的赋值重载时,一定要注意加上父类的访问符plant::,因为子类的赋值重载和父类的赋值重载函数名都是operator=,这就触发了隐藏关系,如果不加上父类访问符plant:: ,默认使用的是子类的operator=,从而导致无限调用子类operator=造成栈溢出
析构
析构的调用也会有很多的坑,接下来通过一个demo来简单的演示一下,析构调用会产生哪些问题,如下面的代码
class plant {
public:
plant(){}
~plant()
{
cout << "父类析构调用完成" << endl;
}
};
class peashooter :public plant {
public:
peashooter(){}
~peashooter()
{
plant::~plant();
cout << "子类析构调用完成" << endl;
}
};
int main()
{
peashooter tmp;
return 0;
}
子类析构时先调用父类的析构,然后自己再析构,看着没任何问题,再不能更正常了,然而
这是什么情况?为什么父类的析构会被调用两次,这是因为子类在调用析构函数的时候,会默认调用父类的析构函数,不需要我们手动去调用父类的析构,正因为我们显示调用了一次父类的析构,从而造成父类两次析构的现象,可以给父类new一个数组,来检测是否会造成父类重复析构的情况,代码如下
class plant {
public:
int* _price = new int[10];
plant(){}
~plant()
{
delete[] _price;
cout << "父类析构调用完成" << endl;
}
};
class peashooter :public plant {
public:
peashooter(){}
int* _hurt_value = new int[10];
~peashooter()
{
plant::~plant();
delete[] _hurt_value;
cout << "子类析构调用完成" << endl;
}
};
int main()
{
peashooter tmp;
return 0;
}
继承与友元
继承与友元是比较好理解的,其实就一句话,父类的有元关系不会被继承
如下面的测试demo
继承与类的静态成员
继承与类的静态成员也是比较轻松的一个知识点,同样一句话也可以总结,静态成员属于整个类,所有对象,同时也属于所有子类的对象
这里需要注意的是,静态成员是存放在静态区的,也就是说,我们不需要创建一个对象才能访问静态成员,如下面的代码演示
tmp指针变量并没有指向一个具体的对象,而直接可以去静态区找静态变量_price
同样(*tmp)._price,并不是在解引用tmp,而是去静态区找变量_price
多继承&菱形继承
多继承是C++中比较麻烦的一部分,这倒不是多继承的问题,而是在使用多继承时会碰到菱形继承这种比较麻烦的多继承形式,属于解决了一个问题又引入了另一个问题这种不断挖坑填坑的行为
多继承意如其名,指一个类继承了两个及以上的类,如下面代码中类C就是多继承,同时继承了类A和类B
class A {
public:
int _a;
};
class B {
public:
int _b;
};
class C : public A, public B{
public:
int _c;
};
如果到这就结束,那么多继承也没有什么可怕的地方,反而还挺好用,一个类拥有了其它的类的属性,多好啊。但是生活没有如果,菱形继承它就来了,下面的代码就是一个简单的菱形继承的模型
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;
};
为什么叫菱形继承,画个图就明白了
那又可怕在哪里呢? 看下图
1. 首先是二义性,当类D想访问变量_a时,它没有办法直接访问,因为_a变量有两个,它继承了类B,类B中有一个_a, 它也继承了类C,类C中有一个_a, 编译器如何知道你访问的是哪一个_a呢?编译器只能报错二义性
2. 数据冗余,类B和类C中同时都含有_a,可是我只需要一个呀,现在你还只是一个_a,如果是一个非常大的string串,非常大的vector,我只需要一份,而我们继承了两份,可想而知这是多大的空间浪费呀
二义性的问题我们费点力还是可以解决的,因为只要加上访问符就知道访问的是哪一个了?例如想访问类B中的_a,只需要加上 B::_a,这样勉强可以解决
但是冗余的问题怎么办,咱C/C++人的使命就是榨干CPU,榨干内存,如此大的冗余,根本无法忍受,这谁能受得了,感觉亏了一个亿,别说用了,光想想就气不打一处来
为了解决这个问题,C++委员会提出了虚继承的概念
虚继承的提出就是为了解决菱形继承的冗余问题,作用听起来容易,但是解释起来还是有一些小麻烦的,接下来就开始刨析何为虚继承
咱们看看没有使用虚继承之前的内存分布图是怎样的
通过内存分布以及监视图,我们可以发现,类D中确实包含了类B和类C,且类B和类C中都包含了类A,也就是存在了两个_a
接下来我们开始使用虚继承,虚继承要求在继承的时候加上关键字virtual,并不是D在继承类B和类C时加上virtual,而是类B和类C在继承类A时加上virtual,如下述代码
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 test;
test.B::_a = 1;
test._b = 2;
test.C::_a = 3;
test._c = 4;
test._d = 5;
return 0;
}
使用虚继承之后,我们再去看看类D的内存分布有没有什么变化(这个时候我们就不要再查看监视窗口了,监视窗口做了一些优化,容易造成误解)
什么情况,加上virtual之后,原本类B和类C中存放_a的位置存放成其它的东西了,转而代之像是存放了一个地址,而且_d = 5之后,后面又出现了一个值为3的东西,我记得类C最后给_a赋值为3,这个难道就是_a吗?那它为什么又跑到最后面了
咱们一个一个探索,先看看,类B中存放的40 9b 3a 00 以及类C中存放的 48 9b 3a 00这两个地址到底指向了哪里
可以发现,这两个地址分别指向了两个值,一个值是14,另一个值是0c
这很像是偏移量,让类B的起始地址加上14看看指向哪里?0x0020f96c加上20,指向的是0x0020f980,这不就是刚才我们打问号的那个地址吗?再让类C的起始地址加上0c看看指向哪里? 0x0020f947加上0c,指向的是ox0020f980,竟然也是打问号的这个地址,并且打问号的那个地址的值是3,正是类C对其进行的赋值
如此以来,我们便明白了,加上虚继承之后,类B和类C继承自类A的 _a 确实被合并成一个元素了, 并且把这个_a 放到了类D的末尾,但是为了让类B和类C都能够找到_a,于是把_a的相对于类B类C起始地址的偏移量放到类B,类C中
可能你会说,这并没有节省内存呀,反而多花销了内存,这是因为此时的_a太小了,造成花销比节省大几个字节,如果换成一个占比内存很大的数据类型,那就能体现出其作用了
继承与组合
继承是子类直接继承父类中可继承的元素,组合就是一个类包含另一个类
举个例子
在两个都能满足我们的需求时,推荐使用组合,而不是继承,一方面继承是继承一个类中除private区域所有的东西,会导致耦合性很高,假设父类protected区域某个地方出问题,麻烦也会继承给子类,要考虑更多的细节
而组合是类B包含类A,即使类A中某些protected区域出现问题,但是不妨碍我类B使用,因为类B只能使用类A的public区域,耦合性很低
如上便是继承的全部讲解,继承也是一把双刃剑,用好了能大大节省开发成本,但是用不好,会给自己带来很多麻烦,所以大家可以用,但是要慎用