【C++】继承

继承的概念及定义

继承机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。

举个例子,假如我们现在要实现一个学校人员管理系统。那么就将学校人员分为比如说学生、教师、保安、宿管等等数个类。

那么这些类中,都有一部分共同的属性,比如说姓名、年龄、电话、家庭住址等等。如果我们在每一个类中都手动重复定义这些共有的属性,就会非常的鸡肋。但是这些类中每个类也有其独特的属性,比如说学生类可以有专业、班级、学号等,这些属性在其他类中是不会重复,因此我们可以通过继承来解决这些问题。

所谓继承,就是让一个类来继承另一个类中的属性和方法。

就像我们上面例子中的那样,我们可以实现一个最基础的类,里面定义上姓名、年龄、电话、家庭住址等属性和方法,然后让学生、教师、保安这三个类来继承这个基础类中的属性和方法。

在继承中,通常称上面的基础类为父类(基类),而学生、教师、保安这三个类统称为子类(派生类)。

上代码:

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; // 工号
};

继承关系和访问限定符

【C++】继承_第1张图片

类成员/继承方式 public继承 protected继承 private继承
基类的public成员 派生类的public成员 基类的protected成员 派生类的private成员
基类的protected成员 派生类的protected成员 派生类的protected成员 派生类的private成员
基类的private成员 在派生类中不可见 在派生类中不可见 在派生类中不可见

总结一下:

  1. 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
  2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
  3. 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected> private。
  4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
  5. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。

继承中的作用域

  1. 在继承体系中基类和派生类都有独立的作用域。
  2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
  3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
  4. 注意在实际中在继承体系里面最好不要定义同名的成员。

【C++】继承_第2张图片
【C++】继承_第3张图片
【C++】继承_第4张图片
继承时,不是将父类中的代码直接拷贝到子类中的。

B类对象访问fun函数时,B的fun和A的fun都是在代码段中的,当B类对象访问时只能跑到B类的代码段中去找fun,想访问A类中的成员时,就要加上类域限定符去访问A类的成员函数fun,但是这里主要原因还是对象中只能存放成员变量。

基类和派生类对象赋值转换

  • 派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
  • 基类对象不能赋值给派生类对象。
  • 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(RunTime TypeInformation)的dynamic_cast 来进行识别后进行安全转换。

【C++】继承_第5张图片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个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的:

  1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
  2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
  3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
  4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
  5. 派生类对象初始化先调用基类构造再调派生类构造。
  6. 派生类对象析构清理先调用派生类析构再调基类的析构。
  7. 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。
    【C++】继承_第6张图片

子类编译器默认生成的构造函数

  1. 对于子类自己的成员,与类和对象中的一样,内置类型不处理,自定义类型调用其默认构造函数。(无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数)
  2. 继承的父类的成员,必须调用父类的构造函数进行初始化。
    【C++】继承_第7张图片

子类编译器默认生成的拷贝构造函数

  1. 自己成员,跟类和对象一样。 (内置类型值拷贝,自定义类型调用它的拷贝构造)。
  2. 继承的父类成员,必须调用父类拷贝构造初始化。
    在这里插入图片描述

子类编译器默认生成的赋值重载

  1. 自己成员,跟类和对象一样。 (内置类型值拷贝,自定义类型调用它的赋值重载)。
  2. 继承的父类成员,必须调用父类赋值重载。
    在这里插入图片描述
    栈溢出一般都是无限递归了,子类的operator=和父类的operator=名字冲突了,前面也讲了,当父子函数名冲突时,会隐藏掉父类的函数。所以这里是一直在调用子类的赋值重载。
    【C++】继承_第8张图片

子类编译器默认生成的析构函数

  1. 自己的成员,内置类型不处理,自定义类型调用其的析构函数。
  2. 继承的成员,调用父类析构函数处理。
    【C++】继承_第9张图片
    子类析构函数后面会自动调用父类析构,这样就能保证先开辟的后析构,也就是栈中的顺序。因此我们是不需要手动调用父类析构函数的。

【C++】继承_第10张图片

【C++】继承_第11张图片

当我们构造子类对象的时候,是先将父类中的成员先构造出来,然后再构造出子类对象,按照栈的顺序的话,就要先析构子类,再析构父类。

如果让我们自己写的话,就不能保证这个顺序了,比如说如果有需要new的成员,若让我们自己写delete和父类析构函数的顺序,是不能保证每个人都一样的。所以让子类析构函数后面自动调用父类析构,这样就能保证先开辟的后析构,也就是栈中的顺序。

所以说,我们是不需要手动调用父类析构函数的。

子类编译器默认生成的&重载

就是返回对象的地址就好了,默认生成的就够了。

【C++】继承_第12张图片

还有个const的。这里有一个场景就是返回nullptr,目的就是不想让别人拿到这个类型的对象的地址。

继承于友元

友元关系不能继承。也就是说基类友元不能访问子类私有和保护成员。

【C++】继承_第13张图片

如果想要让基类的友元也成为子类的友元,那就再在子类中也写一个就行。但是友元能不用就不用。

【C++】继承_第14张图片

继承与静态成员

静态成员在整个继承体系中只有一份,不管是继承体系中的哪个类或者类定义的对象,都是同一个。

静态成员变量在全局区\静态区中,程序结束后才会释放。所以无论派生出多少个子类,都只有一个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。

【C++】继承_第15张图片

把people_conut代码改一下:

在这里插入图片描述

地址也都是相同:

【C++】继承_第16张图片

菱形继承

单继承

单继承:一个子类只有一个直接父类时称这个继承关系为单继承。

【C++】继承_第17张图片

多继承

多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承。

【C++】继承_第18张图片

这才叫多继承,assistant有两个直接父类。如何定义一个不能被继承的类?

  1. C++98:将父类构造函数私有化,子类创建对象时无法调用构造函数。
    【C++】继承_第19张图片
  2. C++11:用final关键字。final的其他功能
    【C++】继承_第20张图片

下来我们来看一道题目:

【C++】继承_第21张图片

A:p1 == p2 == p3
B:p1 < p2 < p3
C:p1 == p3 != p2
D:p1 != p2 != p3

先来讲解一下:

首先,前面讲了切片。所以这个题不可能那么简单的选A。然后要确定出继承的先后顺序。就是按照子类继承时冒号后面的顺序,也就是这里:

在这里插入图片描述

先继承Base1,后继承Base2。

【C++】继承_第22张图片

换成这里的继承就是:

【C++】继承_第23张图片

所以切片的时候就好说了:

【C++】继承_第24张图片

所以本题答案为 C。接下来再用调试来讲解一下:

【C++】继承_第25张图片

我们发现p2的地址是大于p1和p3的。所以其在栈中是这样的:

【C++】继承_第26张图片

在栈帧中是将_b1、_b2、_d挨个push进去的。再看修改这几个值:

【C++】继承_第27张图片

菱形继承

菱形继承是多继承的一种特殊情况。

【C++】继承_第28张图片

下半部分就是多继承,但是两个父类又共同继承了一个父类。

菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。

【C++】继承_第29张图片

在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; // 主修课程
};

【C++】继承_第30张图片

当我们调用at对象中的_name时编译器说我们的_name不明确。

因为我们前面继承的时候Student和Teacher中各有一份Person中的_name。这两个_name又被Assistant继承了下来,所以就导致at对象中有两个_name。当我们调用这两个_name的时候就会出现不明确的情况,编译器不知道是继承自Student中的_name还是继承自Teacher中的_name。这就是菱形继承所导致的二义性。

所以此时就要指明是哪个_name。加上类域限定就行。

在这里插入图片描述

但是这个跟我们想要的功能是不符合的,我们从父类继承下来的属性是为当前的子类所用的,所以说只要一个_name就能代表子类对象的名字了,无缘无故多了一个,这就出现了数据冗余的情况。

如果想要同时解决二义性和数据冗余,就要用到虚拟继承。

虚拟继承解决菱形继承的二义性和数据冗余问题

虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。

【C++】继承_第31张图片

在中间部分加上virtual就行。

【C++】继承_第32张图片

此时就不会有二义性的问题了。

【C++】继承_第33张图片

但是Student和Teacher中的_name还是存在的,而且和at中的_name都是同一个:

【C++】继承_第34张图片

接下来我们通过调试来解释什么是菱形虚拟继承,其又是如何解决数据冗余和二义性的:

这里要搞一个新的例子,上面这个例子讲起来比较麻烦。代码如下(先不虚拟继承):

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;
}

【C++】继承_第35张图片
【C++】继承_第36张图片
【C++】继承_第37张图片
【C++】继承_第38张图片
【C++】继承_第39张图片
【C++】继承_第40张图片

这就是菱形继承下的结构。再来看菱形虚拟继承下的结构。

【C++】继承_第41张图片
【C++】继承_第42张图片
【C++】继承_第43张图片
【C++】继承_第44张图片
【C++】继承_第45张图片
【C++】继承_第46张图片
【C++】继承_第47张图片

我们发现虚拟继承下有八个字节被浪费了,被用在了其他的的地方。我们发现B::_b上方四个字节的和C::_c上方的四个字节存放的东西非常像是地址。

我们先来观察一下B::_b上面的:由于是小端存储,所以地址看上去是倒过来的。

【C++】继承_第48张图片

可以看到,这个地址处存放的值是0,但是重点先不在这,这个位置是一个预留的位置,先不讲这个,等会再说。我们再看下面四个字节的地址中存放的是14,但是这是十六进制的,所以这个数是20。这是干嘛的?再看C::_c上面的:

【C++】继承_第49张图片

再看下面的四个字节,存放的是 0c,十六进制就是12。这里存放的20和12就是一个偏移量,用来计算当前&d处中继承的B\C中的地址到那个共同的 _a 的地址的偏移量:

【C++】继承_第50张图片

所以当想要通过 B:: 或 C:: 来访问_a时就可以通过那个偏移量来计算。而上面的B::_b和C::_c上面的那个地址所指向的空间用专业术语来说就叫做虚基表。

那么之前画的那个图就有问题了,应该是这样的:

【C++】继承_第51张图片

所以我们虚拟继承切片的时候就要进行偏移量的计算,相较于直接菱形继承的直接访问效率会低一点。

有的同学可能会说,这里好像也没有解决掉数据冗余的问题啊,甚至还多用了四个字节,我只能说小了,格局小了。如果说_a是一个很大的数据呢?比如说存放2000个int的数组,请问这时你该如何应对?所以说这里看起来更浪费了是因为_a就是一个int而已,但是如果我们实际应用时不太可能就一个int(当然最好不要产生菱形继承)。

我们可以看看D类有多大:

【C++】继承_第52张图片

24个字节,也就是_a、_b、_c、_d这4个int 和 两个指针(32位)。

上面讲的这些是有关对象模型的知识,各位如果感兴趣的话,可以看看《深度探索C++对象模型》这本书。

B类的大小:

【C++】继承_第53张图片

正常来讲,B里面存放的有_b和_a,但是这里大小为12,所以这里的应该也存放了个指针。我们来看不用虚继承B的大小:

在这里插入图片描述

为8个字节,那么就更能确定了,B中有一个指针,和前面D中的逻辑一样,所以虚继承也影响到了B的对象模型。

通过调试来看看:

【C++】继承_第54张图片
【C++】继承_第55张图片

然后再看下前面的指针存放的是啥:

【C++】继承_第56张图片

也有个偏移量8。就是用来找_a的。

那至于为什么要这样设计,看个场景,给出如下函数:

【C++】继承_第57张图片

里面只做了一件事,就是打印B中的_a。如果说我们的传参穿的是B类的对象,没什么问题,用非虚拟继承和虚拟继承都可以找到B中的_a,而且非虚拟继承的速度还快一点。

但是如果我们传的是D类的对象的地址呢?编译器不知道我们到底是想要B中的_a还是D中的_a,也就出现了二义性的问题,但是切片的时候还是会将B中的传过去,就是因为对象模型的不同。

按照下面的代码:

【C++】继承_第58张图片

如果采用虚拟继承:

【C++】继承_第59张图片

如果B中采用非虚拟继承:

【C++】继承_第60张图片

上面的结果就是对象模型不同所导致的。虚继承也就导致了对象模型比非虚继承的对象模型稍微复杂了一点。再来个例子:

【C++】继承_第61张图片

这种的继承关系也算是菱形继承。那么这里的虚拟继承应该从哪里开始呢,答案是:

【C++】继承_第62张图片

因此很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。

继承和组合

public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。拿我们生活中的例子来说:

  1. 父类:人
    子类:学生
    那么学生可以看作是一个人。
  2. 父类:植物
    子类:牡丹花
    那么牡丹花可以看作是一种植物
    上面就是继承is - a的关系。

组合是一种has-a的关系。假设B组合了A,每个B对象中都有n个A对象,其中n >= 1。就是类B中的成员中包含有类A的成员:

  1. 类A:轮胎
    类B:汽车
    那么可以说,一个汽车有几个轮胎。
  2. 类A:眼睛
    类B:脑袋
    那么可以说,脑袋有两个眼睛。
    上面就是has - a的关系。
    【C++】继承_第63张图片

就是一个类组成了另一个类的成员。但是还有些是既可以继承又可以组合:

  1. 类A:铁
    类B:锅
    既可以说过可以看作是一块铁,也可以说锅是由一堆铁组成的。
  2. 类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中的代码。

所以实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。

你可能感兴趣的:(C++,c++)