本篇主要讲述C++三大特性之 —— 继承。
前面的博客讲的都是封装,也是三大特性之一。
那么这篇博客开始讲继承,也是学习C++非常的重要的一个知识点。
直接来个例子,假如我们现在要实现一个学校人员管理系统。
那么将学校人员分为好多类,比如说学生、教师、清洁工、门卫等等。
现在就以学生、教师、后勤这三个类为例子。
那么这些类中,都有一部分共同的属性,比如说姓名、年龄、电话、家庭住址等等。如果我们在每一个类中都手动定义这些共有的属性,就会非常的麻烦。
但是这些类中每个类也有其独特的属性,比如说学生类可以有专业、班级、学号等,这些属性不会重复,所以这些属性定义出来就不会说重复什么的。
综上,我们是否能够实现一个最基础的类,这个最基础的类能够包含所有我们要实现的类的公共属性,然后我们在各个要实现的类中复用这个最基础的类,这样就不用重复的去敲那些相同的代码了。
。。。
就像算法库中的swap函数一样,是一个函数模板,当我们用的时候直接用函数模板来为我们实例化出一个swap就可以实现交换的功能。
。。。
然而上面的函数模板是针对于函数来说的,而类模板的功能和我们上述的功能又不太一样。
。。。
所以这时我们就可以引入继承这个知识点了。
所谓继承,就是让一个类来继承另一个类中的属性和方法。
就像我们上面的三个例子中的那样,我们可以实现一个最基础的类,里面定义上姓名、年龄、电话、家庭住址等属性和方法,然后让学生、教师、后勤这三个类来继承这个最基础类中的属性和方法。
在继承中,这些类也是有专有名词的,上面的最基础的类的名称就是父类(基类),而学生、教师、后勤这三个类统称为子类(派生类)。我下面写的时候会穿插着用父类子类 和 基类派生类,就是让大家对这两个概念熟悉熟悉。
上面的这个例子就能说明继承体现的是类设计定义层次的复用。
下面我就用代码来演示演示(不是上面例子中的):
先写个父类 person :
可以看到,和我们平时写的类没有任何区别。
注意最下面的 = 是给缺省值,不是在定义对象。
然后我们再写两个类 student 和 teacher,但是先不写继承关系:
此时如果我们想让这两个类去继承person类的话,很简单。
但是有一点语法上的东西要等会再讲,这里看不懂没关系,只是让大家先看一眼长啥样:
语法等会讲,我们先简单用一用:
这里Student 和 Teacher 继承了person中的属性(name、age)和方法(print)。可以直接用print,但是我们现在还没学那么深入,怎样改继承下来的属性的值什么的先不说。
上面person中的name和age是protected的,我们这里不能直接访问,但是如果把权限改成了public就可以访问了。
因为权限。
前者是针对继承的,也就是代码中 :public person这块。
后者是针对父类的,也就是类中各个成员的访问权限。
这两个结合起来就形成了我们子类中各个继承下来的成员的访问权限。
如下表所示(不要死记):
至于什么是不可见,等会演示。
实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问权限为 min(成员在基类中的访问权限,继承方式),这里可以认为各个访问限定符权限的大小为:public > protected > private。
比如说上面的两个例子中,继承方式都为public。
再给个例子,继承方式都为protected时:
直接给结论:
基类private成员在派生类中无论以什么方式继承都是不可见的。
这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
这就是不可见。
再说两点:
所以基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。所以我们以后99%的情况下都用的是公有继承。
这个是和用class和struct有关的。
子类用的是class实现的,默认的继承方式就是private。
子类用的是struct实现的,默认的继承方式就是public。
子类用class
子类用struct实现
答案是学号。
原因如下:
注意这里的隐藏要和前面的私有成员继承的不可见区分开来,这里是类内可以访问到的,只不过要加上类域限定符::。
看下结果:
需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
请问,A类和B类中的fun构成重载吗?
答案是不构成,因为构成重载的一个条件是两个函数要在同一作用域下。
所以说,二者是隐藏关系。
此处A中的fun访问权限是public的,继承方式也是public的,故我们也可在类外访问。
这里稍微讲点。
继承时,不是将父类中的代码直接拷贝到子类中的。
B类对象访问fun函数时,B的fun和A的fun都是在代码段中的,当B类对象访问时只能跑到B类的代码段中去找fun,想访问A类中的成员时,就要加上类域限定符去访问A类的成员函数fun,但是这里主要原因还是对象中只能存放成员变量。
上面这段话听不懂的同学,可以看看我这篇博客,或许你会有新的理解:点击目录类的实例化那里。
强调一下:注意在实际中在继承体系里面最好不要定义同名的成员。
这个还是比较重要的。
给出如下代码:
class Person
{
public:
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public:
int _No; // 学号
};
Student继承Person的就是上面粉色的部分,下面的_No才是真正子类创建的。
然后就可以说一下子类和父类的相互赋值了。
派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
看:
这里虽然是不同类型,但是不是隐式类型转换(光看第三个引用也能赋值就可以看出来),算是一个特殊支持,语法天然支持的。
…
赋值对象,就是将最开始图中子类的粉色部分赋值给父类对象。
赋值指针,就是子类对象的地址给父类对象。
赋值引用,也是引用的粉色部分。
对其修改,如下:
可能有的同学会问这个的运用场景在哪,等会就有。
大家目前阶段就先记住基类对象不能赋值给派生类对象(但其实这种说法不正确)
基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(RunTime Type Information)的dynamic_cast 来进行识别后进行安全转换。(ps:这个我以后再讲,这里先了解一下)
如果各位不知道这几个为啥是默认成员函数的话,可以看我这篇博客:【C++】类和对象(中篇)
6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?
下面我就用上面Person类和Student类来为大家讲解这几个函数的实现及细节。
把person、student改为如下所示:
// 父类,就一普通类
class Person
{
public:
Person(const char* name)
:_name(name)
{
cout << "person()" << endl;
}
Person(const Person& p)
:_name(p._name)
{
cout << "Person(const Person& peson)" << endl;
}
Person& operator=(const Person& p)
{
cout << "Person operator=(const Person& p)" << endl;
if (this != &p)
_name = p._name;
return *this;
}
~Person()
{
cout << "~Person()" << endl;
}
protected:
string _name; // 姓名
};
// 子类
class Student : public Person
{
public:
int _num; // 学号
};
下面开始实现子类。
直接给结论:
如果我想直接在子类构造函数的初始化列表处直接将父类的name初始化,这样是不行的:
直接报错。
这里要注意,子类初始化列表出不能直接初始化父类成员,子类只能处理子类的成员,父类的成员要让父类的构造函数来初始化,只能在初始化列表处调用父类的构造函数。使用的时候像匿名对象一样。
结论:
问题来了:
当我写出如上拷贝构造时,Person的拷贝构造该传什么?
这就要用到刚讲的赋值转换了。直接传s就行,这样子类对象就传给了父类,也就是切片。
结论:
但是如果我们运行起来的话,程序崩掉了,调试发现出现了栈溢出的问题:
栈溢出一般都是无限递归了,看我们的代码:
子类的operator=和父类的operator=名字冲突了,前面也讲了,当父子函数名冲突时,会隐藏掉父类的函数。所以这里是一直在调用子类的赋值重载。
编译器默认生成的析构函数
1、自己的成员,内置类型不处理,自定义类型调用其的析构函数。
2、继承的成员,调用父类析构函数处理。
因为子类的析构和父类的析构又构成了隐藏,有同学可能就会问这里函数名都不相同怎么会隐藏呢? 由于多态的需要,析构函数名字会统一处理成destructor。所以此处二者是重名的。
所以又要加类域限定符。
但是我们测试一下又出问题了:
这里三个子类对象,理应析构三次子类,析构三次父类就好了,但是这里父类析构了六次,双倍了。
这是因为每个子类析构函数后面会自动调用父类的析构函数。但这又是为什么?
因为需要保证先析构子类的内容,再析构父类中的内容。
当我们构造子类对象的时候,是先将父类中的成员先构造出来,然后再构造出子类对象,按照栈的顺序的话,就要先析构子类,再析构父类。
如果让我们自己写的话,就不能保证这个顺序了,比如说如果有需要new的成员,若让我们自己写delete和父类析构函数的顺序,是不能保证每个人都一样的。所以让子类析构函数后面自动调用父类析构,这样就能保证先开辟的后析构,也就是栈中的顺序。
所以说,我们是不需要手动调用父类析构函数的。
测试一下:
这样就都是三次了。
这个其实不用讲,就是返回对象的地址就好了,默认生成的就够了。
还有个const的。
这里有一个场景就是返回nullptr,目的就是不想让别人拿到这个类型的对象的地址。
如果对于友元的知识不太清楚可以看我这篇:【C++】类和对象(下篇)
一句话:静态成员在整个继承体系中只有一份,不管是继承体系中的哪个类或者类定义的对象,都是同一个。
静态成员变量在全局区\静态区中,程序结束后才会释放。所以无论派生出多少个子类,都只有一个static成员实例 。
给出如下代码:
class Person
{
public:
Person() { ++_count; }
void people_conut()
{
cout << "Person人数::" << _count << endl;
}
protected:
string _name; // 姓名
public:
static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
public:
void people_conut()
{
cout << "Student人数::" << _count << endl;
}
protected:
int _stuNum; // 学号
};
class Graduate : public Student
{
public:
void people_conut()
{
cout << "Graduate人数::" << _count << endl;
}
protected:
string _seminarCourse; // 研究科目
};
void TestPerson()
{
Person p;
Student s1;
Student s2;
Student s3;
Graduate s4;
p.people_conut();
s1.people_conut();
s2.people_conut();
s3.people_conut();
s4.people_conut();
cout << endl;
Student::_count = 0;
p.people_conut();
s1.people_conut();
s2.people_conut();
s3.people_conut();
s4.people_conut();
}
上面的代码中,只要创建子类对象,就会调用父类的构造函数,所以只要定义student对象或graduate对象就会让count + 1。
先不说什么是菱形继承,先说简单的。
单继承:一个子类只有一个直接父类时称这个继承关系为单继承。
上面这张图可不是多继承。postgraduate只有一个直接父类。
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
这才叫多继承,assistant有两个直接父类。
这里可以讲讲。
问题:如何定义一个不能被继承的类?
分情况:
C++98
然后回来谈正事。
我们来看一道题目,代码如下:
问题来了,四个选项,该选哪个,想好了再看答案。
A:p1 == p2 == p3
B:p1 < p2 < p3
C:p1 == p3 != p2
D:p1 != p2 != p3
做好题了就看讲解,讲完了公布答案。
首先,前面讲了切片。
所以这个题不可能那么简单的选A。
然后要确定出继承的先后顺序。
就是按照子类继承时冒号后面的顺序,也就是这里:
先继承Base1,后继承Base2。
再来这张图:
换成这里的继承就是:
所以切片的时候就好说了。
&d,就是最外面的大红框。
p1类型为Base1*,指向d的地址,切片时将_b1的地址给p1,而_b1的地址和d的地址相同,所以p1 == &d。
p2类型为Base2*,指向d的地址,切片时将_b2的地址给p2,_d2的地址和d不相同,所以p2 != &d。
p3类型为Derive*,指向d的地址,就是d的地址,所以 p3 == &d。
所以本题答案九四C。
但是上面讲的不太清楚,我们来结合着调试讲:
注意到,p2的地址是大于p1和p3的。
所以其在栈中是这样的:
在栈帧中是将_b1、_b2、_d挨个push进去的。
然后我们再来看菱形继承。
菱形继承是多继承的一种特殊情况。
下半部分就是多继承,但是两个父类又共同继承了一个父类。
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。
在Assistant的对象中Person成员会有两份。
根据上面的继承关系写出如下代码:
class person
{
public:
string _name; // 姓名
};
class Student : public person
{
public:
int _num; // 学号
};
class Teacher : public person
{
public:
int _id; // 职工号
};
class Assistant : public Student, public Teacher
{
public:
string _marjor_course; // 主修课程
};
测试:
出了些问题,当我们调用at对象中的_name时编译器说我们的_name不明确。
因为我们前面继承的时候Student和Teacher中各有一份Person中的_name。这两个_name又被Assistant继承了下来,所以就导致at对象中有两个_name。当我们调用这两个_name的时候就会出现不明确的情况,编译器不知道是继承自Student中的_name还是继承自Teacher中的_name。这就是菱形继承所导致的二义性。
所以此时就要指明是哪个_name。加上类域限定就行。
但是这个跟我们想要的功能是不符合的,我们从父类继承下来的属性是为当前的子类所用的,所以说只要一个_name就能代表子类对象的名字了,无缘无故多了一个,这就出现了数据冗余的情况。
如果想要同时解决二义性和数据冗余,就要用到虚拟继承。
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。
那么怎么使用呢?
看图:
在中间部分加上virtual就行。
此时就不会有二义性的问题了。
但是Student和Teacher中的_name还是存在的,而且和at中的_name都是同一个:
但是面试的时候可能面试官可能会问这样一个问题:
什么是菱形继承?菱形继承的问题是什么?
这个问题好说,刚刚已经讲了。
但是当面试官掏出下面这个问题时,我不知道你又该如何应对:
什么是菱形虚拟继承?其是如何解决数据冗余和二义性的?
ok,这个就稍微复杂点了。不过没有那么难。
我这里要搞一个新的例子,上面这个例子讲起来比较麻烦。
代码如下(先不虚拟继承):
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 = 10;
d._b = 20;
d.C::_a = 30;
d._c = 40;
d._d = 50;
return 0;
}
到这可以再画个图:
更细致点是这样:
这就是菱形继承下的结构。
初始情况下:
各位有没有发现虚拟继承下有八个字节被浪费了??
其实不是浪费,而是用在了其他的的地方。有没有发现B::_b上方四个字节的和C::_c上方的四个字节存放的东西非常像是地址。
我们先来观察一下B::_b上面的:
由于是小端存储,所以地址看上去是倒过来的。
可以看到,这个地址处存放的值是0,但是重点先不在这,这个位置是一个预留的位置,先不讲这个,等会再说。我们再看下面四个字节的地址中存放的是14,但是这是十六进制的,所以这个数是20。这是干嘛的?
再看C::_c上面的:
也是看下面的四个字节,存放的是 0c,十六进制就是12。干啥的?
不卖关子了,这里存放的20和12就是一个偏移量,用来计算当前&d处中继承的B\C中的地址到那个共同的 _a 的地址的偏移量。不信我们可以算算:
所以当想要通过 B:: 或 C:: 来访问_a时就可以通过那个偏移量来计算。而上面的B::_b和C::_c上面的那个地址所指向的空间用专业术语来说就叫做虚基表。
那么我们前面画的那个图就有问题了,应该是这样的:
所以我们虚拟继承切片的时候就要进行偏移量的计算,相较于直接菱形继承的直接访问效率会低一点。
有的同学可能会说,这里好像也没有解决掉数据冗余的问题啊,甚至还多用了四个字节,我只能说小了,格局小了。如果说_a是一个很大的数据呢?比如说存放2000个int的数组,请问这时你该如何应对?所以说这里看起来更浪费了是因为_a就是一个int而已,但是如果我们实际应用时不太可能就一个int(当然最好不要产生菱形继承)。
我们可以看看D类有多大:
24个字节,也就是_a、_b、_c、_d这4个int 和 两个指针(32位)。
上面讲的这些是有关对象模型的知识,各位如果感兴趣的话,可以看看《深度探索C++对象模型》这本书。
再来一个问题:
B类有多大?
先看一下:
正常来讲,B里面存放的有_b和_a,但是这里大小为12,所以这里的应该也存放了个指针。
我们来看不用虚继承B的大小:
为8个字节,那么就更能确定了,B中有一个指针,和前面D中的逻辑一样,所以虚继承也影响到了B的对象模型。
来验证一下:
然后再看下前面的指针存放的是啥:
里面只做了一件事,就是打印B中的_a。
好,如果说我们的传参穿的是B类的对象,没什么问题,用非虚拟继承和虚拟继承都可以找到B中的_a,而且非虚拟继承的速度还快一点。
但是如果我们传的是D类的对象的地址呢?编译器不知道我们到底是想要B中的_a还是D中的_a,也就出现了二义性的问题,但是切片的时候还是会将B中的传过去,就是因为对象模型的不同。
上面的结果就是对象模型不同所导致的。虚继承也就导致了对象模型比非虚继承的对象模型稍微复杂了一点。
那么这里的虚拟继承应该从哪里开始呢?
答案是:
至于为什么,结合刚讲或者自己写一个这样的继承关系的类来捋一捋,看你能明白不。
很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
拿我们生活中的例子来说:
- 父类:人
子类:学生
那么学生可以看作是一个人。- 父类:植物
子类:牡丹花
那么牡丹花可以看作是一种植物上面就是继承is - a的关系。
组合是一种has-a的关系。假设B组合了A,每个B对象中都有n个A对象,其中
n >= 1。就是类B中的成员中包含有类A的成员。例子:
- 类A:轮胎
类B:汽车
那么可以说,一个汽车有几个轮胎。- 类A:眼睛
类B:脑袋
那么可以说,脑袋有两个眼睛。
上面就是has - a的关系。但是还有些是既可以继承又可以组合。
上面就是
例子:
- 类A:铁
类B:锅
既可以说过可以看作是一块铁,也可以说锅是由一堆铁组成的。- 类A:vector/list/dequeue
类B:stack
那么stack可以是由vector或list或dequeue组成的,而stack中也可以有vector或或list或dequeue。
但是要注意:当一个类既可以继承又可以组合时,能够使用对象组合就优先使用对象组合,而不是类继承。
来点专业术语,看不懂的同学直接跳过这点,去看下面的解释。
继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
上面有两个需要解释的东西,一个是白箱,一个是黑箱。
白箱就是我们实现某种功能的时候,这项功能的内部细节可见,比如说实现一个oj判题,设计判题的人是能够大概知道写这道题目的人会以什么样的方法来实现这道题,可能有多种方法,但是出题人只要把控好一些特殊例子或将细节上把握好,就能够判断出做题人的方法是否正确。
黑箱就是实现某种功能时其内部的细节不知道,是摸着石头过河的。实现完全靠程序员凭借自身能力来实现某些未知细节。
还要解释一下耦合度。
给个例子,当公司要组队出去玩的时候,先有两种方法可供选择。
一种是让所有人按照规定时间去干哪些事情。比如说早上九点的时候集合,一同出发,但此时可能就会出现某些掉队的人,例如睡过头的、走之前想上厕所的等等。此时就会导致出发的事件延迟,影响到别人的进度。这就是拼团耦合度高导致效率低下。
。。。
另一种方式是公司只管将某地点的攻略给员工们,员工们自己按照攻略来打卡游玩。这时候就不会出现第一种的场景,睡过头的还可以继续睡,睡醒了再起床去玩,而且还影响不到别人的进度。这就是自由行动,耦合度就会低,员工们互不干扰。
类比到继承和组合,继承就是第一种方式,组合就是第二种方式。
类比不出来的话来举个例子:
一个A类中有一个公有成员,九个保护成员。一个普通的B类。
当B类用public继承A类时,我们就能够在B类中用到A中的所有成员。耦合度就高。我们前期写代码时还没事,但是前期一旦多用了A中的多个成员,后期我们改一个A类的成员,就可能会牵动到很多的地方。
当B类中成员中有一个A类对象时,B类只要不是A类的友元,B类中就只能用到A类的那个公有成员。耦合度就低了很多。后期除非是改了那个A类中的公有成员,不然就不会影响到B中的代码。
所以实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。
到此结束。。。