目录
本文主要介绍C++对象中成员函数有关内容,详细示例请参考C++对象成员函数存储示例。
一、成员函数各种调用方式
考虑如下所示的代码段:
C++支持三种类型的成员函数:static、nonstatic、virtual,每一种类型被调用的方式都不相同。
1.1、非静态成员函数
C++的设计准则之一就是:非静态成员函数至少必须和一般的非成员函数有相同的效率。例如,对于如下所示的两个函数:
选择成员函数不应该带来什么额外负担,这是因为编译器内部已将"成员函数实例"转换为对等的"非成员函数实例"
1.1.1、转换函数
转换步骤分为三步:
- 1、改写函数的signature以安插一个额外的参数到成员函数中,用以提供一个存取管道,使类对象可以调用此函数。这个额外的参数被称为this指针:
如果成员函数是const的,则变成:
- 2、将每一个"对非静态数据成员的存取操作"改为经由this指针来存取:
- 3、将成员函数重新写成一个外部函数。将函数名称经过"mangling"处理,使它在程序中成为独一无二的语汇:
1.1.2、转换调用
现在这个函数已经被转换好了,而其每一个调用操作也都必须转换。如下所示:
对于一开始所示的代码段,normalize()函数将会被转换为下面的形式。其中假设已经声明有一个Point3d copy constructor,而named returned value(NRV)的优化也已实行:
一个比较有效率的做法是直接构建"normal"值,像这样:
1.1.3、名称的特殊处理
1.1.3.1、数据成员
一般而言,成员的名称前面会加上类名称,形成独一无二的命名。例如下面的声明:
其中的ival有可能变成这样:
为什么编译器要这么做?请考虑这样的派生操作:
有可能被转换为:
现在,无论要处理哪一个ival,通过"name mangling",都可以绝对清楚地指出来。
1.1.3.2、成员函数
由于成员函数可以被重载,所以需要更广泛的mangling操作,以提供绝对独一无二的名称。如果把:
转换为:
会导致两个被重载的函数实例拥有相同的名称。为了让它们独一无二,唯有再加上它们的参数列表。如果把参数类型也编码进去,就一定可以制造出独一无二的结果:
1.2、虚成员函数
1.2、虚成员函数
本节只介绍简单的虚成员函数情况,有关继承机制的内容请参看二、虚成员函数。
如果normalize()是一个虚成员函数,那么下面的调用:
将会被内部转换为:
其中:
- vptr表示由编译器产生的指针,指向virtual table。它被安插在每一个声明有(或继承自)一个或多个虚函数的类对象中。事实上其名称也会被"mangled",因为在一个复杂的类派生体系中,可能存在多个vptr。
- 1是virtual table slot的索引值,关联到normalize()函数
- 第二个ptr表示this指针
类似的道理,如果magnitude()也是一个虚函数,它在normalize()之中的调用操作将被转换为:
此时,由于Point3d::magnitude()是在Point3d::normalize()中被调用的,而后者已经由虚拟机制而决议妥当,所以显式地调用"Point3d实例"会比较有效率,并因此压制由于虚拟机制而产生的不必要的重复调用操作:
如果magnitude()声明为inline函数,会更有效率。使用类范围操作符(::)显式调用一个虚函数,其决议方式会和非静态成员函数一样:
1.3、静态成员函数
如果Point3d::normalize()是一个静态成员函数,以下两个调用操作:
将会被转换为一般的非成员函数调用,像这样:
1.3.1、特性
静态成员函数的主要特性就是它没有this指针。以下的次要特性统统根源于其主要特性:
- 它不能够直接存取其类中的非静态成员
- 它不能够被声明为const、volatile或virtual
- 它不需要经由类对象即可被调用——虽然大部分时候它是这样被调用的!
二、虚成员函数
二、虚成员函数
虚函数的一般实现模型为:每一个类有一个virtual table,内含该类中有作用的虚函数的地址,每个对象有一个vptr,指向virtual table,如1.2、虚成员函数节所示,本节主要介绍有关继承的各种情况。
2.1、单一继承下的虚函数
2.1.1、必要的信息及其存放位置
为了支持虚函数机制,必须首先能够对于多态对象有某种形式的"执行期类型判断法(runtime type resolution)"。也就是说对于调用操作:ptr->z()
,将需要ptr在执行期的某些相关信息,如此一来才能够找到并调用z()的适当实例。
或许最直接了当但是成本最高的解决办法就是把必要的信息加在ptr身上。在这样的策略之下,一个指针(或一个引用)持有两项信息:
- 1、它所参考到的对象的地址(也就是目前它所持有的东西)
- 2、对象类型的某种编码,或是某个结构(内含某些信息,用以正确决议出z()函数实例)的地址
这个方法带来两个问题:第一,它明显增加了空间负担,即使程序并不使用多态;第二,它打断了与C程序间的链接兼容性。
如果这份额外信息不能够和指针放在一起,下一个可以考虑的地方就是把它放在对象本身。接下来考虑两个问题:1、什么时候需要这些信息呢?2、哪些对象真正需要这些信息呢?
-
对于第一个问题,很明显是在必须支持某种形式的"执行期多态(runtime polymorphism)"的时候。
-
对于第二个问题,由于没有导入像是polymorphic之类的新关键词,因此识别一个类是否支持多态,唯一适当的方法就是看看它是否有任何虚函数。只要类拥有一个虚函数,它就需要这份额外的执行期信息。
2.1.2、构建virtual table
下一个明显的问题是,我们需要存储什么样的额外信息?也就是说,对于调用操作:ptr->z()
,其中z()是一个虚函数,那么需要什么样的信息才能让我们在执行期调用正确的z()实例?我们需要知道:
- ptr所指对象的真实类型,这可使我们选择正确的z()实例
- z() 实例的位置,以便我们能够调用它
在实现上,首先我们可以在每一个多态的类对象身上增加两个成员:
- 1、一个字符串或数字,表示类的类型
- 2、一个指针,指向某表格,表格中持有程序的虚函数的执行期地址
表格中的虚函数地址如何被构建起来?在C++中,虚函数(可经由其对象被调用)可以在编译时期获知。此外,这一组地址是固定不变的,执行期不可能新增或替换之。由于程序执行时,表格的大小和内容都不会改变,所以其构建和存取皆可以由编译器完全掌控,不需要执行期的任何介入。
然而,执行期备妥那些函数地址,只解决了问题的一半。另一半是找到那些地址。两个步骤可以完成这项任务:
- 1、为了找到表格,每一个类对象被安插了一个由编译器内部产生的指针,指向该表格
- 2、为了找到函数地址,每一个虚函数被指派一个表格索引值
这些工作都由编译器完成。执行期要做的,只是在特定的virtual table slot中激活虚函数。
一个类只会有一个virtual table。每一个virtual table内含其对应类对象中所有虚函数实例的地址。这些虚函数包括:
- 这一类所定义的函数实例。它会覆盖一个可能存在的基类虚函数实例
- 继承自基类的函数实例。这是在派生类决定不改写虚函数时才会出现的情况
- 一个pure_virtual_called()函数实例。它既可以扮演纯虚函数的空间保卫者角色,也可以当做是执行期异常处理函数(有时候会用到)
每一个虚函数都被指派一个固定的索引值,这个索引在整个继承体系中保持与特定的虚函数的关系。
2.1.3、示例
对于下面的Point类:
virtual destructor被指派slot1,而mult()被指派slot2,。此例并没有mult()的函数定义,所以pure_virtual_called()的函数地址会被放在slot2中。如果该函数意外地被调用,通常的操作是结束掉这个程序。y()被指派slot3,而z()被指派slot4。x()的slot是多少?答案是没有,因为x()并非虚函数。Point的内存布局和其virtual table如下所示:
当一个类派生自Point时,会发生什么?例如class Point2d:
一共有三种可能性:
- 1、它可以继承基类所声明的虚函数的函数实例。正确地说是,该函数实例的地址会被拷贝到派生类的virtual table的相对应slot中
- 2、它可以使用自己的函数实例。这表示它自己的函数实例地址必须放在对象的slot中
- 3、它可以加入一个新的虚函数。这时候virtual table的尺寸会增大一个slot,而新的函数实例地址会被放进该slot中
Point2d的virtual table在slot1中指出destructor,而在slot2中指出mult()(取代纯虚函数)。它自己的y()函数实例地址放在slot3中,继承自Point的z()函数实例地址则放在slot4中。Point2d的内存布局和其virtual table如下所示:
类似的情况,Point3d派生自Point2d,如下:
其virtual table中的slot1放置Point3d的destructor,slot2放置mult()函数地址,slot3放置继承自Point2d的y()函数地址,slot4放置自己的z()函数地址。Point3d的内存布局和其virtual table如下所示:
对于ptr->z()
,我们如何有足够的知识在编译时期设定虚函数的调用呢?
- 一般而言,在每次调用z()时,我们并不知道prt所指对象的真正类型。然而,经由ptr可以存取到该对象的virtual table
- 虽然我们不知道哪一个z()函数实例会被调用,但我们知道每一个z()函数地址都被放在slot4中
这些信息使得编译器可以将调用ptr->z()
转换为:
在这一转换中,vptr表示编译器所安插的指针,指向virtual table;4表示z()被指派的slot编号。唯一一个在执行期才能知道的东西是:slot4所指的到底是哪一个z()函数实例。
在一个单一继承体系中,虚函数机制的行为十分良好,不但有效率而且很容易塑造出模型来。
2.2、多重继承下的虚函数
在多重继承中支持虚函数,其复杂度围绕在第二个及后继的基类身上,以及"必须在执行期调整this指针"这一点。以下面的类体系为例:
2.2.1、三个问题
派生类支持虚函数的困难度,统统落在Base2 subobject身上。有三个问题需要解决,以此例而言分别是(1)virtual destructor,(2)被继承下来的Base2::mumble(),(3)一组clone()函数实例。
2.2.1.1、问题一
首先,我们把一个从heap中配置而得的派生类对象的地址,指定给一个Base2指针:
新的派生类对象的地址必须调整以指向其Base2 subobject。编译时期会产生以下的代码:
如果没有这样的调整,指针的任何"非多态运用"(像下面那样)都将失败:
当要删除pbase2所指的对象时:
指针必须被再一次调整,以求再一次指向派生类对象的起始处。然而上述的offset加法不能够在编译时期直接设定,因为pbase2所指的真正对象只有在执行期才能确定。
一般规则是,经由指向"第二或后继基类"的指针(或引用)来调用派生类的虚函数。其所连带的必要的"this指针调整"操作,必须在执行期完成。也就是说,offset的大小,以及把offset加到this指针上的那一小段程序代码,必须由编译器在某个地方插入。问题是,在哪个地方?
Bjarne原先实施于cfront编译器中的方法是将virtual table加大,使它容纳此处所需的this指针,调整相关事物。每一个virtual table slot,不再是一个指针,而是一个集合体,内含可能的offset以及地址。于是虚函数的调用操作由:
改变为:
这个做法的缺点是,它相当与连坐处罚了所有的虚函数调用操作,不管它们是否需要offset的调整。
比较有效率的解决方法是利用所谓的thunk。所谓thunk是一小段assemble代码,用来(1)以适当的offset值调整this指针,(2)跳到虚函数去。例如,经由一个Base2指针调用派生类析构函数,其相关的thunk可能看起来是这个样子:
Bjarne并不是不知道thunk技术,问题是thunk只有以assembly代码完成才有效率可言。由于cfront使用C作为其程序代码产生语言,所以无法提供一个有效率的thunk编译器。
thunk技术允许virtual table slot继续内含一个简单的指针,因此多重继承不需要任何空间上的额外负担。slots中的地址可以直接指向虚函数,也可以指向一个相关的thunk(如果需要调整this指针的话)。
调整this指针的第二个额外负担就是,由于两种不同的可能:(1)经由派生类(或第一个基类)调用,(2)经由第二个(或其后继)基类调用,同一函数在virtual table中可能需要多笔对应的slots。例如:
虽然两个delete操作导致相同的派生类destructor,但它们需要两个不同的virtual table slots:
- 1、pbase1不需要调整this指针(因为Base1是最左端的基类,所以它已经指向派生类对象的起始处)。其virtual table slot需放置真正的destructor地址
- 2、pbase2需要调整this指针。其virtual table slot需要相关的thunk地址。
在多重继承之下,一个派生类内含n-1个额外的virtual table,n表示其上一层基类的个数(因此,单一继承将不会有额外的virtual table)。对于本例的派生类而言,会有两个virtual table被编译器产生出来:
- 1、一个主要实例,与Base1(最左端基类)共享
- 2、一个次要实例,与Base2(第二个基类)有关
针对每一个virtual table,派生类对象中有对应的vptr。如下所示:
有三种情况,第二或后继的基类会影响对虚函数的支持。第一种情况是,通过一个指向第二个基类的指针,调用派生类虚函数。即上述讨论的virtual destructor情况,例如:
从上图可以看到这个调用操作的重点:ptr指向派生类对象中的Base2 subobject;为了能够正确执行,ptr必须调整指向派生类对象的起始处。
2.2.1.2、问题二
第二种情况是第一种情况的变体,通过一个指向派生类的指针,调用第二个基类中一个继承而来的虚函数。在此情况下,派生类指针必须调整,以指向第二个base subobject。例如:
2.2.1.3、问题三
第三种情况发生于一个语言扩充性质之下:允许一个虚函数的返回值类型有所变化,可能是基类类型,也可能是派生类类型。以Derived::clone()函数为例。clone函数的派生类版本传回一个派生类指针,默默地改写了它的两个基类函数实例。当我们通过指向第二个基类的指针来调用clone()时,this指针的offset问题于是诞生了:
当执行pb1->clone()时,pb1会被调整指向派生类对象的起始地址,于是clone()的派生类版本会被调用;它会传回一个指针,指向一个新的派生类对象;该对象的地址在被指定给pb2之前,必须先经过调整,以指向Base2 subobject。
2.3、虚继承下的虚函数
考虑下面的虚基类派生体系,从Point2d派生出Point3d:
虽然Point3d有唯一一个(同时也是最左边的)基类,也就是Point2d,但Point3d和Point2d的起始部分并不像非虚拟的单一继承情况那样。这种情况如下所示:
当一个虚基类从另一个虚基类派生而来,而且两者都支持虚函数和非静态数据成员时,编译器对于虚基类的支持简直就像进了迷宫一样。因此作者建议我们:不要在一个虚基类中声明非静态数据成员。
三、指向成员函数的指针
取一个非静态成员函数的地址,如果该函数是nonvirtual,得到的结果是它在内存中真正的地址。然而这个地址是不完全的。它需要被绑定于某个类对象的地址上,才能够通过它调用该函数。所有的非静态成员函数都需要对象的地址(以参数this指出)。
使用一个成员函数指针,如果并不用于虚函数、多重继承、虚基类等情况的话,并不会比使用一个非成员函数指针的成本更高。
3.1、指向虚成员函数的指针
考虑下面的代码:
pmf是一个指向成员函数的指针,被初始化为Point:: z()(一个虚函数)的地址。prt则被指定一个Point3d对象。如果我们直接经由ptr调用z():
被调用的是Point3d:: z()。但是如果我们从pmf间接调用z()呢?
虚拟机制仍然能够在使用指向成员函数的指针情况下运行。问题是如何实现的呢?
对一个非静态成员函数取其地址,将获得该函数在内存中的地址。然而面对一个虚函数,其地址在编译时期是未知的,所能知道的仅是虚函数在其相关的virtual table中的索引值。也就是说,对一个虚成员函数取其地址,所能获得的只是一个索引值。
以下面的Point声明为例:
然后取destructor的地址:
得到的结果是1。取x()或y()的地址:
得到的则是函数在内存中的地址,因为它们不是虚函数。取z()的地址:
得到的结果是2。通过pmf来调用z(),会被内部转换为一个编译时期的式子,一般形式如下:
对一个指向成员函数的指针评估求值(evaluated),会因为该值有两种意义而复杂化:既可以指向虚函数,也可以指向非虚函数;其调用操作也将有别于常规调用操作。pmf的内部定义是:
必须允许此函数能够寻址出非虚函数x()和虚函数z()两个成员函数,而那两个函数有着相同的原型:
只不过其中一个代表内存地址,另一个代表virtual table中的索引值。因此,编译器必须定义pmf,使它能够(1)持有两种数值,(2)更重要的是其数值可以被区别代表内存地址还是virtual table中的索引值。
在cfront2.0非正式版中,这两个值被内含在一个普通的指针内。cfront使用了以下技巧:
这种实现技巧必须假设继承体系中最多只有128个虚函数。这并不是我们所希望的,但却证明是可行的。然而,多重继承的引入,导致需要更一般化的实现模式,并趁机去除对虚函数的个数限制。
3.2、多重继承下,指向成员函数的指针
为了让指向成员函数的指针能够支持多重继承和虚继承,Bjarne设计了下面一个结构体:
index和faddr分别(不同时)持有virtual table索引和非虚成员函数地址(为了方便,当index不指向virtual table时,会被设为-1)。在此模型之下,像这样的调用操作:
会变成: