【c++进阶系列】:万字详解继承

发布于:2025-08-18 ⋅ 阅读:(11) ⋅ 点赞:(0)

🔥 本文专栏:c++
🌸作者主页:努力努力再努力wz

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

💪 今日博客励志语录厉害的人不是没有眼泪,而是含着眼泪还在跑


引入

那么一提到继承,想必各位读者并不陌生,那么继承就是面向对象的三大特性之一,那么在正式讲解继承之前,那么我们先来看一下继承所应用的场景,那么假设这里我们要实现一个教务管理系统,那么我们用面向对象的思想来实现该教务管理系统,那么首先我们就得分析这里教务管理系统所涉及到的对象,其中就包括学生以及老师,那么接下来我们就得定义出这些对象,那么学生就定义一个student类,而老师就定义一个teacher类,那么学生类和老师类都得分别包含学生以及老师的基本属性,其中学生类就得包含年龄以及姓名和性别以及学号,而老师则包含年龄和性别以及姓名和工号,但是我们发现这两个类中的部分属性明显有重合,比如年龄和性别以及姓名,那么这里我们就可以尝试将这些重合或者说共有的属性给提取出来,然后封装到一个person类中,那么接下来就只需要让teacher类以及student类分别继承这个person类,那么这两个类就分别可以获取这个person类的成员变量以及成员函数,那么我们可以写一个代码来验证这种情况:

#include<iostream>
using namespace std;
class person
{
public:
	int age;
	char sex;
	const char* name;
};
class student :public person
{
public:
	int st_id;
};
class teacher :public person
{
public:
	int te_id;
};
int main()
{
	studen wz;
    teacher wzh;
	return 0;
}

在这里插入图片描述

那么我们可以通过调试窗口可以清晰的看到,那么student对象内部以及teacher对象内部确实拥有了person类里面的成员变量,所以我们就可以知道继承的一个应用场景,那么就是可以提取不同类中的共有属性,将其封装到一个类,避免我们编写重复的代码,那么其他类只需继承该类,那么就能获取该类的成员变量以及成员函数,本质上就是一种代码的复用,那么到后期,学了模版以及继承后,你会发现c++越来越强调我们不要去做一些低效的事情,比如模版,那么模版的出现则是让我们不需要自己去手动在编写多份代码逻辑相同但是处理数据类型不同的冗余代码,而继承的出现则是不需要我们自己在去编写多个不同类的共有的属性,本质上两者都是一种代码的复用,我们之所以能偷这个懒,其实底层是由编译器为我们默默承担了一切

那么继承这里有一些专业术语,而一般被继承的类我们称之为父类或者基类,而继承父类或者基类的类我们则称之为子类或者派生类,那么继承的语法则是:

class 子类名: 访问限定符 父类类名
{
    
};
//后面不加访问限定符默认是私有继承

继承

继承方式

那么我们知道子类继承父类,那么子类能够获取父类的成员变量以及成员函数,但是读者有没有想过,子类是否就一定能够获取父类的全部的成员变量以及成员函数,有没有可能子类只能获取父类的部分的成员变量以及成员函数或者子类压根就获取不了父类的成员变量以及函数
读者可能会有疑惑,因为继承的一个应用场景就是我们不用在编写这些类中共有的属性,只需编写一份代码,也就是将这些共有属性封装到父类中,那么其他类只需继承父类就从而获取到父类的属性,那么这里不让子类获取父类的属性,感觉和继承的应用场景有点不相符合

那么在讲解这个问题的时候,我还是举一个生活中的例子来辅助理解,那么c++中有继承,那么我们生活中同样也有继承,那么生活中的继承一般指的就是儿子或者女儿继承父亲的家业或者财产,那么作为父亲,你可以选择将自己的财产全部交给自己的子女,但是作为父亲的你还考虑到一个情况,那么就是如果我将财产全部都分配给了子女,那么有可能子女获取到我的所有的财富之后,那么就摆烂或者躺平不奋斗了,那么为了能够让子女能够自己奋斗创造属于自己的事业,所以你可以选择分配一部分财产给你的子女,而不是将全部财产给你的子女,那么这部分财产能够让他过上较为舒服的生活但是他们还需要自己奋斗,其余的财产就用来自己养老或者做慈善来成立基金会造福社会

那么c++的设计者也是考虑到了刚才的场景,因为可能会存在这样的需求,那就是用户他不想让父类的所有的成员函数以及成员变量都交给子类,所以这里c++实现这里子类对于父类属性的所有权就是通过访问限定符来实现,你也可以在上文将的继承的语法中,发现后面会出现访问限定修饰符,那么其代表的就是继承方式,那么子类对于父类属性的拥有权是由两部分综合决定,分别是修饰父类成员变量以及成员函数的访问限定符以及继承方式后面的访问限定修饰符。

那么继承的规则就是比较父类成员变量以及函数中被修饰的访问限定符和继承方式中的访问修饰限定符,得较小的那个访问限定符,那么再根据最终得到的该访问限定符的访问范围决定其子类是否能够看见访问该属性

那么读者看到这里会对比较,较小这两个词感到疑惑,那么首先要知道的是,访问修饰限定符都有各自访问的范围,那么其可以按照访问的范围来比较:
p u b l i c > p r o t e c t > p r i v a t e public>protect>private public>protect>private
那么接下来如何进行比较呢,那么假设父类有一个成员变量member它被private修饰,但是子类继承该父类的方式是protect:

class father
{
    privateint member;
};
class son: protect father
{
    
};

那么此时就需要继承方式的访问限定符protect和父类中该成员变被修饰的访问限定符private比较,那么得到该属性最终的访问限定符是private,那么意味着该属性的访问范围只能在父类中访问,那么意味着子类无法访问,所以子类无法获取到该属性,而由此我们还可以得到一个结论,那么父类中的private属性的变量是一定不会被子类给继承到因为private是访问范围最小的访问限定符,同理如果子类采取private方式继承,那么意味着此时子类无法继承父类的所有属性

而此前我们在类和对象中一直没有提到的protect,那么在继承中终于可以登场,那么如果父类的某个属性最终决定得到的访问限定符是protect,那么意味着,此时该属性只能在父类中访问以及继承该父类的子类中访问,那么子类以及父类外部则无法访问到,而我们这里父类中每一个属性可能被三种访问限定符修饰,而继承方式又有三种,分别是共有(public),保护(protect)和私有(private)继承,那么总共组合下来,那么该属性的决定方式有9种情况:
在这里插入图片描述

但是实际上其实我们编写代码的时候,一般都是采取共有继承以及保护继承,那么私有继承的场景几乎很少,那么共有继承就像你的父亲将自己的财产捐给社会,那么社会上的人士都可以享用,而保护继承则是父亲将财产只分配给家族的人中享用非家族人无法享受,而私有继承则是父亲一个人独自享受自己的财产,不分配给任何人


那么这里要注意的一点就是,有一些读者会认为,那么子类存在无法继承获取到父类的部分属性的情况,那么子类对象中就没有必要再为父类无法获取的属性分配空间,而是只开辟继承获取到的父类的属性,但是事实真是如此吗,那么我们可以写一个简单的代码来验证一下,这里我们将person类的成员变量全部设置为private属性:

#include<iostream>
class person
{
private:
    int age;
    char sex;
    const char* name;
};
class student :public person
{
public:
	int st_id;
};
int main()
{
 student wz;
 return 0;
}

在这里插入图片描述

那么我们查看运行窗口,发现子类对象的内存布局中还是为父类的所有成员变量开辟了空间,并且子类的内存布局则是先是排列父类的部分也就是父类的成员变量,然后再排列自己特有的成员变量,并且整体是遵从内存对齐,所以这里c++的处理则是不管是子类能否继承该父类的属性,那么都会将父类的所有的成员变量给拷贝到子类中,意味着子类会拥有完整的父类对象的副本,所以我们一直说的是子类无法访问或者看到父类的成员变量,那么我们知道了子类内部会先排列父类的所有的成员变量并且按照内存对齐的原则分布,所以理论上,我们知道了子类的内存布局,那么我们可以尝试用指针来访问子类中的父类的私有属性的成员变量,根据子类的内存布局是先排列父类然后是子类,并且按照内存对齐规则,那么我们就能够计算到父类每个成员变量的偏移量从而访问,比如这里父类的第一个int类型的成员变量age是在子类的起始位置处分配4个字节,那么我们可以利用指针来访问:

int main()
{
	studen wz;
    int* ptr1 = (int*) & wz;
    cout << "age :" << *ptr1 << endl;
    return 0;
}

在这里插入图片描述

但是这个方式其实是不合法的,那么建议读者一般是在父类中定义一个共有或者保护的接口来提供给子类访问父类私有属性的成员变量
而至于为什么这里c++的设计者要要让子类内部拥有完整的父类对象,而不是只为其能够访问到的父类的成员变量分配空间,那么就和下文所讲的切片有关

切片

那么我们知道c++存在类型转化,比如将一个int类型的数据赋予给一个double类型的数据,那么由于这两个数据类型不一致,那么这里会存在隐式类型转化,那么会先构建一个double类型的中间临时变量,然后再将中间临时变量的值拷贝给double类型的数据,此时发生了整形的提升,由原来4字节的int提升到了8字节,而如果将int类型的数据赋予给char类型,那么由于char类型只有一个字节,那么就会发生数据的截断,同理中间也要开辟临时变量,内置类型也可以转化为自定义类型,其同样也会构建一个中间的临时变量,然后分为两步完成,首先调用其构造函数来初始化这个中间临时变量,然后再调用拷贝函数构造函数或者赋值运算符重载函数来初始化或者赋值给该自定义类型,但是目前的编译器都进行了优化,也就是直接调用目标对象的构造函数或者赋值运算符重载函数来完成初始化或者赋值,而不需要两步来完成。

假设这里我们有一个父类和一个继承了当前父类的子类,那么这里同样也存在类型转化,那么我们可以用一个父类对象去接收一个子类对象,那么按照上文的原理,那么这里是否也会先构建一个中间的临时对象,然后再调用拷贝构造函数或者赋值函数来赋值给该父类对象呢?

那么这里我们可以写一个代码来验证一下,那么采取的方式就是引用,那么该引用是父类的引用,但是让其指向子类对象,那么如果中间产生了临时对象,那么临时对象具有常性,那么这里编译是无法通过的,因为只能通过const修饰的引用才能接收:

class person
{
public:
    int age;
    char sex;
    const char* name;
public:
    person(int _age = 20,char _sex = 0, const char* _name = "WangZhuo")
        : age(_age)
        , sex(_sex)
        , name(_name)
    {

    }
};
class student :public person
{
public:
	int st_id;
};
class teacher :public person
{
public:
	int te_id;
};
int main()
{
    student wz;
    person wzh=wz;
	person& l1=wz;
}

而运行这段代码,编译器没有报错,那么说明这里父类转化为子类并不会产生中间的临时变量,那么这里意味着父类的引用其指向的对象就是该子类对象本身,那么我们上文就介绍过子类对象的内存布局,我们知道子类对象的内存布局是先父后子,也就是先排列父类的成员变量,然后再排列子类的成员变量,并且其分布是满足内存对齐的规则,那么这里由于是父类的引用,那么父类的引用就只需要访问看到父类的部分,而不需要看到子类的部分,所以这里就会出现切片,那么编译器知道该子类的内存布局,那么子类前半部分是完整的父类部分,那么这里编译器就能计算得到父类起始位置的偏移量以及父类对象的大小,所以可以用引入来访问子类中的父类部分,但是父类的指针或者引用是无法访问到子类无法继承到的父类部分的成员变量

int main()
{
    
    student wz;
	person& l1=wz;
    cout << l1.age << endl;
    /* person* ptr=&wz;
    cout<<ptr->age<<endl;*/
	return 0;
}

在这里插入图片描述

那么有了上文的理论依据,那么此时我们如果将一个子类对象赋予给一个父类对象,想必读者此时对其原理不必陌生
那么假设有现在一个父类对象,其接收一个子类对象,那么这里我们知道此时不会生成中间临时变量,那么编译器由于知道子类的内存布局,是先父后子,并且子类拥有完整的父类部分的副本,那么编译器很容易得到计算得到父类部分的起始位置的偏移量以及父类对象的大小,然后直接将父类部分整体拷贝给父类对象,无需生成中间的临时对象,那么这里我们就能够解释上文为什么这里子类要存储完整的父类对象,那么原因就在这,因为这里类型转化的时候,那么将一个子类转化为父类,那么编译器肯定要得到父类的完整切片才可以,不可能你将子类转化为父类,得到的父类对象还是不完整的,其中只有部分成员变量,理论上,其实编译器确实可以做到只存储子类能够访问到的父类的部分成员变量,编译器实现这个其实不难,只需将继承到的父类部分的成员变量其按照内存对齐的规则排列即可,但是这里由于要保证切片的父类部分的完整性,所以必须子类得持有完整的父类的副本

int main()
{
    
    student wz;
	person l1=wz;
    cout << l1.name << endl;
	return 0;
}

在这里插入图片描述

那么知道子类部分能够转化为父类部分,那么父类部分能不能转化为子类部分呢,那么由上文可知,那么父子类的类型转化不会生成中间的临时变量,那么这里如果父类转化给子类,那么这里父类都没有子类的特有的成员变量,那么是无法转化的,那么子类能够转化父类的前提是子类有完整的父类的副本,但是父类的内存布局中,其没有子类特有部分,所以不可能允许父转子的情况出现,那么我们也可以简单写一个代码验证这点:

int main()
{
    
    person wz;
	student l1=wz;
    cout << l1.name << endl;
	return 0;
}

在这里插入图片描述

成员函数的继承

那么经过上文的讲解,那么想必读者知道了子类的内存布局是怎么样了,那么我们从开头就在说继承能够让子类获取父类的成员函数以及成员变量,而上文我们一直都在围绕成员变量的继承,那么这里我们便着重讲解一下成员函数的继承,那么首先要明确的一点是,那么子类的成员函数以及其继承到的父类的成员函数都是存放在代码段当中,那么想必大部分读者应该没有对子类对象不存储自己特有的以及继承到的父类成员函数有疑问,有的话,那么你就得好好回顾类和对象的知识,那么这里我还是简单提一嘴,因为我们会在代码中创建多份类的实例,那么这些类的实例调的同一个成员函数的代码逻辑肯定都是相同,不会存在每一个类的实例调用同一个fun1成员函数的代码逻辑竟然还是不同的,所以就没有必要在对象中存储,而是将其提取出来存放到代码段,到时候通过this指针来确定当前调用该成员函数的是哪个对象,其实这种设计就和继承的思想是很相似的

构造函数

那么这里我们就先来探讨类中最特殊的一个成员函数,也就是构造函数,那么构造函数虽然和普通的成员函数不同,没有返回值并且函数名和类名相同,但其实人家本质还是一个函数,其同样受到访问限定符的修饰,也可以被子类继承,那么对于子类的构造函数来说,那么这里我们知道子类的内存布局是由父类部分以及子类特有的成员变量两部分所构成,那这里很多读者会好奇子类的构造函数的行为,能否我自己定义子类的构造函数时,我自己来子类的父类部分初始化,那么这里我们就先写一份代码来验证一下:

#include<iostream>
using namespace std;
class person
{
public:
    int age;
    char sex;
    const char* name;
    person(int _age = 20,char _sex = 0, const char* _name = "WangZhuo")
        : age(_age)
        , sex(_sex)
        , name(_name)
    {

    }
};
class student :public person
{
public:
	int st_id;
    student(int _age,char _sex,const char* _name,int _id)
        :age(_age)
        ,sex(_sex)
        ,name(_name)
        ,st_id(_id)

    {
    }
};
int main()
{

    student wz(20,0,"xuzitao",1101);
    cout << wz.name << endl;
    return 0;
}

在这里插入图片描述

那么这里发现编译无法通过,也就是编译器是不允许我们自己尝试给子类的父类部分的成员变量进行初始化的,那么为什么编译器禁止这个行为呢,那么原因其实很简单,因为上文都说过父类的构造函数是可以被继承并且被子类使用的,那么这里父类已经有一个能够初始化父类对象的构造函数了,那么何必只需要你自己手动初始化父类部分的元素呢,所以编译器禁止这个行为的目的,就是想告诉你,那么初始化父类部分,那么就调用父类的构造函数初始化,而这里子类的特有部分的成员变量就交给该子类的构造函数来处理,那么这里编译器让我们用父类的构造函数初始化父类部分,其实将子类的初始化工作分成了两个环节分别是父类的初始化和子类特有部分的初始化,那么这里就有一个顺序问题,也就是先初始化父类部分还是子类特有部分

那么有的读者觉得,这里父类和子类谁先谁后好像并没有任何影响,但是实际上编译器还是做了处理,先调用父类的构造函数然后再调用再执行子类自己的特有的部分的构造函数的初始化,并且父类的构造函数初始化是只能在初始化列表中进行而无法再构造函数体内执行,那么编译器会先执行初始化列表然后再执行构造函数体,有些读者则是尝试在构造函数体内调用父类的构造函数是行不通的,在构造函数体写这样的代码来想来调用父类的构造函数

person::person(100,1,"xuzitao");

但是事实上这行代码其实是创建一个person类的匿名对象,那么不是调用父类的构造函数,那么调用父类的构造函数是在初始化列表中,并且写法应该是:

class person
{
public:
    int age;
    char sex;
    const char* name;
    person(int _age ,char _sex , const char* _name )
        : age(_age)
        , sex(_sex)
        , name(_name)
    {

    }
};
class student :public person
{
public:
	int st_id;
    student(int _id)
        : st_id(_id)
        ,person(10, 0, "xuzitao")
    {
    }
};

那么这里如果这里父类的构造函数有不带参数的构造函数,那么我们可以不要在初始化列表中调用父类的构造函数由编译器自动帮我调用,但是如果父类有带参数的构造函数,那么我们就必须在初始化列表中调用带参的构造函数,并且初始化列表的执行顺序也是会先执行父类的构造函数,那么即使你书写的顺序是子类在前父类的构造函数在后,实际上执行的顺序还是先执行父类的构造函数

普通成员函数

那么普通的成员函数如果最终的访问限定符不是private,那么就可以被子类给继承,意味着子类可以调用父类的成员函数,那那么调用的方式就是通过子类的对象去进行调用,那么这里我们还是可以编写一个代码来实验一下:

#include<iostream>
using namespace std;
class person
{
public:
    int age;
    char sex;
    const char* name;
    person(int _age=20 ,char _sex=0 , const char* _name="WangZhuo")
        : age(_age)
        , sex(_sex)
        , name(_name)
    {

    }
    const char* myname()
    {
        return name;
    }
};
class student :public person
{
public:
	int st_id;
    student(int _age = 20, char _sex = 0, const char* _name = "WangZhuo",int _id=10)
        : st_id(_id)
        ,person(_age , _sex , _name )
    {
    }
};
int main()
{

    student wz(1101);
    cout << wz.myname() << endl;
    return 0;
}

在这里插入图片描述

那么这里我们也可以在子类的成员函数内部来调用父类的成员函数:

#include<iostream>
using namespace std;
class person
{
public:
    int age;
    char sex;
    const char* name;
    person(int _age=20 ,char _sex=0 , const char* _name="WangZhuo")
        : age(_age)
        , sex(_sex)
        , name(_name)
    {

    }
    const char* myname()
    {
        return name;
    }
};
class student :public person
{
public:
	int st_id;
    student(int _age = 20, char _sex = 0, const char* _name = "WangZ",int _id=10)
        : st_id(_id)
        ,person(_age , _sex , _name )
    {
    }
    const char* stu_name()
    {
        return myname();
    }
};
int main()
{

    student wz(1101);
    cout << wz.stu_name() << endl;
    return 0;
}

那么这里调用父类的成员函数的时候,那么这里其实编译器还是隐式的传递了一个this指针,那么传递的是一个指向当前子类对象的一个this指针,但是这里调用父类的成员函数其实接收的是一个指向父类对象的指针,但是上文我们讲解了切片,那么由于子类有父类的完整的副本,所以这里能够完成一个隐式类型转化,然后接着执行父类成员函数的代码


那么接下来会存在这样一个场景,那么就是我们父类和子类可能会定义同名的函数,那么此时我们通过子类的对象去调用该同名的成员函数,那么此时编译器会如何抉择呢,那么有的读者可能认为这里编译不通过,会认为编译器会触发二义性错误,那么事实真的如此吗?那么我们可以写一个代码来验证这个情况:

#include<iostream>
using namespace std;
class person
{
public:
    int age;
    char sex;
    const char* name;
    person(int _age=20 ,char _sex=0 , const char* _name="WangZhuo")
        : age(_age)
        , sex(_sex)
        , name(_name)
    {

    }
    void fun()
    {
        cout << "I am person" << endl;
   }
};
class student :public person
{
public:
	int st_id;
    student(int _age = 20, char _sex = 0, const char* _name = "WangZ",int _id=10)
        : st_id(_id)
        ,person(_age , _sex , _name )
    {
    }
    void fun()
    {
        cout << "I am student" << endl;
    }
};
int main()
{

    student wz(1101);
     wz.fun() ;
    return 0;
}

在这里插入图片描述

那么这里我们发现编译通过,并且现象是调用子类的fun函数,那么这里我们再来将子类的fun函数注释掉,那么再来尝试运行一下这个代码,发现其运行的就是父类的fun函数,那么通过这个现象,我们也能知道编译器调用子类的成员函数的机制,那么编译器首先会尝试在子类的定义域寻找是否有该成员函数存在,那么如果存在,那么就优先调用子类的成员函数,而如果子类不存在该成员函数,那么接着会去在父类中寻找该成员函数,才会调用父类,那么这个现象有一个专业术语,那么叫做隐藏,那么也就是父子类定义同名的成员函数,那么子类的成员函数会隐藏父类的成员函数,那么如果我们想要调用父类的同名的成员函数,那么我们就需要指定类域访问:

wz.person::fun();

同理父子类定义同名的成员变量,那么也会存在隐藏,那么这里读者也可以写代码来验证这一个现象,那么原理也是一样的,那么编译器会优先在子类的作用域查找该成员变量的存在,没有再去父类的作用域中寻找

析构函数

那么这里析构函数面对的场景和构造函数一样,那么析构函数同样可以被子类给继承,那么我们知道析构函数的作用就是清理释放资源,那么我们也清楚的认识子类的内存布局,那么对于这里的析构函数,那么是否能够允许我们自己手动清理父类的资源,然后再清理子类的资源呢

那么这里我还编写了一个简单的代码,那么这里我准备了一个父类和一个子类,那么父子类内部都有指针这个成员变量,然后让这两个指针分别指向这个堆上开辟的区域,所以这里我们就得在析构函数释放父子类指针分别指向的空间,避免内存泄漏

那么这里我们首先来看看,我们是否能够尝试自己在子类的构造函数来清理父类部分的资源和子类的资源:

#include<iostream>
using namespace std;
class upper
{
public:
    int* ptr1;
    upper()
        :ptr1(new int[10])
    {

    }
    ~upper()
    {
        delete[] ptr1;
        ptr1=nullptr;
    }
};
class deeper :public upper
{
public:
    int* ptr2;
    deeper()
        :ptr2(new int[5])
    {

    }
    ~deeper()
    {
        delete [] ptr1;
        delete[] ptr2;
          ptr1=nullptr;
    }

};
int main()
{

    deeper wz;
    return 0;
}

在这里插入图片描述

那么我们发现程序崩掉了,那么为什么程序会崩掉了,那么其实原因也很简单,那么就是这里我们父类部分的指针所指向的空间被析构了两次,那么所以程序会崩溃,所以这里编译器其实默认调用了父类的构造函数,所以这里才会出现析构两次的情况发生,并且析构的顺序和构造函数初始化的顺序是相反的,也就是先子后父,那么其先进入的是子类的析构函数,并且编译器会在子类的构造函数体的结尾隐式插入一个代码:

upper::~upper();

所以在执行子类的析构函数体的内容之后,那么接下来就会跳转的父类的析构函数,然后清理父类部分的资源


多继承

那么上文的所有内容,其实都是围绕着一个内容在讲述,那么就是单继承,那么上文的各种例子,都是子类只是继承一个父类的例子,但是由于c++是一门面向对象的语言,那么何为面向对象,那么面向对象就是模拟我们现实生活中的场景,那么显示世界中的各种实体,我们都用类来描述并且建立对应的映射,而继承就是用来描述这些实体之间的关系

而现实生活中必然会存在这样一个场景,那么假设你是一个教师,那么你白天要在学校上班,那么此时你的身份就是教师,那么等你下班回到家之后,此时回到家你又要照顾孩子为孩子做饭,那么此时你的身份角色又切换为了父亲,所以在现实生活中,一个人会担任多个不同的角色,比如在刚才的场景中,那么你既可以担任教师同时也可以担任父亲这个角色,那么c++是如何来模拟这种现实生活中的场景呢?那么没错,就是通过多继承的方式来实现,那么我们可以定义一个teacher类以及一个father类,然后再定义一个子类同时继承teacher类和father类,那么就实现了现实生活中的这个场景:既担任教师又担任父亲这两个角色

虽然看似这个多继承的设计很完美,但是你会发现像Java这样同样也是面向对象的语言,其也支持继承的特性,但是java却没实现多继承而是仅仅支持单继承,那么其不支持的原因不是多继承不优秀,而是其实现的时候会存在很多坑,所以我们不要把多继承想的太过于理想了,那么接下来我们来看一下多继承究竟有哪些缺点

那么还是以上文的场景为例子,那么假设这里我们定义一个teacherfather类然后分别继承teacher类以及father类,那么继承的teacher类肯定得封装描述其teacher身份的基本属性,比如姓名和年龄以及性别和工号等,同理继承的另一个父类father类也得封装描述其身份的各种基本属性,包括年龄和性别以及身份证号等,那么我们不难发现,teacher类和father类中会有重合或者说相同的属性,那么一旦子类teacherfather同时继承这两个父类的时候,就能看出多继承的毛病了,那么由于多继承的子类要持有父类的完整副本,那么意味着这里父类相同的属性,那么该子类实际持有了两份,在刚才的场景中,子类分别持有了teacher类的年龄以及性别,同时也持有了father类的年龄和性别,那么如同你一个人持有两张身份证,所以这就是多继承的第一个问题,那么就是数据冗余,那么父类重合的属性,子类持有一份即可,不需要持有两份相同属性的副本

其次就是当我们通过子类的对象去访问父类部分的属性时,那么当我们访问到父类重合部分的属性的时候,比如访问age,那么此时teacher类也有age这个属性,father类也有age这个属性,那么上文,我们就说过,当父子有同名的成员函数或者变量时,那么这里会先搜索子类的作用域是否有匹配的成语变量与成员函数,如果没有,那么就会去父类的作用域搜索,那么这里编译器查看了teacher类和father类的作用域,发现其都有age这个属性,那么编译器不知道这里究竟要调用的是teacher类的age还是father类的age,那么就会出现多继承的第二个问题,那么便是二义性

teacherfather wz;
wz.age; 

在这里插入图片描述

那么解决这个二义性问题也很简单,那么只需要我们指定类域访问即可:

wz.teacher::age;

但是虽然能解决二义性,但是我们子类对象中还是存储了两份相同内容的副本,比如姓名和年龄,实际上子类只需要一份即可,那么必然会造成空间的浪费,所以要彻底解决数据冗余的问题,那么这里我们就得引入虚继承

虚继承

那么要解决刚才的数据冗余以及二义性就得采取虚继承的方式,在刚才的场景下,teacher类以及father类都有共同或者说重合的属性,那么这个情况,这是我们上文所提到的单继承的一个应用场景,那么我们可以将teacher类和father类的共同属性给提取出来,封装到一个person类中,然后分别让teacher类以及father类继承这个person类,从而获取person类的属性

但是这里实质上还是没有解决数据冗余的问题,一旦teacherfather类同时继承了teacher类以及father这两个父类之后,那么该子类还是会有两份相同属性的副本,所以这个时候虚继承的场景就出现了,那么之所以要将teacher类以及father类的共有的属性给提取出来封装到一个person类,是因为此时从最底层的teacherfather类到最上层的person类,那么整体的继承关系网络就是一个菱形继承,而菱形继承就是虚继承所拥有的场景
在这里插入图片描述

那么所谓的菱形继承,那么指的不是整个继承的关系网络的形状是一个菱形,那么其实只要一个多继承的子类那么它继承的父类往上追根溯源,发现最终都是继承同一个共同父类,那么此时都可以称之为菱形继承,那么下面这张图所描绘的复杂的继承关系其实本质上也是一个菱形继承,因为最终都会有一个共同的父类:
在这里插入图片描述

而菱形继承会导致数据冗余与二义性,因为最终继承同一个父类,那么由于是多继承,那么意味着继承的不同的父类都会持有有一个共同父类的副本,那么会导致继承这些父类的子类持有多份相同属性的数据的副本

那么这里我们先不谈非常复杂的多继承关系,这先来谈在刚才的也是最基本最典型的菱形继承关系,也就是teacherfather类同时继承teacher类和father类,然后这两个父类又分别继承person类,那么这里我们就需要此时在腰部位置采取虚继承,所谓的腰部位置,也就是teacher类和father类分别采取虚继承的方式继承person类,而不是采取普通的单继承的方式

那么在具体讲解虚继承的原理之前,我们先来看一下虚继承的语法,那么相比于传统的继承方式的声明,这里虚继承前面要加一个virtual关键字:

class 子类类名 :virtual 访问限定符 父类类名
{
    
};
    例:
    class teacher: virtual public person
    {
        
    };

知道了语法之后,我们再来讲解原理,那么虚继承相比于传统的继承,那么它所作出的改变,就体现在虚继承之后的子类的内存布局上,那么这里我们可以用代码简单实验一下,那么这里我创建一个虚继承的teacher对象,然后分别打印虚继承之后的teacher类的起始地址以及父类部分的第一个成员变量age的起始地址和子类特有部分成员变量_id的起始地址:

#include<iostream>
using namespace std;
class person
{
public:
    int age;
    char sex;
    const char* name;
};
class teacher : virtual public person
{
public:
    int te_id;
};
class father :virtual public person

{
public:
    int id;
};
class teacherfather :public teacher, father
{
public:
    int own;
    teacherfather()
    {

    }
};
int main()
{
    father wkz;
    cout << &wkz << endl;
    cout << &wkz.id << endl;
    cout << &wkz.age << endl;
    return 0;
}

在这里插入图片描述

那么最终得到了这三个地址00000092017BF6B8 ,00000092017BF6C0,00000092017BF6C8,那么这里为什么我要故意打印这三个地址而不是其他成员变量的地址呢,因为父类部分的起始地址其实就是父类的第一个成员变量的地址,而子类特有部分的起始地址则是子类特有部分的第一个成员变量的地址,所以这里我们比较这三个地址,那么我们可以发现两个关键的信息:第一个是子类特有部分的第一个成员变量的地址要比父类的第一个成员变量的地址小,第二个信息子类的特有部分的第一个成员变量地址和整个子类对象的起始地址并不重合

那从这两个信息,我们就能看出虚继承的子类的内存布局了,那么相比于传统的继承,传统的继承的子类的内存布局是先父后子,并且父类部分是从整个对象起始位置往后分配并且遵从内存对齐的规则,而这里虚继承的内存布局反而是先子后父,也就是子类的特有部分排列在前面,其次再是排列父部分,并且这里的子类特有部分并不是从对象起始位置开辟分配,那么我们可以计算两个地址之间的差值,发现子类特有部分和整个对象起始位置之间差8个字节

那么为什么这里子类部分是要从8个字节后才分配呢,那么这里由于我们的平台是64位,那么意味着该平台下,那么指针的大小不是4字节而是8字节,那么这空出的8个字节其实就是为指针分配的8个字节,而这个指针所指向的内容就是虚基表或者叫做偏移量表,而该偏移量表中记录了其继承的父类部分的起始位置的偏移量,那么在刚才的场景中,那么father虚继承person类,那么此时father类首先会在起始位置开辟一个指针,那么该指针指向一个偏移量表,那么该偏移量表本质就是一个动态数组,那么数组的每一个元素值就是偏移量,那么数组的第一个元素是存储的当前父类部分起始位置距离整个对象的起始位置的偏移量,然后依次是其继承的父类部分的偏移量,那么按照声明顺序排列

typedef long VBTable[]; 
// [0] = offset_to_top
// [1] = offset_to_base1
// [2] = offset_to_base2

在这里插入图片描述

那么我们知道该偏移量表的具体含义之后,那么接下来再来认识下子类在多继承的父类没有采取虚继承下的内存布局以及子类在多继承的父类采取虚继承的内存布局,理解这两种情况下的内存布局,最终我们再来介绍偏移量表的具体应用场景。

那么这里我们先来看第一种情况,也就是多继承的父类没有采取虚继承,此时多继承的子类的内存布局,这里我们还是通过打印4个地址来认识当前子类的布局,分别打印整个teacherfather类整个对象的起始地址和teacher类部分起始地址和father类部分起始地址和teacherfather类特有部分的起始地址:

#include<iostream>
using namespace std;
class person
{
public:
    int age;
    char sex;
    const char* name;
};
class teacher :  public person
{
public:
    int te_id;
};
class father : public person

{
public:
    int id;
};
class teacherfather :public teacher, public father
{
public:
    int own;
    teacherfather()
    {

    }
};
int main()
{
    teacherfather wz;
    cout << &wz << endl;
    cout << &wz.teacher::age << endl;
    cout << &wz.father::age << endl;
    cout << &wz.own<< endl;
    return 0;
}

在这里插入图片描述

我们通过地址清晰的观察到,该子类的内存布局还是先父后子,并且父类的排列顺序是按照继承方式的声明顺序排列,并且按照内存对齐,并且我们根据打印的地址可以知道,这里第一个声明的父类是从整个对象的起始位置开始分配,偏移量为0,那么每一个继承的父类部分都是完整的,因为整个子类对象的起始地址与第二个父类对象father的起始地址相差24字节,正好就是完整的且满足内存对齐的teacher类的大小

而此时我们再来验证父类采取虚继承,此时子类的内存布局,那么验证的方式还是打印几个特殊的地址,分别是整个对象的起始地址以及teacher类部分的起始地址和father类部分的起始地址和teacherfather类特有部分的起始地址以及person类部分的起始地址:

#include<iostream>
using namespace std;
class person
{
public:
    int age;
    char sex;
    const char* name;
};
class teacher : virtual public person
{
public:
    int te_id;
};
class father :virtual public person

{
public:
    int id;
};
class teacherfather :public teacher, public father
{
public:
    int own;
    teacherfather()
    {

    }
};
int main()
{
    teacherfather wz;
    cout << &wz << endl;
    cout << &wz.te_id << endl;
    cout << &wz.id << endl;
    cout << &wz.own<< endl;
    cout << &wz.age << endl;
    return 0;
}

其实从代码中我们就可以感觉到虚继承已经接了了数据冗余的问题了,因为main函数的倒数第二行代码,我们去取age的地址,我们没有指定类域其访问,说明这里编译器不会产生二义性问题,那么意味着此时多继承的子类对象一定值存储了一份公共的父类部分也就是person类,那么先不急,我们接下来看一下打印的这5个地址

在这里插入图片描述

那么从这5个地址,我们就可以看到此时子类的内存布局,这里teacher类的第一个成语变量比整个对象的起始位置大8个字节,那么这8个字节就是其指针,而这里我们father类的第一个成员变量比整个对象的起始位置大24个字节,那么这24个字节就是包括8个字节的指针以及其teacher类特有的4个字节的成员变量te_id和内存对齐补充的4个字节和father类的指针8字节,加起来总共24个字节,所以我们能够确信此时多继承的子类的内存布局还是先父后子,并且父类还是按照声明顺序排列,先是teacher类,然后再是father类,但是teacher类不是在整个对象的起始位置分配,因为起始位置分配了指向偏移量表的指针,但是这里的每一个父类的部分不是完整的,因为其只包含父类特有的部分以及一个指针,而父类之间的共有部分也就是继承的person类,则放到了整个对象的末尾,那么我们可以从打印的地址看到这一点,所以此时在这个场景下的多继承的子类的内存布局是先父后子然后再是父类共有的部分


那么此时知道了多继承子类的内存布局的两种情况之后,我们就能够解释,为什么每一个采取虚继承的父类要存储一个指针,去指向一个偏移量表,那么我们上文就提到的切片,此时偏移量表就能发挥作用,也就是一个子类类型可以转化为父类类型,并且切片的过程是不会产生中间的临时变量,直接在原变量的基础上进行复制拷贝,所以这里由于为了避免数据冗余,那么这里采取了虚继承,那么对多继承的子类以及采取虚继承的父类对象的内存布局进行了特殊的处理,那么此时在刚才的的场景也就是teacherfather类多继承的父类采取的是虚继承,那么这里如果要将该teacherfather对象赋予一个father对象,那么此时编译器就要进行切片,找到father的完整部分,那么编译器首先就要获取father部分的起始位置,那么这里就要计算father部分起始位置的偏移量,然后接着father部分的起始地址就是指针,然后接着解引用该指针,访问偏移量表,然后获取其公共父类部分起始地址的偏移量并且在再计算公共父类的大小,从而就能够获取father部分的完整属性了,所以这里偏移量表的意义就是就是用来定位公共父类部分的起始位置,而这个偏移量存储的是绝对偏移量,而不是相对偏移量,比如这里father中的关于公共父类部分person的偏移量,是相对于整个子类对象的起始位置的偏移量,而不是相对于father起始位置的偏移量,要注意这一点


那么其次有一些读者会有一个疑问,那么他会在想这里teacher类以及father类在继承公共父类person的时候,那么这里需要加virtual关键字,那么为什么这里teacherfather类多继承father类和person类的时候,不需要加virtual关键字呢?

那么由于这里teacherfather类已经是整个继承关系网络的结尾了,那么此时后面没有类再继承teacherfather类了,所以此时teacherfather类没有必要在尝试虚继承,那么虚继承的位置不会在整个继承关系网络最底层,那么一般都是在中间层采取虚继承,而这里你如果teacherfather采取虚继承的话,那么意味着该多继承的子类是的内存布局先子后父,先存储子类的特有部分,然后父类部分则是按照声明顺序排列最后则是公共的父类部分,那么此时该整个的子类对象起始位置处则是开辟一个指针,指向一个偏移量表,其中记录father以及teacher类的偏移量,那么后面没有类继承该类,那么这里额外存储一个指针其实没有什么用


那么最后我们再来评价一下虚继承的好坏,虽然虚继承能够成功的实现多继承,避免数据冗余与二义性,但是我们能用单继承就尽量用单继承,就算能用多继承,那么也得避免菱形继承的情况出现,那么虚继承的优点就是节省空间,原本要存储两份相同的数据,那么现在多继承只存储一份,特别是当公共的父类部分的大小很大的时候,那么此时普通的多继承,每一个父类都要存储这个公共的父类部分,而这里虚继承,只需存储一个指针,借助指针找到偏移量表就能够定位唯一的一份公共父类,但是缺点就是在面对切片的时候,那么此时普通的继承就更有优势,能够在编译时就能确定切片的父类的起始位置的偏移量以及切片的父类的大小,而这里虚继承由于有指针的存在,那么意味着二次寻址甚至多次寻址,因为如果继承的父类还是虚继承的话,那么此时还要再解引用指针递归的去寻找更为上层的父类部分,那么对于性能有损耗

结语

那么这就是本篇文章,关于继承的全部内容,那么感谢耐心看到这里的读者,那么我下一期将会更新多态,那么我会持续更新,希望你能够多多关注,如果本文有帮组到你的话,还请三连加关注哦,你的支持就是我创作最大的动力!
在这里插入图片描述


网站公告

今日签到

点亮在社区的每一天
去签到