欢迎关注
0voice · GitHub
23
、虚函数相关(虚函数表,虚函数指针),虚函数的实现原理
⾸先我们来说⼀下,
C++
中多态的表象,在基类的函数前加上
virtual
关键字,在派⽣类中᯿写该函数,运⾏时将会
根据对象的实际类型来调⽤相应的函数。如果对象类型是派⽣类,就调⽤派⽣类的函数,如果是基类,就调⽤基类
的函数。
实际上,当⼀个类中包含虚函数时,编译器会为该类⽣成⼀个虚函数表,保存该类中虚函数的地址,同样,派⽣类
继承基类,派⽣类中⾃然⼀定有虚函数,所以编译器也会为派⽣类⽣成⾃⼰的虚函数表。当我们定义⼀个派⽣类对
象时,编译器检测该类型有虚函数,所以为这个派⽣类对象⽣成⼀个虚函数指针,指向该类型的虚函数表,这个虚
函数指针的初始化是在构造函数中完成的。
后续如果有⼀个基类类型的指针,指向派⽣类,那么当调⽤虚函数时,就会根据所指真正对象的虚函数表指针去寻
找虚函数的地址,也就可以调⽤派⽣类的虚函数表中的虚函数以此实现多态。
补充
:如果基类中没有定义成
virtual
,那么进⾏
Base B; Derived D; Base *p = D; p->function();
这种情况下调⽤
的则是
Base
中的
function()
。因为基类和派⽣类中都没有虚函数的定义,那么编译器就会认为不⽤留给动态多态
的机会,就事先进⾏函数地址的绑定(早绑定),详述过程就是,定义了⼀个派⽣类对象,⾸先要构造基类的空
间,然后构造派⽣类的⾃身内容,形成⼀个派⽣类对象,那么在进⾏类型转换时,直接截取基类的部分的内存,编
译器认为类型就是基类,那么(函数符号表[不同于虚函数表的另⼀个表]中)绑定的函数地址也就是基类中函数
的地址,所以执⾏的是基类的函数。
24
、编译器处理虚函数表应该如何处理
对于派⽣类来说,编译器建⽴虚函数表的过程其实⼀共是三个步骤:
拷⻉基类的虚函数表,如果是多继承,就拷⻉每个有虚函数基类的虚函数表
当然还有⼀个基类的虚函数表和派⽣类⾃身的虚函数表共⽤了⼀个虚函数表,也称为某个基类为派⽣类的主基
类
查看派⽣类中是否有᯿写基类中的虚函数, 如果有,就替换成已经᯿写的虚函数地址;查看派⽣类是否有⾃
身的虚函数,如果有,就追加⾃身的虚函数到⾃身的虚函数表中。
Derived *pd = new D(); B *pb = pd; C *pc = pd;
其中
pb
,
pd
,
pc
的指针位置是不同的,要注意的是派⽣类的⾃
身的内容要追加在主基类的内存块后。
25
、析构函数⼀般写成虚函数的原因
直观的讲:是为了降低内存泄漏的可能性。举例来说就是,⼀个基类的指针指向⼀个派⽣类的对象,在使⽤完毕准
备销毁时,如果基类的析构函数没有定义成虚函数,那 么编译器根据指针类型就会认为当前对象的类型是基类,调
⽤基类的析构函数 (该对象的析构函数的函数地址早就被绑定为基类的析构函数),仅执⾏基类的析构,派⽣类的
⾃身内容将⽆法被析构,造成内存泄漏。
如果基类的析构函数定义成虚函数,那么编译器就可以根据实际对象,执⾏派⽣类的析构函数,再执⾏基类的析构
函数,成功释放内存。
26
、构造函数为什么⼀般不定义为虚函数
虚函数调⽤只需要知道
“
部分的
”
信息,即只需要知道函数接⼝,⽽不需要知道对象的具体类型。但是,我们要
创建⼀个对象的话,是需要知道对象的完整信息的。特别是,需要知道要创建对象的确切类型,因此,构造函
数不应该被定义成虚函数;
⽽且从⽬前编译器实现虚函数进⾏多态的⽅式来看,虚函数的调⽤是通过实例化之后对象的虚函数表指针来找
到虚函数的地址进⾏调⽤的,如果说构造函数是虚的,那么虚函数表指针则是不存在的,⽆法找到对应的虚函
数表来调⽤虚函数,那么这个调⽤实际上也是违反了先实例化后调⽤的准则。
27
、构造函数或析构函数中调⽤虚函数会怎样
实际上是不应该在构造函数或析构函数中调⽤虚函数的,因为这样的调⽤其实并不会带来所想要的效果。
举例来说就是,有⼀个动物的基类,基类中定义了⼀个动物本身⾏为的虚函数
action_type()
,在基类的构造函数中
调⽤了这个虚函数。
派⽣类中᯿写了这个虚函数,我们期望着根据对象的真实类型不同,⽽调⽤各⾃实现的虚函数,但实际上当我们创
建⼀个派⽣类对象时,⾸先会创建派⽣类的基类部分,执⾏基类的构造函数,此时,派⽣类的⾃身部分还没有被初
始化,对于这种还没有初始化的东⻄,
C++
选择当它们还不存在作为⼀种安全的⽅法。
也就是说构造派⽣类的基类部分是,编译器会认为这就是⼀个基类类型的对象,然后调⽤基类类型中的虚函数实
现,并没有按照我们想要的⽅式进⾏。即对象在派⽣类构造函数执⾏前并不会成为⼀个派⽣类对象。
在析构函数中也是同理,派⽣类执⾏了析构函数后,派⽣类的⾃身成员呈现未定义的状态,那么在执⾏基类的析构
函数中是不可能调⽤到派⽣类᯿写的⽅法的。所以说,我们不应该在构在函数或析构函数中调⽤虚函数,就算调⽤
⼀般也不会达到我们想要的结果。
32
、深拷⻉和浅拷⻉的区别(举例说明深拷⻉的安全性)
当出现类的等号赋值时,会调⽤拷⻉函数,在未定义显示拷⻉构造函数的情况下, 系统会调⽤默认的拷⻉函数-即
浅拷⻉,它能够完成成员的⼀⼀复制。当数据成员中没有指针时,浅拷⻉是可⾏的。
但当数据成员中有指针时,如果采⽤简单的浅拷⻉,则两类中的两个指针指向同⼀个地址,当对象快要结束时,会
调⽤两次析构函数,⽽导致指ᰀ指针的问题。
所以,这时必需采⽤深拷⻉。深拷⻉与浅拷⻉之间的区别就在于
深拷⻉会在堆内存中另外申请空间来存储数据,从
⽽也就解决来ᰀ指针的问题
。简⽽⾔之,当数据成员中有指针时,必需要⽤深拷⻉更加安全。
33
、什么情况下会调⽤拷⻉构造函数
(
三种情况
)
类的对象需要拷⻉时,拷⻉构造函数将会被调⽤,以下的情况都会调⽤拷⻉构造函数:
⼀个对象以值传递的⽅式传⼊函数体,需要拷⻉构造函数创建⼀个临时对象压⼊到栈空间中。
⼀个对象以值传递的⽅式从函数返回,需要执⾏拷⻉构造函数创建⼀个临时对象作为返回值。
⼀个对象需要通过另外⼀个对象进⾏初始化。
34
、为什么拷⻉构造函数必需时引⽤传递,不能是值传递?
为了防⽌递归调⽤。当⼀个对象需要以值⽅式进⾏传递时,编译器会⽣成代码调⽤它的拷⻉构造函数⽣成⼀个副
本,如果类
A
的拷⻉构造函数的参数不是引⽤传递,⽽是采⽤值传递,那么就⼜需要为了创建传递给拷⻉构造函数
的参数的临时对象,⽽⼜⼀次调⽤类
A
的拷⻉构造函数,这就是⼀个⽆限递归。
35
、结构体内存对⻬⽅式和为什么要进⾏内存对⻬?
⾸先我们来说⼀下结构体中
内存对⻬的规则
:
对于结构体中的各个成员,第⼀个成员位于偏移为
0
的位置,以后的每个数据成员的偏移ᰁ必须是
min(#pragma pack()
制定的数,数据成员本身⻓度
)
的倍数。
在所有的数据成员完成各⾃对⻬之后,结构体或联合体本身也要进⾏对⻬,整体⻓度是
min(#pragma pack()
制定的数,⻓度最⻓的数据成员的⻓度
)
的倍数。
那么
内存对⻬的作⽤
是什么呢?
经过内存对⻬之后,
CPU
的内存访问速度⼤⼤提升。因为
CPU
把内存当成是⼀块⼀块的,块的⼤⼩可以是
2
,
4
,
8
,
16
个字节,因此
CPU
在读取内存的时候是⼀块⼀块进⾏读取的,块的⼤⼩称为内存读取粒度。⽐
如说
CPU
要读取⼀个
4
个字节的数据到寄存器中(假设内存读取粒度是
4
),如果数据是从
0
字节开始的,
那么直接将
0-3
四个字节完全读取到寄存器中进⾏处理即可。
如果数据是从
1
字节开始的,就⾸先要将前
4
个字节读取到寄存器,并再次读取
4-7
个字节数据进⼊寄存
器,接着把
0
字节,
5
,
6
,
7
字节的数据剔除,最后合并
1
,
2
,
3
,
4
字节的数据进⼊寄存器,所以说,当内
存没有对⻬时,寄存器进⾏了很多额外的操作,⼤⼤降低了
CPU
的性能。
另外,还有⼀个就是,有的
CPU
遇到未进⾏内存对⻬的处理直接拒绝处理,不是所有的硬件平台都能访问任
意地址上的任意数据,某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。所以内存
对⻬还有利于平台移植。