继承机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
举个例子,假如我们现在要实现一个学校人员管理系统。那么就将学校人员分为比如说学生、教师、保安、宿管等等数个类。
那么这些类中,都有一部分共同的属性,比如说姓名、年龄、电话、家庭住址等等。如果我们在每一个类中都手动重复定义这些共有的属性,就会非常的鸡肋。但是这些类中每个类也有其独特的属性,比如说学生类可以有专业、班级、学号等,这些属性在其他类中是不会重复,因此我们可以通过继承来解决这些问题。
所谓继承,就是让一个类来继承另一个类中的属性和方法。
就像我们上面例子中的那样,我们可以实现一个最基础的类,里面定义上姓名、年龄、电话、家庭住址等属性和方法,然后让学生、教师、保安这三个类来继承这个基础类中的属性和方法。
在继承中,通常称上面的基础类为父类(基类),而学生、教师、保安这三个类统称为子类(派生类)。
上代码:
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "flash"; // 姓名
int _age = 19; // 年龄
};
// 继承后父类的Person的成员(成员函数+成员变量)都会变成子类的一部分。这里体现出了
//Student和Teacher复用了Person的成员。下面我们使用监视窗口查看Student和Teacher对象,可以看到变量的复用。调用Print可以看到成员函数的复用。
class Student : public Person
{
protected:
int _id; // 学号
};
class Teacher : public Person
{
protected:
int _jobid; // 工号
};
类成员/继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类的public成员 | 派生类的public成员 | 基类的protected成员 | 派生类的private成员 |
基类的protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
总结一下:
B类对象访问fun函数时,B的fun和A的fun都是在代码段中的,当B类对象访问时只能跑到B类的代码段中去找fun,想访问A类中的成员时,就要加上类域限定符去访问A类的成员函数fun,但是这里主要原因还是对象中只能存放成员变量。
Student继承Person的就是粉色部分,下面的_No才是真正子类创建的。
class Person
{
public:
std::string _name; // 姓名
std::string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public:
int _No; // 学号
};
void Test()
{
Student sobj;
//派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。
// 这里有个形象的说法叫切片或者切割。
//寓意把派生类中父类那部分切来赋值过去。
Person pobj1 = sobj; //赋值对象,就是将最开始图中子类的粉色部分赋值给父类对象。
Person* pobj2 = &sobj;//赋值指针,就是子类对象的地址给父类对象。
Person& pobj3 = sobj;//赋值引用,也是引用的粉色部分。
//这里虽然是不同类型,但是不是隐式类型转换(光看第三个引用也能赋值就可以看出来),算是一个特殊支持,语法天然支持的。
//基类对象不能赋值给派生类对象
sobj = pobj; //error
// 基类的指针可以通过强制类型转换赋值给派生类的指针
pp = &sobj
Student* ps1 = (Student*)pp; // 这种情况转换时可以的。
ps1->_No = 10;
pp = &pobj;
Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问题
ps2->_No = 10;
}
6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的:
当我们构造子类对象的时候,是先将父类中的成员先构造出来,然后再构造出子类对象,按照栈的顺序的话,就要先析构子类,再析构父类。
如果让我们自己写的话,就不能保证这个顺序了,比如说如果有需要new的成员,若让我们自己写delete和父类析构函数的顺序,是不能保证每个人都一样的。所以让子类析构函数后面自动调用父类析构,这样就能保证先开辟的后析构,也就是栈中的顺序。
所以说,我们是不需要手动调用父类析构函数的。
就是返回对象的地址就好了,默认生成的就够了。
还有个const的。这里有一个场景就是返回nullptr,目的就是不想让别人拿到这个类型的对象的地址。
友元关系不能继承。也就是说基类友元不能访问子类私有和保护成员。
如果想要让基类的友元也成为子类的友元,那就再在子类中也写一个就行。但是友元能不用就不用。
静态成员在整个继承体系中只有一份,不管是继承体系中的哪个类或者类定义的对象,都是同一个。
静态成员变量在全局区\静态区中,程序结束后才会释放。所以无论派生出多少个子类,都只有一个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。
把people_conut代码改一下:
地址也都是相同:
单继承:一个子类只有一个直接父类时称这个继承关系为单继承。
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承。
这才叫多继承,assistant有两个直接父类。如何定义一个不能被继承的类?
下来我们来看一道题目:
A:p1 == p2 == p3
B:p1 < p2 < p3
C:p1 == p3 != p2
D:p1 != p2 != p3
先来讲解一下:
首先,前面讲了切片。所以这个题不可能那么简单的选A。然后要确定出继承的先后顺序。就是按照子类继承时冒号后面的顺序,也就是这里:
先继承Base1,后继承Base2。
换成这里的继承就是:
所以切片的时候就好说了:
所以本题答案为 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都是同一个:
接下来我们通过调试来解释什么是菱形虚拟继承,其又是如何解决数据冗余和二义性的:
这里要搞一个新的例子,上面这个例子讲起来比较麻烦。代码如下(先不虚拟继承):
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的对象模型。
通过调试来看看:
然后再看下前面的指针存放的是啥:
也有个偏移量8。就是用来找_a的。
那至于为什么要这样设计,看个场景,给出如下函数:
里面只做了一件事,就是打印B中的_a。如果说我们的传参穿的是B类的对象,没什么问题,用非虚拟继承和虚拟继承都可以找到B中的_a,而且非虚拟继承的速度还快一点。
但是如果我们传的是D类的对象的地址呢?编译器不知道我们到底是想要B中的_a还是D中的_a,也就出现了二义性的问题,但是切片的时候还是会将B中的传过去,就是因为对象模型的不同。
按照下面的代码:
如果采用虚拟继承:
如果B中采用非虚拟继承:
上面的结果就是对象模型不同所导致的。虚继承也就导致了对象模型比非虚继承的对象模型稍微复杂了一点。再来个例子:
这种的继承关系也算是菱形继承。那么这里的虚拟继承应该从哪里开始呢,答案是:
因此很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。拿我们生活中的例子来说:
组合是一种has-a的关系。假设B组合了A,每个B对象中都有n个A对象,其中n >= 1。就是类B中的成员中包含有类A的成员:
就是一个类组成了另一个类的成员。但是还有些是既可以继承又可以组合:
但是要注意:当一个类既可以继承又可以组合时,能够使用对象组合就优先使用对象组合,而不是类继承。
继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
上面有两个需要解释的东西,一个是白箱,一个是黑箱。
白箱就是我们实现某种功能的时候,这项功能的内部细节可见,比如说实现一个oj判题,设计判题的人是能够大概知道写这道题目的人会以什么样的方法来实现这道题,可能有多种方法,但是出题人只要把控好一些特殊例子或将细节上把握好,就能够判断出做题人的方法是否正确。
黑箱就是实现某种功能时其内部的细节不知道,是摸着石头过河的。实现完全靠程序员凭借自身能力来实现某些未知细节。
给个例子,当公司要组队出去玩的时候,先有两种方法可供选择。
一种是让所有人按照规定时间去干哪些事情。比如说早上九点的时候集合,一同出发,但此时可能就会出现某些掉队的人,例如睡过头的、走之前想上厕所的等等。此时就会导致出发的事件延迟,影响到别人的进度。这就是拼团耦合度高导致效率低下。
另一种方式是公司只管将某地点的攻略给员工们,员工们自己按照攻略来打卡游玩。这时候就不会出现第一种的场景,睡过头的还可以继续睡,睡醒了再起床去玩,而且还影响不到别人的进度。这就是自由行动,耦合度就会低,员工们互不干扰。
类比到继承和组合,继承就是第一种方式,组合就是第二种方式。举个例子:
一个A类中有一个公有成员,九个保护成员。一个普通的B类。
当B类用public继承A类时,我们就能够在B类中用到A中的所有成员。耦合度就高。我们前期写代码时还没事,但是前期一旦多用了A中的多个成员,后期我们改一个A类的成员,就可能会牵动到很多的地方。
当B类中成员中有一个A类对象时,B类只要不是A类的友元,B类中就只能用到A类的那个公有成员。耦合度就低了很多。后期除非是改了那个A类中的公有成员,不然就不会影响到B中的代码。
所以实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。