【C++】面向对象之继承
创始人
2025-05-31 12:31:22
0

谈到面向对象的三大特性,必然绕不开封装、继承和多态。

但是需要明确的是三大特性是所有的支持面向对象的语言都有的,

但是具体语言可能还有具体特性。

下面就看一下C++中的继承。


基本概念和语法

引入

当我们在写一个大的项目需要定义多个类的时候,

譬如写一个校园管理系统,

我们需要定义学生类、教师类、职工类等等…

但这些类必然都有一些共同的属性,

譬如姓名、性别、身份证号等等…

所有的这些属性在定义不同类的时候都要重复定义多次,

那有没有一种方法能实现代码的复用,我们只需定义一次就好呢?

以前学过的知识其实能帮我们解决这个问题,

就是通过组合的方法。

比如我现在将各个类的共同属性抽象出来写成一个Person类:

class Person
{
public://...protected:string _name;string _id;string _gender;//...
};

然后通过组合的方法定义Teacher类:

class Teacher
{
public://...protected:Person _basic_info;string _job_id; //工号string _title; //职称//...
};

组合当然不是这里要讲的重点,

而且这里会有一个缺点,就是我们无法在Teacher类中直接访问_basic_info中的成员变量,

因为它们被设置成了类外面不可见。

所以我们用继承的方法来定义一个Student类:

class Student : public Person
{
public://...protected:string _stu_id;string _grade;//...
};

所以继承是什么呢?

定义

继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,

它允许程序员在保持原有类特性的基础上进行扩展,增加功能,

这样产生新的类,称派生类,也叫做子类,

被继承的类称为基类,也叫父类。

继承呈现了面向对象程序设计的层次结构

体现了由简单到复杂的认知过程。

以前我们接触的复用都是函数复用,继承是类设计层次的复用

我们刚刚写的一个继承其实分为三部分:

image-20230318191948684

派生类和基类我们已经通过继承的定义差不多理解了,

那继承方式又是什么呢?

继承方式

首先先说明继承方式当时一共有三种,

分别是publicprotectedprivate

但是这里的继承方式和访问限定符虽然同名,但可不是一个意思。

下面先给出一个表格总结再做解释:

访问限定/继承方式public继承protected继承private继承
基类的public成员派生类的public成员派生类的protected成员派生类的private成员
基类的protected成员派生类的protected成员派生类的protected成员派生类的private成员
基类的private成员派生类不可见派生类不可见派生类不可见

此前对于访问限定符protected和private,

我们只知道这俩都是限定成员在类外不可见,

除此之外没有区别。

所以他俩的区别是在继承这里拉开的。

在以public继承的前提下,

private限制成员对外绝对不可见,

虽然派生类继承下来之后给它分配空间了,

但是你派生类只能用我基类提供的接口去访问。

而如果想要基类的成员在派生类中也可访问,

则可以定义成protected成员。

还有一个问题,当我们用struct定义类的时候,

类的成员默认都是用的public限定,

对于继承方式也是如此,默认使用public继承;

相应地,class就完全反过来,

两个都用的private。

想判断一个成员在派生类中的访问权限,

其实可以取巧一点:

我们定义访问权限大小:public > protected > private

那么成员在派生类中的访问权限就是min{成员在基类的访问限定符,继承方式}

对于protected继承和private继承,

因为它们太鸡肋了,基本是见不到的,

所以我们一般都是用的public继承。


基类和派生类的赋值转换

我们现在定义了两个类:

class Person
{
protected:string _name; // 姓名string _sex;  // 性别int _age; // 年龄
};class Student : public Person
{
public:int _No ; // 学号
};

其中Person是基类,Student是派生类。

然后我们创建两个对象:

Person p;
Student s;

既然这两个对象存在部分相同性质的成员,

那么我们可不可以用p给s赋值,或者用s给p赋值这种奇怪的操作呢?

image-20230318200759441

看来后者是可以的。

因为p包含了s的所有成员,

而这种复制方式其实就是所谓的切片或切割,

通俗点就是可以把派生类对象赋值给基类,

将派生类中基类的那一部分切出来给父类:

image-20230318212144656

更甚至,我们还可以将派生类对象赋给基类的指针、引用:

Person* ptr = &s;
Person& ref = s;

与直接赋值不一样的是,

我们还可以通过强制类型转换将基类对象赋值给派生类的指针、引用,

这样也是合法的:

Student* ptr_stu = (Student*)&p;
Student& ref_stu = (Student&)p;

但是这样就要考虑越界访问的问题了。

这里我们就要思考一个问题了,

将派生类对象赋给基类/基类指针/基类引用的时候,

是不是发生了隐式类型转换呢?

我们只考虑将对象赋给引用,

当我们想用一个类型赋给另一种类型的引用时是行不通的:

image-20230318214200699

这是因为发生类型转换时会产生一个临时变量。

首先会截取d的整数部分赋给整形类型的临时变量,

然后引用指向该临时变量。

而这个临时变量是常量属性的,

我们不能用一个非const引用类型引用一个const对象,

所以这样就是允许的:

double d = 3.14;
const int& ref_i = d;

而我们上面用基类引用类型引用派生类对象,

明明是不同类型,为什么不加const却可以呢?

这是因为赋值的时候并没有发生类型转换


继承中的作用域

在继承体系中基类和派生类都有各自的作用域。

这意味着我们可以在基类中定义同名成员变量或同名成员函数,

class Person
{
protected:string _name = "李四";//...
}class Teacher : public Person
{
protected:string _name = "李老师";//...
}

同名成员构成隐藏关系,

所以如果我们在Teacher类中直接访问_name成员,

实际上访问到的是"李老师"

如果想在Teacher类中访问Person类中的_name时,

则需要加作用域限定符Person::_name

需要注意,只要子类和派生类中的成员名相同,

就会构成隐藏关系,也叫重定义,

函数也是如此:

class A
{
public:void fun(){cout << "func()" << endl;}
};class B : public A
{
public:void fun(int i){A::fun();cout << "func(int i)->" <

需要注意的是,因为A和B是两个不同的域,

所以fun()函数即使函数同名参数不同,

也不能构成重载,函数重载的前提是函数在同一作用域。

所以函数也是构成隐藏关系的。


派生类的默认成员函数

构造函数

对于构造函数,

派生类一定是调用基类的默认构造函数去初始化基类的那一部分成员的,

如果基类没有默认构造函数可用,

则必须在派生类构造函数的初始化列表显示调用:

class A
{
public:A():_a(1){}protected:int _a;
}class B : class A
{
public:B(int a, int b):A(a), _b(b){}protected:int _b;
}

这里其实还有一个细节就是关于基类子对象和派生类对象的构造顺序问题,

因为初始化列表的初始化顺序只与类成员变量的声明顺序有关,

这里默认基类成员先于派生类声明,

所以派生类对象初始化总是会先调用基类构造再调派生类构造。

拷贝构造函数

对于拷贝构造函数也是如此:

派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化:

class A
{
public:A(const A& aa):_a(aa._a){}protected:int _a;
}class B : class A
{
public:B(const B& bb):A(bb), _b(bb._b){}protected:int _b;
}

在派生类的构造函数调用基类的拷贝构造传参时就体现出切片了。

赋值运算符重载

派生类的operator=必须要调用基类的operator=完成基类的复制,

因为派生类和基类的赋值运算符重载函数是同名的,

同名函数会构成隐藏,

所以在派生类中想要调用基类的赋值运算符重载一定要加作用域限定符,

否则会发生无穷递归:

class A
{
public:A& operator=(const A& aa){if (this != &aa){_a = aa._a;}}protected:int _a;
}class B : class A
{
public:B& operator=(const B& bb){if (this != &bb){A::operator=(bb);_b = bb._b;}}protected:int _b;
}

析构函数

上面都还好理解,下面的析构函数就有点特殊了。

第一点,如果我们想在派生类的析构函数中去显示调用基类的析构函数,

来清理基类的那部分成员,

直接调用是不行的:

image-20230319174036341

因为编译器会将~识别为操作符,

所以我们要加作用域限定符:

~B()
{A::~A();
}

但实际上大可不必,

因为派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员

简而言之就是我们不用再派生类的析构函数中去显示调用基类的析构函数,

如果显示调用了,

对于不含有动态分配的成员的基类还好还好,

如果有的话就意味着对同一个指针进行了两次delete

用下面的代码测试一下:

class A
{
public:~A(){delete[] _a;}protected:int* _a = new int[10];
};class B : public A
{
public:~B(){A::~A();delete[] _b;}protected:int* _b = new int[10];
};int main()
{B bb;
}

现在问你,这段代码有什么问题?

A.语法错误 B.编译错误 C.运行错误 D.无错误

答案是C,因为对同一个指针释放了两次。

我们可以用这段代码来验证是否真调用了两次:

class A
{
public:~A(){cout << "~A()" << endl;}protected:int _a;
};class B : public A
{
public:~B(){A::~A();cout << "~B" << endl;}protected:int* _b;
};int main()
{B bb;
}

运行结果如下:

image-20230319174912326


继承与友元

现在有一个基类的友元:

class A
{friend void Print(A aa);
protected:int _a = 10;
};void Print(A aa)
{cout << aa._a << endl;
}

然后有一个派生类:

class B : public A
{
protected:int _b = 20;
};

我们可以将B类对象作为基类友元函数的参数进行传参:

B bb;
Print(bb);

image-20230320154216387

看起来友元关系似乎也能继承,

但这实际上是切片的体现,

如果想在Print()函数中访问_b则就不行了,

所以实际上友元关系是不能继承的

我们并不能在基类友元中访问派生类的私有或保护成员。


继承与静态成员

我们现在在基类中定义了一个静态成员并对其进行了初始化:

class Person
{
public:Person () {_count++;}protected:string _name;public:static int _count;  //统计一共创建了多少个Person对象
};int Person::_count = 0;

现在有两个派生类:

class Student : public Person
{
protected:int _stuNum; //学号
};class Teacher : public Person
{
protected:int _jobNum; //工号
};

看下面代码的运行结果:

int main()
{Person p;Student s;Teacher t;cout << Person::_count << endl;cout << Student::_count << endl;cout << Teacher::_count << endl;return 0;
}

image-20230320155908646

可以看出,

基类定义了static静态成员,

则整个继承体系里面只有一个这样的成员,

这个静态成员为继承体系里的所有类及其对象共有。


复杂的继承场景

复杂继承

这里有三种相对简单继承复杂一点的继承,

一种是一条龙下来的单继承:

class A
{};class B : public A
{};class C : public B
{};//...

image-20230320160832648

一种是较为复杂一点点的多继承:

class A
{};class B
{};class C : public A, public B
{};

image-20230320161329612

放到具体场景,

比如助教有学生和老师的双重身份,

所以声明助教类时采用多继承是没问题的:

class Teacher
{};class Student
{};class Assistant : public Teacher, public Student
{};

但是此时就有一个问题,

Teacher类Student类还要一个Person基类呢!

所以就又有了更为复杂的一种继承方式 —— 菱形继承:

class Person
{};class Teacher : public Person
{};class Student : public Person
{};class Assistant : public Teacher, public Student
{};

image-20230320162010171

而这种继承方式会有什么问题呢?


菱形继承引发的数据冗余和二义性

还是以上面的例子为例,

现在丰富一下类的成员,为了方便演示都设置为公有:

class Person
{ public: string _name = "张三"; };class Teacher : public Person
{ public: int _jobID = 100001; };class Student : public Person
{ public: int _stuID = 200001; };class Assistant : public Teacher, public Student
{ public: string _assistCourse = "面向对象的程序设计"; };

那么我们实例化出来一个Assistant对象后就会出现这样一幕:

image-20230320165146341

这是因为对象中存在两个_name

一个继承自Teacher类,一个继承自Student类

所以如果要访问的话还必须得加作用域限定符:

image-20230320165547068

作用域限定符解决了数据的二义性问题,

但是实打实的两个_name还是同时存在,

数据的冗余问题仍然没有解决。

所以又有了下面的虚拟继承。


虚拟继承及其原理

虚拟继承涉及到一个关键字virtual

对于上面的情况,

想要解决数据冗余,

我们只能在声明TeacherStudent类时进行虚拟继承:

class Person
{ public: string _name = "张三"; };class Teacher : virtual public Person
{ public: int _jobID = 100001; };class Student : virtual public Person
{ public: int _stuID = 200001; };class Assistant : public Teacher, public Student
{ public: string _assistCourse = "面向对象的程序设计"; };

一定要注意虚拟继承的使用位置。

如此Assistant的实例化对象中就只有一个_name了:

image-20230320171307778

下面通过一个简化模型去研究一下虚拟继承的原理。

首先先通过下面的代码看一下普通菱形继承引发的数据冗余性问题:

class A
{ public: int _a = 1; };class B : public A
{ public: int _b = 2; };class C : public A
{ public: int _c = 3; };class D : public B, public C
{ public: int _d = 4; };int main()
{D dd;return 0;
}

内存监视窗口如下:

下面是使用虚拟继承后的代码和内存监视窗口信息:

class A
{ public: int _a = 1; };class B : virtual public A
{ public: int _b = 2; };class C : virtual public A
{ public: int _c = 3; };class D : public B, public C
{ public: int _d = 4; };int main()
{D dd;return 0;
}

image-20230320192641947

好像确实解决了数据冗余,只有一份_a了,

但是又多了两个奇奇怪怪的值。

这两个值放在这里有什么特殊意义呢?

实际上这是两个指针值,叫做虚基表指针,

他俩指向的就是虚基表,

下面就看看虚基表是怎么一回事:

image-20230320193019624

先看一下B的,它的虚基表有一个0x00000014

而B的起始地址0x00FEFD74_a的地址0x00FEFD88正好差了0x00000014个字节,

再看一下C的,它的虚基表有一个0x0000000c

而C的起始地址0x00FEFD7C_a的地址0x00FEFD88正好差了0x0000000c个字节。

所以虚基表存放点实际上就是B、C相对于公共部分的内存偏移量。

那问题来了,这不也没节省空间吗?甚至比原来还多了一个空间。

这是因为定义的成员太少,当成员多起来后空间优化就显得十分明显了。

每个虚拟继承得到的派生类其实都有一个虚基表,

可以通过虚基表找到基类的成员。

我们可以再从内存窗口看一下B实例化出来的对象:

image-20230320204716246

因为虚拟继承得到的派生类存储结构发生了变化,

基类的公共成员都放在了最后,

那么在切片的时候就要特殊处理,

而虚基表其实就是这么一种特殊处理机制。

相关内容

热门资讯

在PyCharm中运行Pyth... 先看一个报错: Traceback (most recent call last):F...
最新或2023(历届)给淘宝买... 给淘宝买家的感谢信范文一:  亲:  您好!  首先在此感谢您对本店的关注与支持!我们是一群年轻上进...
写给物业的一封感谢信范文 怎么... 写给物业的感谢信范文一:  XX建物业管理有限公司成都分公司:  新年伊始辞旧岁,万象更新迎新春。公...
关于最新或2023(历届)企业...  企业对员工的感谢信范文一:  尊敬的各位同事:  大家好!为应对公司生产一线人员缺口,同时保障公司...
最新或2023(历届)企业对员...  企业对员工的感谢信范文一:  尊敬的各位同事:  大家好!为应对公司生产一线人员缺口,同时保障公司...
最新或2023(历届)教你怎么... 给领导的感谢信范文一:  尊敬的xx县交巡警大队领导:  我们是xxx丝绸印花有限公司,今天来信的目...
城乡供水一体化平台-助力乡村振... 城乡供水一体化管理系统建设方案 城乡供水一体化管理系统是运用云计算、大数据等信息化手段࿰...
【MySQL】锁 锁 文章目录锁全局锁表级锁表锁元数据锁(MDL)意向锁AUTO-INC锁...
捐款感谢信的范文参照 感谢村民... 捐款感谢信的范文一尊敬的xx实验中学的全体师生、员工:  您们好!您们的捐款我们如数收到,您们的爱心...
最新或2023(历届)火灾捐款...  火灾捐款感谢信范文一:同志:  正月初七早晨,新河镇西门街遭受火灾,一排木结构二层七间民房着火,居...
毕业送给给老师的感谢信 六年级... 尊敬的老师:  你好!感谢这三年来你对我的关怀与照顾,在你的帮助下,我也考得了好成绩,我之所以能考到...
最新或2023(历届)给老师的...   给老师的感谢信范文参考一敬爱的老师:  您好!最近在课堂上看到您容光满面,我也替您感到快乐与舒心...
家长写给老师的感谢信范文精选 ... 泾洋初级中学的老师、同学们:  我是贵校初三第十三班学生陈佳豪的家长。就在清明节放假期间,我的孩子在...
【vue2】vue2中的性能优... ⭐ v-for 遍历避免同时使用 v-if ⭐ v-for 中的key绑定唯一的值 ⭐ v-show...
freemarker转成PDF... Spring Boot 集成 FreeMarker 可以通过在 pom.xml 文件中添加依赖项来实...
写给亲爱的妈妈的一封感谢信 给... 写给亲爱的妈妈的一封感谢信亲爱的妈妈:  您好!  恩情,从小处讲可以是炎夏中给你一碗清爽冰凉的柠檬...
受到爱心捐款的感谢信范文 受灾... 受到爱心捐款感谢信一尊敬的老师,亲爱的同学: 你们好!  自9月xx日校工会、校团委发出向后港小学陈...
关于国家助学金感谢信范文 关于... 关于国家助学金感谢信篇一  尊敬的各位领导、老师:  你们好!  我很高兴能向国家和学校申请国家助学...
最新或2023(历届)助学金获...  助学金获奖感谢信范文篇一:  首先,感谢国家对我们贫困大学生的关怀和关爱。  xxxx年10月份我...
关于拾金不昧的感谢信精选范文 ... 拾金不昧的感谢信范文篇一  尊敬的县保健院领导及全体员工:  我叫XXX,于20XX年2月17日下午...