4.3 函数的效能(效率)
在接下来的这组测试中,我们分别以非成员友元函数、成员函数、内联成员函数以及虚成员函数的形式,依次计算两个三维向量的叉积。然后,再分别在单继承、多重继承和虚继承下执行虚成员函数的版本。下面是以非成员函数形式实现的叉积代码:

main() 函数的结构如下(此处以非成员函数调用叉积为例):

随着测试程序中函数表示形式的变化,cross_product() 的实际调用方式也会相应改变。表4.1列出了该测试的执行结果。

正如第4.2节所述,非成员函数、静态成员函数和非静态成员函数在内部都会被转换为等价的表示形式。因此,看到这三种形式之间没有性能差异也就不足为奇了。
在未优化的内联版本中,性能提升了约25%,这同样不足为奇。而优化后的内联版本的结果则近乎神奇------这是怎么回事呢?
这种惊人的结果是因为,那些被识别为循环不变的表达式被提升到了循环之外,因此只被计算了一次。这个例子说明,内联展开不仅能节省函数调用的开销,还为程序的进一步优化提供了更多机会。
为了确保虚函数的调用确实通过虚机制进行,我们通过引用而非对象来调用虚函数版本。性能下降的幅度在 4% 到 11% 之间。一部分开销来自 Point3d 构造函数在 1000 万次循环中反复设置内部的 vptr(上文中,计算叉乘时,有一个中间变量pC);另一部分开销则是因为 CC 和 NCC 编译器(至少 NCC 在兼容 cfront 的模式下)都采用了 delta-offset 模型来支持虚函数。
回顾一下,在该模型下,用于将 this 指针调整到正确地址所需的偏移量被存储在虚函数表中。所有形如 ptr->virt_func(); 的调用都会被转换为类似下面的形式:

尽管在大多数调用中,存储的偏移量值为 0(只有在第二个或后续基类、或虚基类的情况下,delta 才非零)。在这种实现下,单继承和多继承的虚函数调用具有相同的开销。而在 thunk 模型下,this 指针的调整开销则只局限于那些真正需要调整的调用中。
在这两种实现下,有一个令人费解的结果:多重继承下的虚函数调用开销反而更高。按照常理,如果一个编译器采用 thunk 模型,那么在调用属于第二个或后续基类的虚函数时,确实会出现这种额外开销;但这两个编译器(CC 和 NCC)实际采用的是 delta-offset 模型,并不应该出现这种现象。因为在 delta-offset 模型下,无论是单继承还是多重继承,this 指针的调整操作都会发生,所以这个开销本身并不能解释为什么多重继承会更慢。
当我运行单继承测试时,同样感到困惑:随着继承层数的增加,虚函数版本的可执行程序运行时间竟然显著增长。一开始我也想不通原因。后来,经过长时间仔细审视代码,我终于恍然大悟,无论单继承层次有多深,main 循环中调用虚函数的代码是完全相同的,对坐标值的操作也完全相同。我之前忽略的一个关键差异在于:cross_product() 函数内部定义了一个局部的 Point3d 类对象 pC。在这个 cross_product() 实现中,默认的 Point3d 构造函数(没有定义析构函数)总共被调用了 1000 万次。随着单继承的每一层加深,构造函数的代码也变得更加复杂(例如需要逐层初始化基类子对象、设置各层的 vptr 等),从而导致可执行程序的运行时间增加。这一发现同样解释了多重继承调用中出现的额外开销。
引入虚函数之后,类的构造函数会被扩充,以便在其中设置虚表指针(vptr)。在 CC 和 NCC 编译器中,即使基类构造函数中不可能有虚函数调用,它们也不会优化掉对 vptr 的设置------因此,每增加一层继承,就会多一次设置 vptr 的操作。此外,在 CC 和 NCC 版本中,构造函数内部还插入了一个用于向后兼容语言 2.0 之前版本的测试:

在引入 new 和 delete 运算符的类实例版本之前,管理类内存分配的唯一方法就是在构造函数内对 this 指针进行赋值(即在构造函数内为类分配内存)。上述条件测试正是为了支持这种旧式的语义。对于 cfront 来说,对"赋值给 this"这一语义的向后兼容承诺一直持续到 4.0 版(出于各种晦涩的原因,这已完全超出本书讨论范围)。讽刺的是,由于 SGI 分发的 NCC 运行在"cfront 兼容模式"下,NCC 也提供了这种向后兼容特性。除向后兼容外,如今已没有任何理由继续在构造函数中包含这个条件测试。现代实现将 operator new 的调用与构造函数的调用分离开来(第 6.2 节),而"赋值给 this"的语义(该语义指在构造函数中为类对象本身分配内存)也不再被语言所支持。
上图代码中,如果this非0,说明是现代版本中,用new或者栈已经分配了空间,直接在已分配的空间执行用户代码即可;如果this为0,说明是在早期版本中,需要构造函数给类对象分配空间,此时就用了new(sizeof(*this))分配一块大小为类对象大小的内存,并将指针返回给this。
在这些实现下,每增加一个基类或单继承的每一层,都会给构造函数额外增加一次对 this 指针的测试(而且这次测试在此场景下完全是多余的)。当构造函数被调用 1000 万次时,这种性能下降就变得可以测量了(这显然反映的是实现上的异常行为,而非对象模型本身的问题)。
无论如何,我想验证构造函数调用的额外开销是否就是性能下降的原因。于是我采用两种替代风格重写了函数,去掉了局部对象:
1.将用于保存结果的对象作为函数的额外参数传入:


2.直接在 this 对象中计算结果:

在这两种改写方式中,其非优化平均执行时间均稳定在 6.90。
有趣的是,语言本身并没有提供一种机制,能够表明某个默认构造函数的调用是不必要的、应该被省略。也就是说,在我们的使用场景中,局部 pC 类对象的声明其实并不需要调用构造函数------但我们却无法在不消除这个局部对象的前提下移除该构造调用。
4.4 指向Member Function的指针(指向成员函数的指针)
在第3章中我们已经看到,取一个非静态数据成员的地址所返回的值,是该成员在类布局中的位置字节偏移量(再加1)。可以将其看作一个不完整的值------它需要与某个类对象的地址绑定之后,才能访问到该成员的实际实例。
而取一个非虚的非静态成员函数的地址时,所返回的值则是该函数代码体在内存中的实际地址。然而,这个值同样是不完整的------它也需要与某个类对象的地址绑定之后,才能实际调用该成员函数。对象的地址将作为 this 指针参数,传递给所有非静态成员函数。
回忆一下,声明指向成员函数的指针的语法如下:

因此,我们可以这样定义并初始化一个指向类成员函数的指针:

也可以这样给它赋值:

调用时需要使用指向成员的指针选择运算符,形式如下:

或者:

编译器在内部会分别将它们转换为类似下面的伪代码(即调用coord,以&origin为参数):

以及:

指向成员函数的声明语法以及指向成员的指针选择运算符,实际上就是为 this 指针充当占位符(这也就是为什么静态成员函数------它们没有 this 指针------的类型是"指向函数的指针",而不是"指向成员函数的指针")。
如果没有虚函数和多重继承(当然也包括虚基类)所带来的复杂性------这些复杂性会影响指向成员的指针的类型和调用方式------那么使用指向成员的指针并不会比使用指向非成员函数的指针更昂贵。在实践中,对于那些不含虚函数、没有虚基类也不涉及多重继承的类,编译器能够提供等效的性能。在下一节中,我们将探讨对虚函数的支持如何使指向成员函数的指针变得更加复杂。
支持"指向Virtual Member Functions"之指针(支持指向虚成员函数的指针)
请看下面这段代码片段:

这里,pmf 是一个指向成员的指针,被初始化为虚函数 Point:😒() 的地址。ptr 则被初始化为指向一个 Point3d 类型的对象。如果我们通过 ptr 直接调用 z():

实际被调用的将是 Point3d:😒()。那么,如果我们通过 pmf 间接调用 z():

是否仍然会调用 Point3d:😒()?换句话说,虚函数机制能否通过指向成员的指针来正常工作?答案当然是肯定的。问题在于:它是如何做到的?
在上一节中,我们看到,取非静态成员函数的地址会得到该函数在内存中的实际地址。然而,对于虚函数来说,这个地址在编译时是无法确定的。能确定的是该函数在虚函数表中的索引。也就是说,取一个虚成员函数的地址,实际上得到的是它在类的虚函数表中的索引。
举例来说,假设我们有一个简化的 Point 类声明:

那么,取析构函数的地址 &Point::~Point 会得到 1(假设虚函数表中第一个槽位是析构函数)。取非虚函数 x() 或 y() 的地址------&Point::x、&Point::y------则会得到它们在内存中的实际地址。而取虚函数 z() 的地址 &Point:😒 会得到 2(即虚表中的索引)。
这样一来,通过指向成员的指针 pmf 调用 z() 时,编译器内部会将其转换为如下通用形式:

指向成员函数的指针的求值之所以复杂,是因为它可能持有两种完全不同的值(实际内存地址或虚表索引),而这两种值所对应的调用策略也截然不同。pmf 的内部表示------例如 float (Point::*pmf)(); ------必须能够同时容纳非虚函数 x() 和虚函数 z() 的地址/索引,因为两者具有相同的函数原型:

那么,编译器必须在内部这样定义 pmf:
1.能够同时存储这两种值;
2.更重要的是,能够区分出存储的到底是地址还是索引。
你能想到编译器是如何做到的吗?
在 2.0 版本之前的 cfront 实现中,上述两种值(实际内存地址和虚表索引)都存放在一个普通的指针里。那么,cfront 是如何区分一个值是真正的内存地址还是虚表索引的呢?它使用了下面这种技巧(如果存的值大于127,就把它当作实际内存地址来用,否则将其当作虚表索引来用):

正如 Stroustrup 在 [LIPP88] 中所写:"当然,这种实现假定一个继承体系中的虚函数数量不超过 128 个。这个限制并不理想,但在实践中被证明是可行的。然而,多重继承的引入需要一种更通用的实现方案,同时也为消除虚函数数量的限制提供了契机。"
多重继承之下,指向Member Functions的指针
为了支持多重继承和虚继承下的指向成员的指针,Stroustrup 设计了如下聚合结构(原始论述参见 [LIPP88]):

这些成员分别代表什么含义?index 和 faddr 成员分别用于存放虚表索引或非虚成员函数的实际地址(按照惯例,如果 index 不用于索引虚表,则将其设为 -1)。在这种模型下,像 ( ptr->*pmf )() 这样的调用会被转换为:

这种方案的一个缺点是:每次调用都需要额外检查该调用是虚函数还是非虚函数。微软通过引入所谓的 vcall thunk 消除了这个检查(也因此去掉了 index 成员)。在这种策略下,faddr 要么被赋值为实际的成员函数地址(如果该函数是非虚的),要么被赋值为 vcall thunk 的地址。这样一来,虚函数和非虚函数的调用在形式上变得透明;vcall thunk 会负责从关联的虚函数表中提取并调用正确的槽位。
这种聚合表示的另一个副作用是:当传递指向成员的指针字面量时,需要生成一个临时对象。例如,考虑以下代码:

表达式 &Point3d::normal 的值类似于 { 0, -1, 10727417 },这要求编译器生成一个临时对象,并用这些显式值进行初始化:

其中,delta 成员表示 this 指针的调整偏移量,而 v_offset 成员则保存了虚基类(或第二个/后续基类)的 vptr 所在位置(如果 vptr 被放在类对象的起始位置,那么这个字段就变得不必要了------但这需要在降低 C 对象兼容性之间做出权衡,参见第 3.4 节)。
这两个成员(delta 和 v_offset)只有在涉及多重继承或虚继承时才需要,因此许多编译器会根据类的特性提供多种内部表示。例如,微软提供了三种变体:
1.单继承版本:仅保存 vcall thunk 或函数地址。
2.多重继承版本:保存 faddr 和 delta 成员。
3.虚继承版本:保存多达四个成员!
"指向Member Functions之指针"的效率
在接下来的这组测试中,我们将通过以下几种方式间接调用叉积函数:指向非成员函数的指针、指向类成员函数的指针、指向虚成员函数的指针,以及多重继承和虚继承体系下对非虚和虚成员函数的一系列调用。
第一个测试中,使用指向非成员叉积函数的指针,在下面的 main() 函数中执行:


而指向类成员函数的声明和调用则如下所示:

在 CC 和 NCC 编译器中,对指向类成员函数的指针的支持采用如下内部形式。调用语句:

会被转换为如下的通用条件测试形式:

回忆一下,指向成员的指针是一个包含三个成员的结构体:index、faddr 和 delta:
1.index 要么存放虚函数表中的索引,要么被设为 -1 以表示该成员函数是非虚的。
2.faddr 存放非虚成员函数的地址。
3.delta 存放可能需要用到的 this 指针调整量。
表 4.2 展示了该测试的执行结果:

4.5 Inline Functions(内联函数)
下面是为我们的 Point 类实现加法运算符的一种可能方式:


从理论上讲,更清晰的实现方式应该利用公有的内联访问函数(getter/setter):

通过将对 _x 数据成员的直接访问限制在这两个get/set函数之内,我们可以最大程度地减轻因数据成员表示方式后续发生改变(例如在继承层次中将其上移或下移)所造成的影响。通过将这些访问函数声明为内联,我们既能保持直接成员访问的性能效率,又能获得函数调用所带来的封装性。而且,加法运算符也不再需要被声明为 Point 的友元。
然而在实际中,我们无法强制任何特定函数被内联------尽管曾有一位 cfront 客户提交了一个高优先级的修改请求,要求增加一个 must_inline 关键字。inline 关键字(或者在类声明内部定义的成员函数、友元函数,这种方式会隐式地向编译器发出内联请求)仅仅是一个请求。要让这一请求得到满足,编译器必须相信它能够在一个任意的表达式中"合理地"展开该函数。
当我说编译器相信它能够"合理地"展开一个内联函数时,我的意思是:在某种程度上,该函数执行的代价小于函数调用和返回机制本身的开销。cfront 通过一种复杂度度量启发式规则来判断,通常它会计算赋值操作的次数、函数调用的次数、虚函数调用的次数等。每一类表达式都被赋予一个权重,内联函数的复杂度就是其各项操作的加权和。显然,这种复杂度度量中给各项赋予的具体数值是存在争议的。
通常,内联函数的处理分为两个阶段:
1.分析函数定义,判断该函数是否具有"固有的可内联性"(这里的"固有"意指特定于某个编译实现)。
如果编译器因函数的复杂性或其构造方式而判定该函数无法被内联,就会将其转换为一个static函数,并在当前编译的模块中生成其定义。在支持分离编译模块的环境中,编译器对此几乎没有其他选择。理想情况下,链接器会清理掉由此产生的多个重复实例。但总的来说,目前的链接器在执行这种清理时,并不会一并清除随调用生成的调试信息------这可以通过 UNIX 的 strip 命令(该命令会从可执行文件、目标文件、共享库中移除符号表、调试信息和重定位信息,从而显著减小文件的体积,好处在于可使得逆向分析更加困难,以及提升发布版本的加载速度并降低存储成本)来完成。
2.在实际调用点对函数进行内联展开。这涉及到对实参的求值以及临时变量的管理。
正是在展开阶段,实现会判断某个调用点是否"不可内联"。例如,在 cfront 中,同一个表达式内对同一个内联函数的第二次或后续调用是不会被展开的------就像我们理论上对 Point 类访问函数的改进用法那样:

在 cfront 下,这个表达式会被展开成类似下面这种伪代码:

这完全算不上什么改进!此时我们唯一能做的,就是重写表达式来绕过这种保守的内联实现:

当然,这个注释是必要的------它能让将来阅读我们代码的人明白,我们原本考虑过使用公有的内联接口,但最终不得不退而求其次!
其他编译器在内联展开上是否像 cfront 那样受限?并非如此。然而,不幸的是,编译器厂商们(无论是 UNIX 平台还是 PC 平台)似乎普遍认为没有必要详细讨论其内联支持的范围或限制。通常情况下,你只能通过查看汇编代码来了解哪些函数被内联了、哪些没有。
形式参数(Formal Arguments)
内联展开期间实际发生了什么?每个形式参数都会被替换为对应的实际参数。如果实际参数带有副作用,就不能简单地将其直接替换到每个形式参数出现的位置------这会导致实际参数被多次求值。一般来说,处理带有副作用的实际参数需要引入一个临时对象。另一方面,如果实际参数是一个常量表达式,我们则希望先对它进行求值,然后再进行替换;之后还可能执行常量折叠(即直接把常量表达式的结果计算出来)。如果实际参数既不是常量表达式,也不是带有副作用的表达式,那么就直接将每个实际参数替换为对应的形式参数。
例如,假设我们有下面这个简单的内联函数:

以及对该内联函数的如下三次调用:


标记为 (1) 的那行内联展开是简单的参数替换:

标记为 (2) 的那行内联展开则涉及到常量折叠:

最后,标记为 (3) 的那行内联展开涉及到参数的副作用以及为了避免多次求值而引入的临时对象:

局部变量(Local Variables)
如果我们稍微修改一下 min 的定义,在函数内部添加一个局部变量:

那么这个局部变量需要哪些支持或特殊处理呢?例如,假设我们有以下对 min() 的调用:


一种会保留局部变量的展开方式,大致如下所示(理论上,这个例子里的内联局部变量其实可以优化掉,直接在 minval 上算出结果):

总体来看,内联函数里的每个局部变量,都必须当作一个名称唯一的变量,引入到调用点的外层块中。如果同一个内联函数在一条表达式里被展开多次,每次展开很可能需要自己独立的一套局部变量。不过,如果内联函数是在多个不连续的语句里分别展开,那么一套局部变量也许可以重复用在多次展开中。
内联函数里的局部变量,再加上带有副作用的调用实参,两者合在一起,往往会在展开处产生数量惊人的内部临时变量------尤其是在同一个表达式里多次展开内联函数时。举个例子:

上面的调用可能会被展开成:


内联函数提供了一种必要的信息隐藏支持------它能够高效地访问类内部封装的非公开数据。同时,它也安全地替代了 C 语言中重度依赖的 #define 预处理宏(尤其是当宏的实参带有副作用时,这种替代优势更加明显)。不过请注意:如果一个内联函数在程序中被调用太多次,那么它的展开代码总量,可能会远远超出你从它的定义表面所看到的规模,从而引发意想不到的代码膨胀。
正如我们刚才看到的,带有副作用的实参、同一个表达式里的多次调用、以及内联函数自身的多个局部变量------这些因素都会产生临时变量,而编译器未必能全部消除。此外,内联函数内部再展开内联(即嵌套内联),会因为"级联复杂度"导致一个看起来很简单的小型内联函数最终无法展开。这种情况可能发生在复杂类层次的构造函数中,也可能出现在对象继承链上一连串看似无害的内联调用里------每个调用只执行少量操作,然后就把请求转发给另一个对象。
内联函数确实是一把利器,能帮我们写出既安全又高效的程序。不过相比于等价的非内联版本,使用内联函数时需要我们多加留心。