自学内容网 自学内容网

learn C++ NO.17——继承

什么是继承?

用冒号 : 后跟基类名称来声明一个类是从某个基类继承而来的。继承方式可以是 public、protected 或 private,这决定了基类成员在子类中的访问权限。
下面通过代码简单进行一下演示.
在这里插入图片描述

派生类Student即子类,而基类Person是它的父类。语法格式为 class/struct + 派生类名 : 继承方式(如public) + 基类名。
在这里插入图片描述

继承的方式

类的访问限定符有三个分别是public、protected以及private。而继承方式有三种分别是公有继承(public)、保护继承(protected)以及私有继承(private)。而C++的祖师爷把继承方式用类的访问限定符和继承方式一组合,组合出了9种继承方式。

Public 继承:当使用 public 继承时,基类的 public 成员在子类中仍然是 public 的,基类的 protected 成员在子类中仍然是 protected 的,但基类的 private 成员在子类中无法直接访问。

Protected 继承:当使用 protected 继承时,基类的 public 和 protected 成员在子类中变成 protected 的,基类的 private 成员在子类中无法直接访问。

Private 继承:当使用 private 继承时,基类的 public 和 protected 成员在子类中变成 private 的,基类的 private 成员在子类中无法直接访问。

简单总结一句话就能记住这九种方式。基类的私有都不可见,而基类的公有成员和保护成员都是取和继承方式这两个比权限小的。 权限的级别从小到大 public < protected < private。

对于基类的私有成员私有不可见这个概念进行一些解释,这里的不可见是语言层面上的对其限制访问(类内类外都不可以使用),底层内存中实际还是会存储对应的数据。

基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的

使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
在这里插入图片描述
在这里插入图片描述
在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。

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

首先,谈一谈基类和派生类的关系。在继承关系中,派生类对象是基类对象的扩展。这意味着派生类对象包含基类对象的所有成员(数据和函数),并且还可能有额外的成员。

可以将一个派生类对象赋值给一个基类对象,这种赋值是**切片(slicing)操作 **,即只将基类部分的数据成员复制到基类对象中。派生类特有的成员会被忽略。派生类对象不仅可以用来赋值给基类对象,还可以赋值给基类对象的引用和指针。

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

在语法层面,不允许用基类对象赋值给派生类对象。但是指针和引用还是可以做到向下转移,但是要涉及多态以及RTTI(RunTime Type Information)。这里不多赘述,后面会详细聊。也可以通过提供一个构造函数或赋值运算符来实现这种赋值。

继承中的作用域

作用域的概念可以理解成是编译器去查找对应的变量或函数的优先去哪个{}区域找。通常编译器是取最近的域的内容来进行匹配。对应到继承中,编译器会优先去找子类类域中的成员,若子类没有对应成员,则会去父类的作用域找。

在派生类中,如果定义了一个与基类同名的成员(无论类型是否相同),基类的那个成员会被隐藏。这意味着,在派生类的作用域中,访问该名称时只会看到派生类定义的成员。

下面通过样例看一看。
在这里插入图片描述
Person类中的_num和Student类中的_num构成隐藏(重定义)关系。不仅是成员变量可以构成隐藏,成员函数也可以构成隐藏。

在这里插入图片描述

当然,下面以一个非常迷惑人的题目为例。加深大家对继承中作用域的理解。
在这里插入图片描述
这里我先声明答案为A。而不是B。因为函数构成重载需要在同一作用域下。当然,你如果在主函数内定义一个Student对象去调用func()的话,会出现报错。因为,编译器优先去在Student的类域查找,发现找到了func(int i)。所以,编译器会认为是你的在使用函数语法上出错了。

派生类的默认成员函数

默认成员函数就是程序员不写,编译器自动生成的成员函数。通常有六个默认成员函数,如默认构造函数、析构函数、拷贝构造函数、赋值运算符重载,移动构造函数(C++11)以及移动赋值重载函数(C++11)。
在这里插入图片描述

下面我通过样例依次介绍派生类的默认成员函数的一些语法细节。下面是一份基类代码,这份代码中实现了默认构造函数、析构函数、拷贝构造和拷贝赋值运算符重载。

在这里插入图片描述

假设定义一个Student派生类来公有继承基类。然后,按照原来的写法写一份错误的样例。
在这里插入图片描述
如果在派生类的默认构造函数中没有初始化基类成员的话,编译器会去调用基类的默认构造函数来初始化基类的成员
在这里插入图片描述

若基类没有提供默认构造,派生类没有在构造函数中初始化基类成员则编译器报错。
在这里插入图片描述

派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
在这里插入图片描述

派生类的拷贝构造中,需要显示调用基类的拷贝构造函数来对基类成员进行正确的拷贝。
在这里插入图片描述

如果派生类不在构造函数中去拷贝初始化基类成员部分,此时若基类提供了默认构造,那么编译器将会自动去调用基类的默认构造,进而导致错误。
在这里插入图片描述

派生类的拷贝复制重载必须调用基类的拷贝复制重载,完成基类部分的拷贝赋值。 否则,由于这里的operator=与基类的operator=构成隐藏,不指定基类类域调用的话,默认调用派生类自己的operator=,进而导致栈溢出问 题。
在这里插入图片描述

下面介绍一下关于派生类继承基类后的析构函数问题。派生类不能在析构函数中,显示调用基类的析构函数。会导致析构多次的问题。因为,编译器自动会去调用析构函数。
在这里插入图片描述

上面我们提到,在实例化派生类对象中,会先根据声明的顺序,先去调用基类的构造函数初始化基类的成员。而在对象的生命周期结束后,编译器会根据对象的实例化顺序,优先去析构派生类的成员,再去析构基类成员。派生类在析构函数中显示调用基类析构函数,就无法保证先析构派生类后析构基类的顺序要求。先析构基类成员,还会引发派生类对象访问基类成员已经释放的空间导致程序崩溃。所以,不能在派生类的析构函数中显示调用基类的析构函数。
在这里插入图片描述

由于多态特性,编译器将析构函数的函数名进行了特殊的处理,在派生类中调用基类的析构函数需要指定基类类域。这里算是挖一个坑,下一篇文章来填这个坑。

友元与继承

**基类的友元关系派生类不能继承。**基类的友元无法访问派生类的保护成员以及私有成员。
在这里插入图片描述
如果Display函数也要访问Student的保护成员,那就在Student类内也得声明友元。
在这里插入图片描述

继承与静态成员的关系

基类定义了一份静态成员,无论多少个派生类继承了这个静态成员,整个继承体系中也只有一份静态成员的实例。派生类的对象模型中是没有这个静态成员的,但是,派生类可以使用这个静态成员。
在这里插入图片描述

多继承

多继承指的是一个派生类继承两个及以上的基类,语法如下。

class A
{};

class B
{};

//多继承
class C : class B , class A 
{};

上面都是一单继承为样例进行介绍。如何区分单继承还是多继承,主要是看 :右边有几个类。

C++的祖师爷们整出了多继承时,可能觉得这个东西非常妙,毕竟面向对象模型中,难免会有一些情况下,一个对象身兼数职。比如你可能既是一名程序员,又在下班后兼职当外卖骑手。不可否认的是,多继承在一些场景下是有用的,但是,它也会引发一些列的问题,如菱形继承。

菱形继承

菱形继承是一种特殊情况的多继承。下面看一看什么是菱形继承。
在这里插入图片描述
在这种结构中,如果 B 和 C 都各自有一个从 A 继承来的成员变量或函数,那么 D 将会拥有两份 A 中成员的拷贝,这可能会导致数据冗余和二义性问题。
在这里插入图片描述

在这里插入图片描述

虚继承

既然菱形继承有数据冗余和二义性的问题,那要如何解决呢?在腰部位置引入虚继承,即在class B 和 class C 前引入virtual 修饰继承,可以保证class D中只有一份class A成员。

在这里插入图片描述

虚继承如何解决的数据冗余和二义性

下面通过调试的内存窗口看一看,究竟C++底层是如何实现虚继承来解决数据冗余和二义性的问题。

首先,看一看菱形继承在内存窗口的模型是怎么样的。
在这里插入图片描述
在这里插入图片描述

通过上面的内存窗口可以看到,d对象中既有B对象的_a,又有C对象的_a。所以产生了数据冗余和二义性问题。

下面再看看引入虚继承后的内存窗口。
在这里插入图片描述

不难看到对比与上面没有引入虚继承的对象模型,这里引入虚继承的菱形继承的对象模型多了两个指针成员(虚基表指针),分别指向两块独立的空间(虚基表)。独立的空间内存的就是与对象a部分的相对位置(偏移量)

在这里插入图片描述
为什么需要这样处理呢?因为这样处理方便编译器统一对虚继承的派生类的公共基类成员进行查找处理。只要引入虚继承,派生类的对象模型都会产生变化。
在这里插入图片描述
而编译器并不会要针对切片进行特殊处理,因为对象模型的实现思路是一样的,编译器对虚继承的处理方式是统一的。下面通过反汇编简单对比一下。
在这里插入图片描述

总结一下菱形虚拟继承相关的问题

菱形虚拟继承在C++中主要是用来解决菱形继承(多重继承的一种特殊情况)所带来的数据冗余和二义性问题的。然而,它本身也引入了一些复杂性和潜在的问题,主要包括以下几点:

1. 复杂性开销

  • 实现复杂性:虚拟继承增加了编译器实现的复杂性,因为编译器需要管理虚拟基类表、偏移量等额外信息,以确保基类实例的共享和正确访问。
  • 代码复杂性:对于开发者而言,理解和维护使用虚拟继承的代码可能更加复杂。特别是当类继承层次较深或较复杂时,理解对象内存布局和成员访问方式可能会变得更加困难。

2. 性能影响

  • 内存开销:虚拟继承引入了额外的内存开销,因为需要存储指向虚拟基类实例的指针(通常通过虚拟基类表实现)。这可能会增加对象的大小,从而影响内存使用效率。
  • 访问开销:访问虚拟基类的成员可能需要通过额外的指针间接访问,这可能会增加访问成本,影响程序的性能。

3. 构造和析构顺序

  • 构造顺序:在菱形虚拟继承中,虚拟基类的构造函数会在任何派生类构造函数之前被调用,且只调用一次。然而,这可能会使得构造顺序的理解变得更加复杂,尤其是在涉及多个虚拟基类和深层继承层次时。
  • 析构顺序:与构造顺序相反,虚拟基类的析构函数会在任何派生类析构函数之后被调用。这同样需要开发者特别注意,以避免在析构过程中访问已销毁的对象成员。

4. 初始化问题

  • 显式初始化:在使用虚拟继承时,最终派生类必须显式地调用虚拟基类的构造函数,否则编译器将报错。这意味着在编写继承体系时,需要特别注意构造函数的调用方式和顺序。
  • 初始化列表:在派生类的初始化列表中,必须按照正确的顺序列出所有基类(包括间接基类)的构造函数调用,以确保基类被正确初始化。

5. 访问控制

  • 访问权限:虽然虚拟继承解决了基类成员的二义性问题,但它并不影响成员的访问权限。如果基类成员在派生类中被隐藏或重定义,那么访问这些成员时仍然需要遵循C++的访问控制规则。

6. 设计和维护难度

  • 设计考虑:在设计继承体系时,需要仔细考虑是否真的需要使用虚拟继承。因为虚拟继承虽然解决了菱形继承的问题,但也可能引入其他复杂性和性能开销。
  • 维护难度:使用虚拟继承的代码可能在后期维护中变得更加困难,特别是当继承层次变得复杂时。因此,在设计阶段就需要权衡其利弊。

总之,菱形虚拟继承在C++中是一种有用的特性,用于解决特定的多重继承问题。然而,它本身也引入了一些复杂性和潜在的问题,需要开发者在设计和维护过程中特别注意。在可能的情况下,优先使用对象组合而不是类继承,以降低复杂性和提高代码维护性。

继承和组和

class A
{};

//继承
class B : public A
{};

//组合
class C
{
private:
A a;
};

继承是一种白箱复用,耦合度相对较高。组合是一种黑箱复用,耦合度较低。在软件工程学科中,程序要讲究高内聚低耦合。实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系既可以用继承,又可以用组合,那就用组合。


原文地址:https://blog.csdn.net/m0_71927622/article/details/142210322

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!