目录
一、简介:
面向对象编程(Object-Oriented Programming,OOP)是一种程序设计范式,它基于以下三大特性:
-
封装(Encapsulation): 封装是将数据和方法(或函数)组合成一个单一的单元,并对外部隐藏其内部的实现细节的机制。通过封装,对象的内部状态和行为被保护起来,只有通过对象提供的公共接口才能访问和操作对象的数据。这样可以提高代码的可维护性和安全性,同时也方便了代码的复用和协作开发。
-
继承(Inheritance): 继承是一种机制,允许一个类(称为子类或派生类)基于另一个类(称为父类或基类)定义,并且在不改变原有类的情况下增加或修改其功能。子类继承了父类的属性和方法,并且可以在此基础上扩展新的属性和方法。继承使得代码的重用更为简单,同时也支持了代码的层次化组织和抽象概念的表示。
-
多态(Polymorphism): 多态是指同一个操作或函数在不同的对象上具有不同的行为或实现方式的能力。在面向对象编程中,多态允许一个函数根据调用时的对象类型以不同的方式进行响应,这样可以在不修改函数本身的情况下改变函数的行为。多态性提高了代码的灵活性和可扩展性,使得代码更加易于维护和扩展。
这三大特性共同构成了面向对象编程的基础,使得程序设计变得更加模块化、灵活和易于扩展。在许多现代编程语言中,如Java、C++、Python等,都支持面向对象编程,并且提供了丰富的语法和功能来支持封装、继承和多态。
二、继承
1.基础介绍:
1.1、
继承机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用(指的是在不同的上下文中重复利用已经存在的代码、设计或其他资源的能力)
下面举个例子:
这里可以运行一下程序:
我们可以发现,这两个类执行的是同一个函数,侧面说明了父类是子类的成员,我们可以再通过调试看看是不是真的是这样。
如上图所示,t 这个类里面明显包含了person 这个类,说明前面的结论是对的;
1.2 继承格式介绍
1.2.1
1.2.2
继承关系与访问限定符:
用不同的方式继承,导致的效果会不一样。这里的表示继承方式的符号与访问限定符是一样的,
当我们以不同的方式继承时,派生类访问方式也会不同,具体如下表
这里如果强行去记,未免太过麻烦,这里我提供一个更好的记忆方式:由于访问限定符与继承方式符号时一样的,所以每次继承时,取两个符号中权限小的作为访问方式,权限默认 public >
protected > private, 比如基类的public 成员以protected 方式继承,那这个基类成员在派生类中就是protected 成员了。
这里总结一下:
<1> 基类中private 成员无论什么方式继承,在派生类都不可见,这里的不可见不是没有继承,而是继承了,但由于private访问限定符的原因,在基类外无法访问。
<2>如果我们想基类成员在类外不能被访问,但又在派生类内可以访问,我们就可以使用protected 这个访问限定符,这个限定符就是为了继承才诞生的。
<3>默认class关键字继承方式是private, 而struct 关键字的继承方式默认是public ,为了方便阅读,这里建议继承时最好表明继承方式。
<4>实际应用的时候一般用public 继承,主要是private 继承与protected 继承的代码只能在派生类中使用,实际应用中代码的拓展维护性不强。
2.基类和派生类对象赋值转换
这里先介绍一点其他知识
如上图所示,当我们以定义了一个 i 变量与d变量时,虽然两者的类型不同,但我们依然可以将i 变量赋值给 d 变量,这里很明显出现了类型转换,但是为什么下面的k 和 t 变量不可以呢?
这里是因为在类型转换的时候会出现一个临时变量,而且这个临时变量具有常性,所以第二个赋值就无法成立,需要在double& 前加一个const,使其具有常性才不会报错(如下图)。
这里解释一下为什么我们需要临时变量这个东西,举个例子:在一些比较中我们需要将字符与整型进行对比,但是这两个的类型是不一样的,所以一般都会对字符进行整型提升,将其变为和整型一样的类型才可以与其比较。假设没有临时变量,此时我们的字符就变成了整型了,而我们的目的是比较这两者的大小,并不希望改变字符,所以我们就需要另外一个变量来代替字符进行比较,确保原来字符不变。
那我们再看下面的例子
这里的teacher 转换为 person类型时为什么没有报错呢?其实这里的做了特殊处理(也就是为了语法体系逻辑自洽而设计的规定),这里的特殊处理就涉及到了基类和派生类对象赋值转换,俗称切片,
这里的变量最终都是指向派生类中的基类部分,也就是说,前面两张图中的t 、b、ptr 指向的或代表的都是teacher这个类中继承person的那一部分,这个就是赋值转换。
这里要说明几个点:
1、子类对象可以赋值给父类的指针、引用、或变量;
2、父类对象是不能赋值给子类对象(注意与第三条区别);
3. 基类的指针可以通过强制类型转换赋值给派生类的指针(不过这个要看情况,不能随便转换,要不然会有越界行为的发生);
示例:
3.继承中的作用域
1. 在继承体系中基类和派生类都有独立的作用域。
2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,
也叫重定义。在子类成员函数中,可以使用 基类::基类成员 显示访问
3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏(注意跟后面的重写区分)
4.所以在实际中在继承体系里面最好不要定义同名的成员。
我们可以举个例子:
比如上面这个类,_num再没有指定特定的作用域之前,是默认优先选择派生类里的同名变量(或函数),这就构成了隐藏,如果确实要访问基类里的成员就用上述所说的指明是哪一个基类即可。这里特别说明一下函数,若基类与子类的两个成员函数同名,则是构成隐藏,而不是重载,重载要在同一作用域内。
4.派生类的默认成员函数
1.构造函数:
<1>如果我们没有写,由编译器生成默认的构造函数,则对派生类成员来说,就是跟普通的默认构造没什么区别,内置类型和自定义类型分别处理。而对派生类里的基类成员来说,它们会返回去调基类的默认构造(把基类比作爹,把派生类比作儿子,那这个过程就是相当于爹活只能爹干,儿子的活只能儿子干)。
<2>如果我们要显示的写构造函数,直接在初始化列表里面初始化是会报错的
像这张图一样,我们是没法在B 这个类里面初始name这个基类变量,所以在派生类里头我们一般是不用特地初始化基类成员变量的函数,因为基类的成员会自己调自己的默认构造函数。如果非要在派生类里初始化,只能将在初始化列表里直接加上基类的构造函数(基类的构造此时要自己写,不然会报错,因为如果不写编译器会找不到对应的构造函数)。
那么这里的初始化顺序是怎么样的呢?
答案其实显而易见,在学习默认构造函数时,我们就知道初始化的顺序跟初始化列表写的顺序没有关系,只和声明的顺序相关,在这里基类肯定比派生类要先声明,所以这里基类成员肯定会先初始化。
2.析构函数
其实析构函数大体跟构造函数差不多,只不过在一些地方有些差异,这里将介绍一些差异。
先看这张图:
我们可以发现,在对B这个类进行析构时,里面想调A 的析构函数是调不到的,这是因为隐藏的缘故,或许你会疑惑为什么函数名不同都能构成隐藏,这里其实跟多态的知识相关联,这里的函数名都会被处理成destructor(),这里也就变成了隐藏关系,由于牵扯到后面知识,这里就不在赘述。
如果我们一定在B这个派生类里调A这个基类的析构函数怎么办呢,在函数名前加上作用域即可(如下图),
在程序运行以后,我们会发现一个问题,那就是~A 函数调用了两次,这是为什么呢?
其实这是编译器为了保证析构时的顺序时先子后父,再派生类的析构函数结束后,编译器会自动调用基类的析构函数,这里说明一下为什么析构时要按照先子后父。
原因:正常情况下,我们不显示调父类(基类)的析构时,派生类里的基类成员会调自己基类构造函数。假设是基类成员先析构,如果在派生类析构函数里,是有权限调度基类的成员,如果此时派生类的析构对基类成员进行访问,就有可能出现野指针的情况,那程序就有可能会崩溃。所以在平常写的时候不建议显示地写基类的析构函数。
小结一下:其实这些默认成员函数规则其实和普通类是差不多的,只不过在继承这一部分,多了父类的成员,父类的那一部分将有父类自己的函数完成。
5.友元与继承
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员 (简单理解就是,你父亲的朋友不是你的朋友),如下图所示。
6.继承与静态成员变量
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例 。(无论你指定基类还派生类的作用域访问的都是同一个静态变量)
7.复杂的菱形继承及菱形虚拟继承
继承分为单继承,多继承与菱形继承,单继承就是子类只有一个直接父类(如下图)
多继承就是子类有两个及以上的父类(如下图)
单继承与多继承其实理解起来难度没什么大,最主要的菱形继承,这个继承相对复杂,这里着重介绍。
如上图,这里的继承关系就是菱形继承,这里会产生一些歧义,下面举个例子
在上面的者个例子中我们可以看见,A 被 B 与 C 所继承,同时D 又继承了B 与 C。 此时我们访问name 这个变量时,就会出现歧义,到底是访问B 中 A的变量,还是C 中 A 的变量呢?此时编译器无法确定,就会报错。简单的解决办法就是在name 前指明是哪个类,比如我要调B 里面继承的name , 那就在name 加上 "B :: "即可。当然,这个解决方式只是暂时解决了二义性(有歧义的意思)的问题,并没有解决数据冗余的问题,在D这个类中我们只要有一个name 变量即可。
这里介绍一下官方的解决办法
在B 和 C 这两个继承方式符号前加一个virtual 关键字
此时问题就得到解决了,我们可以打开监视窗口看看name 到底是不是只有一个。
打开测试窗口,我们会发现,所有的name都变成了666,说明在D 这个类里只有一个name变量,如果你还不相信,可以打印name的地址,这里不在赘述。
下面介绍一下这个方法的原理
这里我们先把继承方式符号前的virtual 去掉,然后依次设定变量的值,在内存中观察他们,
正常在内存中,变量的存储方式与上图一致,加上virtual后我们再观察一下有什么变化
可以看到,被virtual 修饰后,A 的成员变量已经跑到最下面来了。通过观察我们会发现B 和 C 这个类里面有存了一串数字(16进制),推测它可能是地址,我们可以通过内存窗口观察一下到底这个地址里面到底有什么东西。
我们可以发现这两个地址里面都存了一个数字,分别是0x00000014 和 0x0000000c 转换成十进制,就是20 跟12 。此时我们如果将C 类所在的地址加12 和 B类的地址加20 我们就会发现它们的和刚好就是A 的地址,这里的指针指向的地址就叫作虚基表,里面存的是相对于A 的偏移量,或许你会疑惑为什么不能在虚基表里的第一个位置存偏移量呢?,其实这里还要存储其他内容,由于牵扯后面的内容,所以这里先卖个关子。需要注意的是,所有D 类的对象中的虚基表指向的都是同一个位置,不会因为对象不同而改变。
另外,如果在虚继承时,发生了赋值转换(切片),此时访问基类时通过虚机表里面的偏移量来实现的,而这就降低了访问速度,所以为了解决这个问题,代价很大。
补充一点,我们在编译时大多数变量的地址其实就确定了,但虚继承的对象成员(基类)要在运行时才能有确定的地址。
8.总结:
1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱
形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有会问题。
2. 多继承可以认为是C++的缺陷之一,很多后来的面向对象语言都没有多继承,如Java(因为实在太坑了)。
3. 继承和组合
<1>public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
<2>组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
<3>优先使用对象组合,而不是类继承 。
<4>继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
<5>对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。
<6>组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。(总结引自比特课件)
感谢各位读者的阅读,如有错误之处还望各位大佬指出,谢谢!!!
标签:函数,inheritance,继承,成员,派生类,C++,基类,变量 From: https://blog.csdn.net/2302_79538079/article/details/136630189