继承
1.基本概念
继承机制是面向对象程序设计使代码可以复用的重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称为派生类。
继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,而继承便是类设计层次的复用。继承会得到基类里的所有成员。
2.继承方式
如果不写继承方式,class默认是私有继承,struct默认是公有继承
protected/private:在类外面不能访问,在类里面可以访问
公有继承、保护继承、私有继承,总的来说就是取小的那个就对了
私有成员的意义:不想被子类继承的成员可以设计成私有。但是不代表子类没有继承私有
私有和保护的区别:基类中想给子类复用,但是又不想暴露直接访问的成员时,就应该定义成保护
基类私有成员无论以什么方式继承都是不可见的。这里的不可见指的是基类的私有成员还是被继承到了派生类对象,但是派生类不管在类内还是类外都访问不了它了。
继承中的作用域
C++定义以后会产生域,C语言是局部域,全局域,有些域会影响生命周期,有些域不会,
类域不影响生命周期,只是影响访问。父类和子类是可以定义同名变量的,子类访问时先访问的是子类的,如果就是想访问父类的,则要指定作用域
实际中尽量不要定义同名的成员。(父类和子类是在各自的作用域,不是同一作用域)。
如果同名,例如子类和父类都定义了name,子类会继承父类的name,会有两个name。这时会隐藏父类的。如果想使用父类的,需要指定作用域。
1.在继承体系中,基类和派生类都有独立的作用域。
2.子类和父类中有同名成员,成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义(如果想在子类成员函数中使用基类成员,可以基类::基类成员以此显示访问
3.对于成员函数,只要函数名相同,就构成隐藏。
以下程序,两个func的关系是什么?两个func构成函数重载嘛?
class A { public: void func() { ; } }; class B : public A { public: void func(int n) { ; } };
不构成重载,因为函数重载要求在同一作用域。
对象中不存储成员函数,只存储成员变量。
继承后类的大小的改变:多出了父类的成员变量。父类和子类的成员函数还是在各自的代码段中的。
2.赋值兼容转换
公有继承情况下:
子类对象可以赋值给父类的对象/指针/引用,这个叫做赋值兼容转换,也叫切割或切片(虽然是不同类型,但不是隐式类型转换)
我们之前讲的同类型变量可以赋值,不同类型间赋值要进行强制类型转换或隐式类型转换,
赋值时不用加const就说明了没有发生隐式类型转换。
person& rp = sobj;这句代码能通过就说明了它不是隐式类型转换,因为产生的临时变量具有常性,要实现转换的话,应该加const才可以,但是这里不加却可以
不是说切出来拿走,而是切出来拷贝给父类
但是下面的代码不需要加const就可以编译通过,所以这里不是隐式类型转换
父类对象给子类对象赋值是不行的,即使强制类型转换也不支持,因为少了一部分东西。如果是指针或引用的话是可以的,但是是很危险的,存在越界访问的问题。
派生类的默认成员函数
我们现在写了父类和子类,那子类的六个默认成员函数怎么办呢?
派生类如何初始化?
子类编译默认生成的构造函数会干什么?
1.对于自己的成员,和以前一样(和普通只定义一个类和对象一样,当成一个普通类就可以,即调用自己的构造函数、析构函数等等)
2.对于继承的父类成员:必须调用父类的构造函数初始化
如果父类没有默认构造呢?这时子类无法生成默认构造,因为它处理不了父类的成员,因为其必须得调用父类的默认构造处理,所以我们需要给子类写一个构造
应该这么写
不允许子类的构造函数初始化父类。要求是这样的:子类的构造函数只能初始化自己的成员,对于父类的成员,子类只能调用父类的构造函数处理
编译生成默认拷贝构造:
1.对于自己的成员:跟类和对象一样(对于内置类型值拷贝,对于自定义类型调用它的拷贝构造)
2.对于继承的父类成员:必须调用父类的拷贝构造初始化。
派生类不写都可以直接调用父类的拷贝构造。(都是自定义类型)
需要我们拿到子类对象中的父类对象。我们把子类对象传给父类对象的引用就构成了切片
也可以强制类型转换等等,不过没什么必要
注意:有深拷贝的时候才需要显式写S
切片很重要
编译器默认生成的operator=、析构都同上。写赋值时记得不能自己给自己赋值(子类和父类的operator=会构成隐藏的)
栈溢出基本只有一种情况,就是无限递归了
自己的成员:构造函数、析构函数:内置类型不处理,自定义类型调用他的析构、构造
继承的成员:调用父类析构、构造函数处理
想显式写
析构函数比较特殊
子类的析构函数与父类的析构函数构成隐藏。
这样就可以了
但是我们其实不应该显示的写,因为子类会自动调用父类的析构函数。(为什么多次析构没问题呢,因为没做什么事情,如果析构函数里有对指针的释放,那么就会出问题了,因为对指针释放了两次)
子类和父类的析构函数名不同,为什么它两构成隐藏?
由于之后多态的需要,析构函数的名字会被同一处理成destructor()
先定义的先初始化,后定义的后初始化,先定义的后析构,后定义的先析构,因为要符合后进先出。父类的先构造,子类再构造,子类先析构,父类后析构。
编译器默认生成的赋值同上
如果我们自己显示写调用父类的析构函数的话,顺序就无法保证了,所以子类的析构函数中不需要显式调用父类的析构函数,因为每个子类析构函数后面,会自动调用父类析构函数,这样才能保证先析构子类,再析构父类
静态变量一定是不被包含在对象中的
子类继承父类的相同名字的变量后,我们实际访问的是子类的该变量,其类似于全局变量和局部变量访问时的就近原则。如果想访问父类的,可以指定作用域
为什么栈溢出?
因为这里递归调用了。一直在调用子类的。(子类和父类的operator=构成了隐藏关系)如果想调父类的, 需要我们指定一下
析构也是:编译器自动生成的析构函数,内置类型不处理,自定义类型调用他的析构
构造和析构可以看成一类,拷贝构造和赋值运算符重载可以看成一类
因为子类的析构函数跟父类析构函数构成隐藏。这里其实是找不到该函数(~Person)。
由于多态的需要,析构函数的名字会被统一处理成destructor()
子类对象不用显示调用父类 ,因为它会自动调用
取地址重载不用调用父类的,不用写成合成版本
继承与友元
友元关系不能被继承,也就是说基类的友元不能访问子类私有和保护成员
某个函数是父类的友元,父类被子类继承,不代表它是子类的友元,如果想访问,需要把该函数弄成子类的友元
继承与静态成员
整个继承体系里,只有一个静态成员
顾名思义
静态成员是存在静态区的,且基类与派生类访问的count是同一份
如果没有明确说明,一般都是说公有继承
统计有多少个人,在Person里++一个统计数值
多继承,Derive继承了Base1和Base2,先继承的在前
p2大,因为在栈里
derive其实就是12个字节
单继承和多继承
单继承:一个子类只有一个直接父类时,称这个继承关系为单继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承:菱形继承是多继承的一种特殊情况
Person这个信息在assistant里面就会有两份,两份会导致数据冗余,重复占用空间了,构造了两次,拷贝了两次。
二义性:assistant要访问name时,不知道要访问谁的。
如何定义一个不能被继承的类:C++98:将父类的构造函数私有,这时子类对象实例化时,就会无法调用构造函数。
这样在继承时就会报错
C++11新增了一个final关键字,也叫最终类。
内存中并没有存Base1、Base2,只是编译器在调试窗口方便我们看
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题,在assistant的对象中person成员会有两份
研究生助教,既是学生,也是老师。
有两份数据。
二义性,编译器不知道调谁的_name。
指定作用域就可以了,但这样还是有时会造成空间浪费。
也属于菱形继承。为什么左边的virtual放在B而不是C?
如何解决上述问题?
虚继承(菱形虚拟继承)
在腰部位置加上virtual(它和后面讲的多态不同)
就可以这样了,这三种方式访问到的都是同一份
菱形虚拟继承解决了数据冗余和二义性
iostream菱形继承了istream和ostream。
多继承的例子:
以上就会有数据冗余和二义性的问题。
多继承:
菱形虚拟继承:
我们可以把中间虚继承了
这时就可以
我们可以看到,重复的成员被放到了同一地址,同一成员只有一份。只有这一个地址发生改变。 重复的成员放到了公共的地方。
解决了数据冗余和二义性,但是看上去时变大了,并没有减少空间。真正多的是两个指针,多了8字节。那如果a很大呢,例如a是4000字节,多存一份不就是多40000字节,而如果是虚拟继承,只是多一个指针。
这多出来的数据实际代表的是偏移量十六进制(这12和20是距离也是偏移量)。该数据+偏移量得到的就是_a。
偏移量的作用在于:以后要切片的时候,用指针地址加偏移量,就可以找到继承的被重复的数据
有数据冗余和二义性
在虚继承里,B对象有_a,只是怎么存的问题。 但是实际计算B的大小是12。
我们发现除了1和2还有一个指针(这个指针指向一个表,表里存的是偏移量。为什么不直接把偏移量存在这里呢?怕遇到下面这样的场景)在这里 .公共a是放在最下面的(高地址处),它是怕遇到这种场景
但我们不知道这个B*的指针是指向B对象还是D对象。对象里只有一个指向表的指针,这个表叫虚基表,通过偏移量找虚基类。
父类有多个成员需要多个指针吗?不用,能找到第一个,就能找到第二个。它是把对象当成整体的
如何定义一个不能被继承的类?
C++98:
1.父类构造函数私有(即子类不可见)
2.这样就会导致子类对象实例化时,无法调用构造函数(以此来间接实现该类不能被继承)
倒不是继承时会报错,而是实例化对象时会报错
而C++11做出了改进:
新增了关键字final,也叫最终类
这样就会直接报错说A不可以被继承,因为已经被声明成final
多态继承中的指针偏移:
对象的分布上:先继承的在前面
菱形继承二义性的解决:指定作用域
数据冗余的解决:虚继承(virtual)
菱形虚拟继承解决了二义性和数据冗余,还节省了空间(例如当父类的成员变量很大时)
选C
p2=p3!=p1
继承和组合
公有继承是一种is-a的关系,也就是说每个派生类对象都是一个基类对象。例如:人和学生的关系(每一个学生都是人),植物和玫瑰花的关系(玫瑰花都是植物)。
什么是组合呢?是一种has-a的关系例如:头和眼睛的关系、轮胎和车
组合也是复用,相比较公有继承,组合的权限较小(能访问组合的公有,但是不能访问保护和私有)。实际中,哪个符合就用哪个。实际运用中,一些类型的关系既可以认为是is-a,也可以认为是has-a(既适合用继承,又适合用组合),这种情况下,优先使用对象组合,而不是类继承
组合:
低耦合,高内聚,所以优先使用组合。
组合是黑箱复用,继承是白箱复用
oj的测试用例就是白盒测试。软件工程提倡联系越少越好,也就是低耦合,互相影响小。
白箱复用、黑箱复用。
黑盒测试:不知道你的实现,我以功能的角度去进行测试。(耦合度低)
白盒测试:我知道你的实现,针对你的实现去进行测试。(耦合度高)
继承耦合度高(依赖关系强)。我们提倡耦合度低,越低越好
UML图、类图
多态
多态:多种形态,具体点就是去完成某个行为,当不同对象去完成时会产生出不同的状态。
例如买票,学生、军人买票和普通人买票就不一样。学生、军人、普通人就是三种不同类型的对象。学生买到半价票,普通人买票,军人不用排队买票,他们产生出的状态不同
做同一件事,但是结果不同。
只有成员函数才能加virtual,加了virtual之后这个函数就叫做虚函数。只有成员函数才能是虚函数。
(这里的virtual和虚继承那里的不一样,就像delete也有两种作用一样,就像取地址和引用这种)
虚函数(下图buyticket)间的关系:重写(覆盖)
虚函数的重写/覆盖条件:虚函数函数名、参数、返回值类型相同
注:不符合重写,才是隐藏关系(缺省参数给多少/有没有缺省参数并不影响,当然,参数类型不同就不构成重写了,参数名不写也不行,(参数名不同呢?可以吗?))
多态有两个条件:
1.虚函数的重写
2.使用父类的指针或引用去调用虚函数。
这两个条件有一个不满足就构不成多态
这时和p的类型已经没有关系了,而是看其指向(引用)的对象
1.不是父类的指针或引用去调用:
这样g更不行
2.不符合重写virtual
但是父类写,子类不写virtual可以
3.参数不同
不构成多态,看类型,构成多态,看它指向的对象
在构成虚函数时,如果去掉父类,则不符合重写了,不是虚函数了
多态特例:
特例1:如果去掉子类的virtual,则还是虚函数(但去调父类,则不符合重写,就不是虚函数了),因为它认为是先把父类的虚函数继承下来,继承下来之后重写,重写的是它的实现,所以虽然子类中我们没写virtual,但是还是认为是它的实现(子类虚函数不加virtual,依旧构成重写)。实际中最好加上virtual。
特例2:重写的协变。即返回值可以不同,但是要求返回值必须是父子关系的指针或者引用。不然会报错。子类返回的是父类的话也不可以,会报错。
这样会报错,这样才对。(即使是父子对象也不行,必须是父子指针或引用,且父是父,子是子)
参数不同:
题:
选B,首先,test不是多态,test的this指针是B类型的,this指针调用func,调用的就是B的func。func构成重写,但是val实际是1,因为虚函数重写是一个接口继承(接口就是把函数的函数名参数返回值等架子拿下来),普通函数继承是实现继承(主要继承的是你的实现)。
接口继承是重写实现。
编译器识别是不是重写时,都不怎么看子类的相关字段的,所以子类成员函数前面加不加virtual无所谓
这样也可以
这样呢
带虚函数对象的大小的计算
考察的不是内存对齐,考察的是多态,因为该对象里会多一个指针,这个指针叫虚表指针(vftptr虚函数指针),(vftable虚函数表),表:能存储多个数据的一个东西。其实是用了一个数组把虚函数的地址存到虚函数表里。父类和子类不是共用一个虚表,当函数被重写时,虚函数表内的该函数地址就会发生变化。虚函数会把它的地址放到虚函数表。多态的原理的关键:虚函数会存进虚表,对象里没有虚表,有的是一个虚表的指针。虚函数重写以后呢?
虚函数准确调用的原理:如果传的是父类对象,那就去父类对象的虚函数表,如果是子类对象,那就去子类对象的虚函数表
多态的本质原理:符合多态的两个条件,调用时,会到指向对象的虚表中找到对应的虚函数地址进行调用
虚函数表是一个函数指针数组,里面存的是虚函数的指针
多态调用:程序运行时,去指向对象的虚表中找到函数的地址,进行调用
普通函数调用:在编译(编译时就有函数的地址)或链接(在符号表里找)时确定(去找)函数的地址,运行时直接调用
多态的原理:虚表(虚函数表)
多态的条件:1.必须完成虚函数的重写2.必须是父类指针或引用去调用虚函数。才能有多态的效果
只要不符合条件,就是直接调用。
看看构成多态和不构成多态调用的区别:
虚函数是被存在哪里的呢?
虚函数不是存在虚表里的,任何函数都是编译好了变好指令,放在公共代码段的。我们写的代码编译好之后的这个文件,是一个可执行程序,程序运行起来之后变成一个进程,进程分段把我们的代码指令加到代码段里去了。
那么虚表里面放的是什么呢?
虚表里实际上放的只是函数的地址,
实际调用时,如何得知该调用的是子类还是父类的虚函数?
虽说会发生切割切片,也就是都看到的是父类对象,但是因为指针指向虚函数表里的不同,所以可以得知。(父类对象和子类对象指向的并不是同一个虚表,因为子类的虚表被重写了也就是覆盖了)
C++是怎么考量这种行为的呢?
首先,虚函数是存在公共代码段的。
函数的地址是什么?可能是第一句指令的地址。
虚表里放的只是函数的地址。
父类对象和子类对象不是指向同一个虚表,因为这个虚表被重写了(即被覆盖了),是一个子类的虚函数。虚函数表本质是一个函数指针的数组。
因为是一个引用,如果是父类对象的引用,找到的就是父类的虚表
构成多态的调用,运行时到指向对象虚表中找调用虚函数地址,所以p指向谁,调用谁的虚函数
不构成多态调用,属于普通调用,编译时确定调用函数的地址。
虚函数重写的要求:1.要求父类子类都是虚函数(子类可以不写virtual)2.要求函数名、参数、返回值都相同
如果父类有虚函数,子类没有虚函数重写,那么编译器调用时是编译时决议还是运行时决议?
是运行时决议,编译器只是检查父类有没有虚函数,并且是引用,那么久直接去虚表里找。父类的虚表和子类的虚表在这种情况下,都是同一个虚函数都是父类的虚函数。
有些地方会要求析构函数加virtual。
析构函数是否建议加virtual呢?
在继承中,建议把析构函数定义成虚函数。
加了virtual以后,子类和父类能构成虚函数的重写(子类重写了父类)。普通场景析构函数是不是虚函数都没问题,但是当用一个指针接收new出来的子类对象时,再去delete这个指针。同理来一个new出来的父类。不加virtual时,new的是子类对象,但是调用的会是父类的析构函数,符合多态按照多态去调,不符合就按编译决议。
析构函数函数名不同,为什么构成重写呢?
因为析构函数会被处理成destructor,所以加了virtual是完成虚函数重写的。
子类析构函数重写父类析构函数,才能正确调用。指向父类调用父类析构函数。指向子类调用子类析构函数
虚函数重写的两个例外:
1.协变(基类与派生类虚函数返回值类型不同,但是返回值必须是父子关系的指针或引用,也可以是其他父子,必须是先父后子)
2.析构函数的重写
虚表个数:单继承情况下一般只有一个虚表
C++11增加两个关键字
final:去修饰一个类,这个类就不能被继承。或如果一个虚函数不想被重写,就可以在虚函数后面加一个final。virtual void person() final{}
override:C++11新增关键字。只能加在子类,加在子类去检查子类的虚函数是否完成重写,没有完成重写就会报错。virtual void person() override{}
重载、重写(覆盖)、隐藏(重定义)的区别是什么?
相同点:它们都是函数之间的关系。
重载:要求1必须在同一作用域。要求2函数名相同,参数不同(类型、顺序、个数,有两种特殊情况)
重写(覆盖):要求1两个函数分别在基类和派生类的作用域。要求2三同(函数名、参数、返回值,协变例外)要求3两个函数都必须是虚函数。有时子类虽然不写,但是也算是虚函数。
重定义(隐藏):要求1:两个函数分别在基类和派生类的作用域。要求2函数名相同。要求3两个基类和派生类的同名函数不构成重写就是重定义
多态的原理
虚表不是在对象里,对象里只有虚表指针。虚函数会放进虚表,虚函数重写以后,父类里会有虚表。
共用一个虚表还是各自用各自的虚表呢?
共用一个虚表。如果各自用各自的,不就浪费了,因为p1和p2的虚表指针都是相同的。同一个类型的对象共用一个虚表。
多继承的虚表个数
这个func3在哪里放着呢?
在第一个虚表里,谁先继承就在谁里。
但是父类和子类就不一样了,父类用父类的,子类用子类的
多态的条件:
1.函数的重写
2.父类指针或引用去调用
2破坏就会不构成多态
虚函数重写的条件:
1.虚函数
2.函数名、参数、返回值
重写了,父类是父类的虚表,子类是子类的虚表。
如果没有完成重写,用的是不是同一个虚表呢?没有完成重写,那父类的虚表里是父类的虚函数,子类的虚表里也是父类的虚函数。是不是同一个虚表呢?不是。虽然里面的内容是一样的。
在vs下,不管是否完成重写,子类的虚表跟父类的虚表都不是同一个。(和编译器有关),如果是一个,也是有可能的。
1.派生类只有一个虚表指针,因为派生类是继承的父类,父类里面有一个虚表指针,它就不会再生成虚表指针了。所以单继承的派生类里面也就只有一个虚表指针了。
2.重写为什么也叫作覆盖呢?
覆盖是原理层的叫法。子类的虚表是把父类的虚表拷贝下来,重写的虚函数那个位置会被覆盖成子类的虚函数,重写是语法层的叫法,覆盖是原理层的叫法。
不管我子类是不是重写,如果我子类自己还有虚函数呢?
比如说里面还有一个没有重写的虚函数,这个虚函数放哪呢?
只要是虚函数,虚函数的地址都会放进虚表。
有些编译器(例如vs下)无法看到子类中自己单独写的虚函数。这并不代表其不进虚表。它是进虚表的。
虚函数表是一个函数指针数组,我们打印数组里的内容就可以。
注意函数指针取别名时:typedef void(*别名)();
取虚表的对象:*(int*)&s1
为什么不放在对象里,而是放在虚表里呢?
一个对象可能会有多个虚函数,多个虚表指针
虚函数指针会放在虚表,重写以后,父类里会有一个虚表
子类中会有父类成员,子类与父类的虚表存的内容不完全一样,因为有的部分被重写了
虚函数存在哪里?
不是在虚表里,而是在代码段,任何函数都是在代码段的。虚表里放的是函数的地址,因为虚表本质是函数指针数组
任何函数都是编译好了变成指令,放在公共代码段的。
虚表里放得只是函数的地址,虚函数还是放在公共代码段的。
父类对象与子类对象指向的虚表不同
子类没有函数,只有父类有虚函数,是否构成多态?
构成,因为编译器不看子类完没完成重写,只是粗略检查父类有没有虚函数。
但是我们会发现,子类的虚函数表里存的是父类的虚函数,因为这时没有覆盖。也就是如果不完成重写,我们把搞成虚函数就没什么意义,反而会使效率变慢。
析构函数建议加virtual,这样能和父类的析构函数构成重写,不同析构函数名不同,为什么构成重写?为什么函数名不相同还是构成重写?
因为析构函数统一被处理成destructor,所以可以认为它们函数名相同,这样处理的目的就是让他们构成重写。
为什么要求构成重写?00:33:00因为可能出现这种错误
不加virtual,析构函数的调用错误,因为这是一个普通调用,delete先调用destructor,再调用operator delete(ptr2),现在不符合多态,编译时决议,
子类析构函数重写父类析构函数,才能正确调用,指向父类调用父类析构函数,指向子类调用子类析构函数
建议在继承中,析构函数定义成虚函数
25节00:48:00-00:55:00-26节2:30:00
抽象类
包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化(即不能定义)出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。派生类继承后规范了派生类必须重写,另外,纯虚函数更体现出了接口继承。
如果不想让一个对象实例化,就把它定义成抽象类
纯虚函数是不需要实现的,想实现也可以。(可以有函数体,也可以没有函数体)
override是检查重写
抽象类是强制重写
父类无法实例化时,子类也无法实例化,因为子类继承了父类,继承之后子类里也有了纯虚函数,有纯虚函数,如果子类不重写,子类也是抽象类,不合理,可以说是间接强制了子类必须去重写,不然子类会无法实例化出对象。
接口继承和实现继承
虚函数的实现是接口继承。普通继承是实现继承。