天天动画片 > 八卦谈 > c++ 虚函数工作原理

c++ 虚函数工作原理

八卦谈 佚名 2024-03-04 00:29:18

1.为什么调用普通函数比调用虚函数的效率高?

因为普通函数是静态联编的,而调用虚函数是动态联编的。

联编的作用:程序调用函数,编译器决定使用哪个可执行代码块。(所谓联编就是将函数名和函数体的程序连接到一起的过程)

静态联编 :在编译的时候就确定了函数的地址,然后call就调用了。

(静态联编本质是系统用实参与形参进行匹配,对于重名的重载函数根据参数上的差异进行区分,然后进行联编,从而实现编译时的多态。函数的选择基于指向对象的指针类型或者引用类型。)

动态联编 : 首先需要取到对象的首地址,然后再解引用取到虚函数表的首地址后,再加上偏移量才能找到要调的虚函数,然后call调用。

(动态联编本质上是运行阶段执行的联编,当程序调用某一个函数时,系统会根据当前的对象类型去寻找和连接其程序的代码。函数的选择基于对象的类型。)

2.为什么要用虚函数表(存函数指针的数组)?

同一个类的多个对象的虚函数表是同一个,所以这样就可以节省空间,一个类自己的虚函数和继承的虚函数还有重写父类的虚函数都会存在自己的虚函数表。同时,虚函数表本质是一个地图导航,可以清楚告诉一个想要操作子类的父类指针到底该使用哪个函数。

3.为什么要把基类的析构函数定义为虚函数?

在用基类操作派生类时,为了防止执行基类的析构函数,不执行派生类的析构函数。因为这样的删除只能够删除基类对象, 而不能删除子类对象, 形成了删除一半形象, 会造成内存泄漏.

4.虚函数可以是内联的吗?

要多态的时候不内联,不多态的时候(也就是非指针、引用,也就是传值)可以内联。


函数调用捆绑

要想深刻理解虚函数机理,首先要了解函数调用捆绑机制。捆绑指的是将标识符(如变量名与函数名)转化为地址。这里我们仅仅关注有关函数调用的捆绑。我们知道每个函数在编译的过程中是存在一个唯一的地址的。如果我们在程序段里面直接调用某个函数,那么编译器或者链接器会直接将函数标识符替换为一个机器地址。这种方式是早捆绑,或者说是静态捆绑。因为捆绑是在程序运行之前完成的。

调用都是直接使用函数名,采用早捆绑的方式。编译器会将每个函数调用替换为一个跳转指令,这个指令告诉CPU跳转到函数的地址来执行。

但是有时候,我们在程序运行前并不知道调用哪个函数,此时必须使用晚捆绑或者动态捆绑。晚绑定的一个例子就是使用函数指针。

使用函数指针来间接调用函数,编译器在编译阶段并不知道函数指针到底指向哪个函数,所以必须使用动态捆绑的方式。

动态绑定看起来更灵活,但是其是有代价的。静态捆绑时,CUP可以直接跳转到函数地址。但是动态捆绑,CPU必须先提取指针的地址,然后再跳转到指向的函数地址。这多了一个步骤!

虚函数表(Vtable)

C++使用了一种称为“虚表”的晚捆绑技术来实现虚函数。虚表是一个函数查询表,以动态捆绑的方式解析函数调用。每个具有一个或者多个虚函数的类都有一张虚表,这个表是在编译阶段建立的静态数组,其中包含了每个虚方法的函数指针,这些指针指向的是该类可见的派生最远的函数实现。其次,编译器会在基类对象都会添加一个隐含指针,这里我们称为*__vptr。这个指针当然能够被派生类所继承,这相当重要。当类的实例被创建时,这个指针指向该类所对应的虚表。这样,当使用某个对象调用虚方法时,通过该指针查找虚表,然后根据实际的对象类型执行正确版本的方法调用。

纯虚函数与抽象基类

有时候,基类的某个虚方法并不需要实现,但是希望派生类能够提供重写的版本。这个时候,你需要定义纯虚函数。纯虚函数在类的定义中显示说明该方法不需要实现,其作用在于指明派生类必须要重写它。纯虚函数的定义很简单:方法声明后紧跟着=0。如果一个类中至少含有一个纯虚函数,那么这个类是抽象基类,因为这个类无法实例化。当继承一个抽象类时,必须重写所有纯虚函数,否则继承出来的类也是一个抽象类。

抽象类至少包含一个纯虚方法,抽象类提供了一种禁止其他代码直接实例化对象的方法,但是重写纯虚方法的派生类可以实例化。

接口类

接口是一个抽象的概念,使用者只关注功能而不要求了解实现。一个接口类可以看成一些纯虚方法的集合,这意味着接口类仅有定义功能,而没有具体的实现。C++ 其实没有单独的接口概念,而在Java和C#等语言中接口是与类相区别的。但是 C++ 仍然可以使用接口类实现类似的效果。有时候,我们也称接口类为纯抽象类,因为这个类中全是虚方法。

可以看到Instrument是一个纯抽象类,其只提供方法的声明,具体却没有实现。但是它的两个派生类分别重写了这些纯虚方法,因此可以实例化。并且两个函数可以接收任意继承了Instrument的类实例对象。进一步说,这两个函数仅关注接收的对象是否提供了Instrument所要求的接口,但是不关注具体是怎么实现的。纯抽象类提供了更高级的抽象!这符合OOP的思想。

虚基类

虚基类主要是用来解决菱形层次结构中的歧义基类问题。菱形层次结构是多重继承中的一个典例。

利用虚基类,可以解决上面多重继承中歧义基类问题,基类仅被继承一次。但是要注意的是此时的虚基类由派生最远的类负责创建(可以看成该类的直接基类),因为PoweredDevice并没有无参构造函数,所以在Copier构造函数初始化列表中必须加上PoweredDevice的有参构造函数调用!

说点题外话,尽管虚基类可以解决多重继承中的菱形层次结构,但是看起来还是很抽象与复杂。实际上,多重继承本来就是一个很有争议的话题,因为使用多重继承会使得继承体系变得复杂,而且产生一系列问题,像Java和C#这类语言,是不允许多重继承的,但是其单独提供了接口,类可以继承多个接口,这也相当于多重继承了。而且好处是接口的继承相当于组合,这也是比较推崇的!

对象切片

前面讲过,实现虚函数及多态性必须要用传地址的方式(引用或者指针)。一般,地址具有相同的长度,这意味着派生类对象的地址与基类对象的地址也是相同,尽管派生类对象所占的内存一般要高过基类对象。所以,传地址的方式不会导致类型信息损失,进而可以实现多态性。

可以看到使用引用或者指针的方式,多态性都能够实现,但是传值的方式就存在问题。当我们将一个派生类对象直接赋值给基类对象时,仅仅基类的部分被复制,派生类的那部分信息将丢失。我们称这种现象为“对象切片”:对象丢失了自己原有的部分信息。使用对象本身并没有问题,但是处理不当,会造成很多问题。

动态转型

前面的例子,我们都是将派生类对象复制给基类对象,不管是通过传地址的方式还是对象切片方式。这些都是向上转型——在类层次中向上移动。我们不禁会想,肯定会存在可以向下移动的向下转型。一般来说,派生类包含基类信息,所以向上转型是容易的。但是,反过来可能会失败!因为无法保证基类对象实际上存储的是派生类对象。

process函数接收一个基类指针,但是在内部使用static_cast向下转型为派生类指针,然后进行后序处理。如果送入process函数的指针实际上就是指向派生类对象,那么上面的代码是没有问题的。但是,如果仅仅传入就是指向基类对象的指针,或者指向其他派生类的指针,那么函数内部的转型将存在问题:由于static_cast在运行时是不检查对象实际类型的,这将导致不可控行为!

为了解决这样的隐患,C++引入了运行时的动态类型转化操作符dynamic_castdynamic_cast在运行时检测底层对象的类型信息。如果类型转换没有意义,那么它将返回一个空指针(对于指针类型)或者抛出一个std::bad_cast异常(对于引用类型)。


本文标题:c++ 虚函数工作原理 - 八卦谈
本文地址:www.ttdhp.com/article/50060.html

天天动画片声明:登载此文出于传递更多信息之目的,并不意味着赞同其观点或证实其描述。
扫码关注我们