第7章 优化热点语句 摘录

语句级别的性能优化的问题在于,除了函数调用外,没有哪条C++语句会消耗许多条机器指令。通常,集中精力在这些微小的性能点上是无法获得与开发人员人员所付出的努力相应的性能回报的,除非开发人员找到了放大这些语句的开销,使得它们成为值得优化的热点代码的因素。这些因素包括包括:

循环:

        循环中语句开销是语句各自的开销乘以它们被重复执行的次数。热点代码必须由开发人员自己找出来。分析器可以指出包含热点循环的函数,但它不会指出函数中那些循环是热点循环;它还可能会因为某个函数被一个或多个循环调用而指出该函数,但它不会指出具体哪个循环是热点循环。既然分析器无法直接指出热点循环,开发人员就必须以分析器的输出结果作为线索,检查代码并找出热点循环。

频繁被调用的函数:

        函数的开销是函数自身的开销乘以它被执行的次数。分析器可以直接指出热点函数。

贯穿整个程序的惯用法:

        如果在程序中广泛地使用了这些惯用法,那么将它替换为性能开销更小的惯用法可以提升程序的整体性能。

在语句级别优化代码能够显著地改善嵌入在各种工具、转置、外置和玩具中简单的小型处理器的性能,因为在这类处理器上,指令是直接从内存中被获取,然后一条一条地执行。不过,由于桌面级和手持设备的处理器提供了指令级的并发和缓存,因此语句级别的优化带来的回报比优化内存分配和复制要小。

在桌面级计算机设计的程序中,应当只对那些会被频繁调用的库函数或是程序中最底层的循环,如占用最多运行时间的图形引擎或编程语言解释器,进行语句级别优化。

语句性能优化还有一个问题:优化效果取决于编译器。适用于某个编译器的编程惯用法可能在另外一个编译器上毫无效果,甚至反而降低性能。更关键的是,意味着当团队升级了编译器版本后,性的编译器可能会降低他们精心优化后的代码的速度。这是语句级别的优化可能比其他性能优化手段效果更差的一个原因。

7.1 从循环中移除代码

一个循环是由两部分组成的:一段被重复执行的控制语句和一个确定需要进行多少次循环的控制分支。通常情况下,移除C++语句中的计算指的是移除循环汇总的控制语句的计算。不过在循环中,控制分支也有额外的优化机会,因为从某种意义来说,它产生了额外的开销。

char s[] = "Thsi string has many space";

for (size_t i = 0; i < strlen(s); ++i)
    if (s[i] == ' ')
        s[i] = '*';

调用strlen()的开销是昂贵的,使得这个算法的开销从O(n)变化为O(n^2)这是一个库函数中隐藏了循环的典型例子。

7.1.1 缓存循环结束条件值

可以通过在进入循环时计算并缓存循环结束的条件值,即调用开销昂贵的strlen()的返回值,来提高程序性能。

char s[] = "Thsi string has many space";

for (size_t i = 0, len = strlen(s); i < len; ++i)
    if (s[i] == ' ')
        s[i] = '*';

7.1.2 使用更高效的循环语法

C++中for循环语句的声明语法:

for(初始化表达式;循环条件;继续表达式)语句会被编译为如下代码:

        初始化表达式;
L1:if(!循环表达式)goto L2;
        语句;
        继续表达式;
        goto L1;

L2:

for循环必须执行两次jump指令;一次当循环条件为false时;另一次则是在计算继续表达式之后。这些jump指令可能会降低指定速度。C++还有一种使用不那么广泛的,称为do的更简单的循环形式,它的声明语法为:

do 语句 while(循环条件);会被编译为:

L1:控制语句
        if(循环条件) goto L1;

将一个for循环简化为do循环通常可用提高循环处理的速度。

char s[] = "Thsi string has many space";
size_t i = 0, len = strlen(s);
do
{
    if (s[i] == ' ')
        s[i] = '*';
    i++;
}  
while(i < len);

7.1.3 用递减代替递增

缓存循环结束的另一种方法是用递减代替递增,将循环结束条件缓存在循环索引变量中。

char s[] = "Thsi string has many space";

for (int len = (int)strlen(s) - 1; i >= 0; --i)
    if (s[i] == ' ')
        s[i] = '*';

我将循环变量i的类型从无符号的size_t变为了有符号的int。for循环的结束条件是i>=0。如果i是无符号的,那么它总是大于或等于0,则循环永远无法结束。在采用递减方式时,这是一个非常典型的错误。

7.1.4 从循环中移除不变性代码

将具有不变形的代码移动至循环外部。当代码不依赖于循环的循环变量时,它就具有循环不变性。

int i, j, x, a[10];
...
for(i = 0; i < 10; ++i)
{
    j = 100;
    a[i] = i + j * x * x;
}

int i, j, x, a[10];
...
j = 100;

for(i = 0; i < 10; ++i)
{
    a[i] = i + j * x * x;
}

现代编译器非常善于找出在循环中被重复计算的具有循环不变性的代码,然后将计算移动至循环外部来改善程序性能。开发人员通常没有必要重写这段代码,因为编译器已经替我们找出了具有循环不变性的代码并重写代码。

当在循环中有语句调用了函数时,编译器可能无法确定函数的返回值是否依赖于循环中的某些变量,被调用的函数可能很复杂,或是函数体包含在另外一个编译器看不到的编译单元中。这时,开发人员必须自己找出具有循环不变性的函数调用并将它们从循环中移除。

7.1.5 从循环中移除无谓的函数调用

一次函数调用可能会执行大量的指令。如果函数具有循环不变性(loop-invariant),那么将它移除到循环外有助于改善性能。

char s[] = "Thsi string has many space";

for (size_t i = 0, len = strlen(s); i < len; ++i)
    if (s[i] == ' ')
        s[i] = '*';

相比于编译器彻底但有限的分析能力,开发人员的判断更加有效。有一种函数永远可以被移动到循环外部,那就是返回值只依赖于函数参数而且没有副作用的纯函数pure function。

对于是纯函数的数学函数而言,将数学函数移动到循环之外,这里的性能提升并不明显,因为数学函数通常只会对保存在寄存器中的一两个数值进行运算,而不会像strlen一样访问内存。

有时候,在循环中调用的函数根本就不会工作或者只是进行一些无谓的工作。

7.1.6 从循环中移除隐含的函数调用

C++代码可能还会隐式地调用函数:

        声明一个类实例(构造函数);
        初始化一个类实例(构造函数);
        赋值给一个类实例(赋值运算符);
        涉及类实例的计算表达式(运算符成员函数);
        退出作用域(析构函数);
        函数参数(每个参数表达式都会被复制构造到它的形参中);
        函数返回一个类的实例(调用复制构造函数,可能是两次);
        向标准库容器中插入元素(元素会被移动构造或复制构造);
        向矢量中插入元素(如果矢量重新分配了内存,那么所有的元素都需要被移动构造或是复制构造);

这些函数调用被隐藏起来了。从表面上看不出带有名字和参数列表的函数调用。它们看起来更像赋值和声明。很容易误以为此处没有发生函数调用。

如果将函数签名从通过值传递实参修改为传递指向类的引用或指针,有时候可以在进行隐式函数调用时移除形参构造。

如果将函数签名修改为通过输出参数返回指向类实例的引用或指针时,可以在进行隐式函数调用时移除函数返回值的复制。

如果赋值语句和初始化声明具有循环不变性,那么可以将它们移动至循环外部。有时,即使需要每次都将变量传递到循环中,也可以将声明移动到循环外部,并在每次循环中都执行一次开销较小的函数调用。

for(...)
{
    std::string s("<p>");
    ...
    s += "</p>";
}

在for循环中声明字符串s的开销是昂贵的。在循环语句块的大括号的位置将会调用s的析构函数,而析构函数会释放为s动态分配的内存。

std::string s("<p>");
for(...)
{
    s.clear();
    s += "<p>";
    ...
    s += "</p>";
}

现在,不会再每次循环中都调用s的析构函数了。这不仅在每次循环中都节省了一次函数调用,同时还会移除一次对内存管理器的调用。

该行为不仅仅适用于字符串或是那些含有动态内存的类。类实例中还可能会含有取自操作系统的资源,如一个窗口或是文件句柄,抑或可能会在它自身的构造函数和析构函数中进行一些开销昂贵的处理。

7.1.7 从循环中移除昂贵的、缓慢改变的调用

日志记录必须尽可能地高效,否则会降低程序的性能。连续调用timetoa()两次获取到当前时间可能是相同的。可以使用相同的时间以10行日志为一组输出日志。

7.1.8 将循环放入函数以较少调用开销

如果程序需要遍历字符串、数组或是其他数据结构,并会在每次迭代中都调用一个函数,那么可以通过一种称为循环倒置loop inversion的技巧来提高程序性能。循环倒置是指将在循环中调用函数变为在函数中调用循环。这需要改变函数的接口,不再接收一条元素作为参数,而是接收整个数据结构作为参数。按照这种方式修改后,如果数据结构中包含n条元素,那么可节省n-1次函数调用。

#include <ctype>

void replace_nonprinting(char& c)
{
    if (!isprint(c))
        c = '.';
}

for (unsigend int i = 0, e = str.size(); i < e; ++i)
    replace_nonprinting(str[i]);


void replace_nonprinting(std::string& str)
{
    for (unsigend int i = 0, e = str.size(); i < e; ++i)
        if (!isprint(c))
            c = '.';
}

7.1.9 不要频繁地进行操作

在一个程序的主循环中每秒处理1000个事务,那么它应当每隔多长时间检测一次是否有终止命令呢?答案是“视情况而定”,这取决于两件事情:程序需要以多快的速度响应终止请求,以及程序检查终止命令的开销。

如果程序的响应目标是需要在疫苗内停止程序,而且在检测到停止命令后需要平均500+-100毫秒来停止程序,那么它需要每400毫秒检测一次,更频繁的检测只会是浪费时间。

另一个因素是检测终止命令的开销。如果主循环是Windows消息循环,那么终止命令就是Windows的WM_CLOSE消息。

7.1.10 其他优化技巧

目前已经有许多tips被编译器实现。事实上,编译器比绝大多数程序员的编程能力更加优秀,这也是为什么使用类似的性能优化的结果总是让人沮丧,已经为什么本节中的内容并不会太多。

7.2 从函数中移除代码

函数包含两部分:一部分是由一段代码组成的函数体,另一部分是由参数列表和返回值类型组成的函数头。

尽管执行函数体的开销可能会非常大,但是调用函数的开销和大多数C++语法的开销一样,是非常小的。不过,当函数被多次调用时,累积的开销可能会变得巨大,因此减少这种开销非常重要。

7.2.1 函数调用的开销

函数是最古来和最重要的抽象概念。程序员先定义一个函数,接着就可以在代码中的其他地方调用这个函数。每次调用时,计算机都会在执行代码中保存它的位置,将控制权交给函数体,接着会返回到函数调用后的下一条语句,高效地将函数体插入到指令执行流中。

这种便利性可不是免费的。每次程序调用一个函数时,都会发生类似下面的处理(依赖于处理器体系结构和优化器设置)。

(1)执行代码将一个栈帧推入到调用栈中来保存函数的参数和局部变量。

(2)计算每个参数表达式并复制到栈帧中;

(3)执行地址被复制到栈帧中并生成返回地址;

(4)执行代码将执行地址更新为函数体第一条语句

(5)执行执行函数体中的指令;

(6)返回地址被从栈帧复制到指令地址中,将控制权交给函数调用后的语句;

(7)栈帧被从栈中弹出;

不过,关于函数开销也有一些利好消息。带有函数的程序通常都会比带有被内联展开的大型函数的程序更加紧凑。这有利于提高缓存和虚拟内存的性能。而且,函数调用与非函数调用的其他开销都相同,这使得提高会被频繁地调用函数的性能成为一种有效的优化手段。

1. 函数调用的基本开销

函数参数:

        除了计算参数表达式的开销外,复制每个参数的值都栈中也会发生开销。如果只有几个小型的参数,那么可能跨越很高效地将它们传递到寄存器中,但是如果有很多参数,那么至少其中一部分需要通过栈传递。

成员函数调用(与函数调用)

        每个成员函数都有一个额外的隐藏参数:一个指向this指针类实例的指针,而成员函数正式通过它被调用的。这个指针必须被写入到调用栈上的内存中或是保存在寄存器中。

调用和返回

        调用和返回对程序的功能没有任何影响。我们可以通过用函数体替代函数调用来移除这些开些。的确,当函数很小且在函数被调用之前已经定义了函数时,许多编译器都会尝试内联函数体。如果不能内联函数,调用和返回就会产生开销。

        调用函数要求执行地址写入到栈帧中来生成返回地址。在调用和返回时,执行连续地工作于非连续的内存地址上。不过,当程序需要跨越非连续地址时,可能会发生流水线停顿和高速缓存未命中。

2. 虚函数的开销

在C++中可用将任何成员函数定义为虚函数。继承类能够通过定义一个具有相同函数签名的成员函数来重写基类的虚成员函数。不论在继承类实例上调用虚函数还是在一个指向基类类型的指针或是引用上调用虚函数,都可以使用新的函数体。程序在解引用实例时会选择调用哪个函数。因此,程序是在运行时通过类实例的实际类型来确定调用哪个重写函数的。

每个带有虚成员函数的实例都有一个无名指针指向一个称为虚函数表的表,这张表指向类中可见的每个虚函数签名所关联的函数体。虚函数表指针通常都是类实例的第一个字段,这样解引用的开销更小。

调用虚函数的代码会解引用指向类实例的指针,来获得指向虚函数表的指针。这段代码会为虚函数加上索引来得到函数的执行地址。实际这里会为所有的虚函数调用额外地加载两次非连续内存,每次都会增加高速缓存未命中的几率和发生流水线停顿的几率。虚函数的另一个问题是编译器难以内联它们。编译器只有在它能同时访问函数体和构造实例的代码时才能内联它们。

3. 继承类中成员函数调用

当一个类继承另一个类时,继承类的成员函数可能需要进行一些额外的工作。

        继承类中定义的虚成员函数:如果继承关系最顶端的基类没有虚成员函数,那么代码必须要给this类实例指针加上一个偏移量,来得到继承类的虚函数表,接着会遍历虚函数表来获取函数执行地址。这些代码会包含更多的指令字节,而且这些指令通常会比较慢,因为它们会进行额外的计算。嵌入式处理器上非常明显,桌面级通过指令级别的并发掩盖了大部分这种额外的开销。

        多重继承的继承类中定义的成员函数调用:代码必须向this类实例指针中加上一个偏移量来组成指向多重继承类实例的指针。

        多重继承的继承类中定义的虚成员函数调用:代码必须向this类实例指针加上潜在不同的偏移量来组成继承类的类实例指针。

        虚多重继承:为了组成虚多继承类的实例的指针,代码必须解引用实例中的表,来确定得到指向虚多继承类的实例的指针时需要加在类实例指针上的偏移量。

4. 函数指针的开销

C++提供的函数指针,这样当通过函数指针调用函数时,代码可以在运行时选择要执行的函数体。处理基本的函数调用和返回开销,这种机制还会产生其他额外的开销。

函数指针(指向非成员函数和静态成员函数的指针)

        代码必须接引指针来获取函数的执行地址。编译器不太可能会内联这些函数。

成员函数指针

        成员函数指针声明同时指定了函数签名和解释函数调用的上下文中的类。程序通过将函数赋值给函数指针,显式地选择通过成员函数指针调用哪个函数。
        成员函数指针有多种形式,一个成员函数只能有一种表现形式。它必须足够通用才能够在以上列举的各种复杂的情景下调用任意的成员函数。我们有理由认为一个成员函数指针会出现最差情况的性能。

5. 函数调用开销总结

C风格的不带参数的void函数的调用开销最小。如果能够内联它,就没有开销,即使不能内联,开销也仅仅是两次内存读取加上两次程序执行的非局部转移。

如果基类没有虚函数,而虚函数也在多重继承的继承类中,那么这是最坏的情况。此时代码必须解引类实例中的函数表来确定加到类实例指针上的偏移量,构成虚拟多重继承函数的实例的指针,接着解引该实例来获得虚函数表,最后索引虚函数表得到函数执行地址。

惊叹C++居然如此高效地实施了这么复杂的特性。坏消息是除非函数会被频繁的调用,否则移除移除非连续内存读取并不足以改善性能;好消息则是分析器则会直接指出调用最频繁的函数。

7.2.2 简单地声明内联函数

移除函数调用开销的一种有效方式是内联函数。那些函数体在类中定义的函数会被隐式地声明为内联函数。通过将在类定义外部定义的函数声明为存储类内联,也可以明确地将它们声明为内联函数。

如果函数定义出现在它们在某个编译单元中第一次被使用之前,那么编译器还可能会直接选择内联比较短的函数。

当编译器内联函数一个时,那么它还有可能会改善代码,包括移除调用和返回语句。如果编译器能够确定当参数为某个特定值时有些分支永远不会执行,那么编译器会移除这些分支。

调试版本关闭了内联函数。

7.2.3 在使用之前定义函数

当编译器编译对某个函数的调用时发现该函数已经被定义了,那么编译器能够自主选择内联这次函数调用。如果编译器能够同时找到函数体,以及实例化那些发生虚函数调用的类变量、指针或者引用,那么这样适用于虚函数。

7.2.4 移除未使用的多态性

在C++中,虚成员函数多用来实现运行时多态。多态性允许成员函数根据不同的调用对象,从多个不同但语义上有关联的方法中选择一个执行。

要实现多态行为,可以在基类中定义虚成员函数。然后任何继承类都能够选择使用特化行为来重写基类函数的行为。这些不同的实现是通过每个继承类都必须有不同的实现的语义概念关联在一起的。

当程序必须在运行时从多种实现中选择一种执行时,虚函数表是一种非常高效的机制,它的间接成本只有两次额外的内存读取以及与这两次内存读取相关的流水线停顿。

不过,多态仍然可能会带来不必要的性能开销。例如,一个类的本来的设计目的是方便实现派生类的层次结构,但是最后却没有实现这些派生类;或者一个函数被声明为虚函数是希望利用多态性,但是这个函数却永远没有被实现。

7.2.5 放弃不使用的接口

基类通过声明一组纯虚函数(有函数声明,但没有函数体的函数)定义接口。由于纯虚函数没有函数体,因此C++不允许实例化接口基类。继承类可以通过重写接口基类中的所有纯虚函数来实现接口。C++中接口惯用法的优点在于,继承类必须实现接口中声明的所有函数,否则编译器将不允许程序创建继承类的实例。

开发人员可以使用接口类来隔离操作系统依赖性,特别是当设计人员预计需要为多个操作系统实现程序时。

例如:

// file.h -- 接口
class File
{
public:
    virtual ~fILE() {}
    virtual bool Open(Path& p) = 0;
    virtual bool Close() = 0;
    virtual int GetChar() = 0;
    virtual unsigned GetErrorCode() = 0;
};

C++11中的关键字override是可选关键字,它告诉编译器当前的声明会重写基类中虚函数的声明。当指定了override关键字后,如果在基类中没有虚函数声明,编译器会报出警告消息。

// Windowsfile.h -- 接口
#include "File.h"

class WindowsFile : public File
{
public:
    ~File() {}
    bool Open(Path&) override;
    bool Close() override;
    int GetChar() override;
    usnigned GetErrorCode() override;
};

// Windowsfile.cpp -- Windows版的实现
#include "WindowsFile.h"
bool WindowsFile::Open(Path& p)
{
...
}

如果性能优化开发人员并非设计接口的人,那么当他提议进行修改时,必须做好被驳回的准备。他人可能会建议他拿出性能数据来证明修改的合理性。

1. 在链接时选择接口实现

使用C++虚函数实现接口惯用法的问题在于,虚函数为设计时间问题提供一个带有运行时开销的运行时解决方案。

定义一个file的接口类来隔离操作系统依赖性。在继承类Windowsfile中实现了这个接口。在继承类Linux实现了这个接口。一般而言,Windowfile和Linuxfile永远不会 在同一个程序中被实例化。它们使得底层调用只会被实现在一个操作系统上。这样就不会发生虚函数的调用开销。

如果无需在运行时做出选择的话,那么开发人员可以使用链接器来从多个实现中选择一种。选择哪个实现会由链接器根据参数列表来做出决定。

在链接时选择实现的优点在于使得程序具有通用性,而缺点则是部分决定被放在.cpp文件中,部分决定被放在了makefile或是工程文件中。

2. 在编译时选择接口接口

如果对于两种file实现使用不同的编译器(gcc, clang),那么可以在编译时使用#ifdef来选择实现,头文件不需要做出任何改变。

// file.cpp--实现
#include "File.h"
#ifdef _WIN32
    bool File::Open(Path& p)
    {...}
...
#else // Linux
    BOOL fILE::Open(Path&)
    {...}
#endif

这个方法要求使用预处理宏来选择所希望的实现。有些开发人员喜欢这种方法,因为可以在.cpp文件中做更多决定。另外一些开发人员则认为在一个文件中编写两种实现方式是凌乱且非面向对象的。

7.2.6 用模板在编译时选择实现

C++模板特化是另一种在编译时选择实现的方法。利用模板,开发人员可以创建具有通用接口的类群,但是它们的行为取决于模板的类型参数。

模板参数可以是任意类型--具有之间的一组成员函数的类类型或是具有内建运算符的基本类型。因此,存在两种接口:模板类的public成员,以及由在模板参数上被调用的运算符和函数所定义的接口。

通过模板定义的接口就没有那么严格了。只有参数中那些实际会被模板的某种特化所调用的函数才需要被定义。

面的特性是一把双刃剑:一方面,即使开发人员在某个模板特化汇总忘记实现接口了,编译器也不会立即报出错误;但另一方面,开发人员也能够选择不去实现哪些在上下文汇总没被用到的函数。

从性能优化的角度看,多态类的层次与模板实例之间的最重要的区别是,通常在编译时整个模板都是可用的。在多数用例下,C++都会内联函数调用,用多种方法改善程序性能。

7.2.7 避免使用PIMPL惯用法

PIMPL是Pointer to IMPLemnetation的缩写,它是一种用作编译防火墙--一种防止修改一个头文件会触发许多源文件中被重新编译的机制--的编程惯用法。在90年代,大型程序的编译时间是以小时为单位计算的,当时使用PIMPL是合理的。

#include "foo.h"
#include "bar.h"
#include "baz.h"

class BigClass
{
public:
    BigClass();
    void f1(int a) {}
    void f2(float f){}
    Foo foo_;
    Bar bar_;
    Baz baz_;
};

要实现PIMPL。开发人员要定义一个新的类。

class Impl;

class BigClass()
{
public:
    BigClass();
    void f1(int a);
    void f2(float f);
    Impl* impl;
};

C++允许声明一个指向未完成类型,即一个还没有定义的对象的指针。是因为所有指针的大小都是相同的,因此编译器知道如何预留指针的存储空间。在实现上,BigClass的对外可见的定义不再依赖foo.h、bar.h、baz.h了。在bigclass.cpp中有Impl的完整定义。

// bigclass.cpp
#include "foo.h"
#include "bar.h"
#include "baz.h"
#include "bigclass.h"

class Impl
{
    void g1(int a);
    void g2(float f);
    Foo foo_;
    Bar bar_;
    Baz baz_;
};

void Impl :: g1(int a)
{...}

...

void BigClass::BigClass()
{
    impl_ = new Impl;
}

void BigClass::f1(int a)
{
    impl_->g1(a);
}

实现了PIMPL后,在编译时,对foo.h, bar.h, baz.h, Impl的实现的改变都会导致bigclass.cpp被重新编译。但是bigclass.h不会改变,这样就限制了重编译的范围。

在运行时情况就不同了,PIMPL给程序带来了延迟之前BigClass中的成员函数可能会被内联,而现在则会发生一次成员函数调用。而且,现在每次成员函数调用都会调用Impl的成员函数。使用了PIMPL的工程往往会在很多地方使用它,导致形成了多层嵌套函数调用。甚者,这些额外的函数调用层次使得调试变得更加困难。

2016年,PIMPL已经不是必须的,只有当BigClass是一个非常大的类,依赖于许多头文件时,才需要使用PIMPL。这样类的违背了许多面向对象编程原则。采用将BigClass分解,使接口功能更加集中的方法,可能与PIMPL同样有效。

7.2.8 移除对DLL的调用

在Window上,当DLL被按需加载后在程序中显式地设置函数指针,或是在程序启动时自动地加载DLL时隐式地设置函数指针,然后通过这个函数指针调用动态链接库DLL,Linux上也有DLL,实现也是相同的。

有些DLL调用是必需的,例如,应用程序可能需要实现第三方插件库。其他情况下,DLL则不是必需的。

另外一种改善函数调用性能的方式是不使用DLL,而是使用对象代码库并将其链接到可执行程序上。

7.2.9 使用静态成员函数取代成员函数

每次对成员函数的调用都有一个额外的隐式参数:指向成员函数被调用的类实例的this指针。通过对this指针加上偏移量可以获取类成员函数。虚成员函数必须解引用this指针来获得虚函数表指针。

有时,一个成员函数中的处理仅仅使用了它的参数,而不用访问成员数据,也不用调用其他的虚成员函数。在这种情况下,this指针没有任何作用。应当将这样的成员函数声明为静态函数。静态成员函数不会计算this指针,可以通过普通的函数指针,而不是开销更加昂贵的成员函数指针找到它们。

7.2.10 将虚析构函数移至基类中

任何有继承类的类的析构函数都应当被声明为虚函数。这样delete表达式将会引起一个指向基类的指针,继承类和基类的析构函数都会被调用。

另外一个在继承层次关系顶端的基类中声明虚函数的理由是:确保在基类中有虚函数表指针。

继承层次关系中的基类处于一个特殊的位置。如果在这个基类中有虚成员函数声明,那么虚函数表指针在其他继承类中的偏移量是0,;如果这个基类声明了成员变量且没有声明任何虚成员函数,但是有些继承类却声明了虚成员函数,那么每个虚成员函数调用都会在this指针上加一个偏移量来得到虚函数表指针的地址。确保在这个基类中至少有一个成员函数,可以强制虚函数表指针出现在偏移量为0的位置上,这有助于产生更高效的代码。

而析构函数则是最佳候选。如果这个基类有继承类。它就必须是虚函数。在类实例的生命周期中析构函数只会被调用一次,因此只要不是那些在程序中会被频繁地构造和析构的非常小的类,将其设置为虚函数后的开销是最小的。

7.3 优化表达式

在语句级别下面是设计基本数据类型(整数、浮点和指针)的数学计算,这也是最后的优化机会。如果一个热点函数中只有一条表达式,那么它可能是唯一的优化机会。

现代编译器非常善于优化设计基本数据类型的表达式。从其他所有方面到性能优化,它们都非常擅长,但是它们不够勇敢。

优化表达式在每次执行一次的小型处理器上有很好的效果。在桌面级的具有多段流水线的处理器中,虽然也可以测试到有改进效果,但并不明显。

7.3.1 简化表达式

C++会严格地以运算符的优先级和可结合性的顺序来计算表达式。C++之所以让程序员手动优化表达式,是因为C++的int类型的模运算并非是整数的数学运算,C++的float类型的近似计算也并非真正的数学运算。C++必须给予程序员足够的权力来清晰地表达他的意图,否则编译器会对表达式进行重排序,从而导致控制流程发生各种变化。这意味着开发人员必须尽可能使用最少的运算符来书写表达式。

用于计算多项式的霍纳法则证明了以一种更高效的形式重写表达式有多么厉害。

y = a * x * x * x + b * x * x + c * x + d;
// 简化后
y = (((a * x + b ) * x) + c) * x + d;

这条语句将会执行6次乘法运算和3次加法运算,简化后执行3次乘法,3次加法。通常霍纳法则可以将表达式的乘法运算从n(n - 1)减少为n,其中n为多项式维数。

C++之所以不会重排序算术表达式是因为这非常危险。数值分析是一个非常大的主题,a/b*c的故事。

开发人员是唯一必须知道表达式的写法,它的参数的数量级并对其输出结果负责的人,编译器不会帮助我们完成这项任务,这也是它不会优化表达式的原因。

7.3.2 将常量组合在一起

编译器可以帮我们做的一件事是计算常量表达式:

int seconds = 24 * 60 * 60 *days;
// 编译器计算常量表达式
int seconds = 86400 * days;

int seconds = 24 * 60 * days * 60;
// 编译器计算常量表达式
int seconds = 1440 * days * 60;

我们应当总是用括号将常量表达式组合在一起,或是将它们放在表达式的左端,或者更好的一种表达式的做法是,将它们独立出来初始化给一个常量,或者将它们放在一个常量表达式函数中。

7.3.3 使用更高效的运算符

如果其中一个参数可以用指数替换2的幂,那么开发人员就可以重写表达式,用位移运算代替乘法运算。

另一种优化是用位移运算和加法运算替代乘法运算。,例如x*9可以写成x*8 + x*1;

7.3.4 使用整数计算代替浮点型计算

浮点型计算的开销是昂贵的。浮点数值内部的表现比较复杂,它带有一个整数型尾数,一个独立的指数以及两个符号。PC上实现了浮点型计算单元可能占到芯片面积的20%。

// 对浮点类型进行摄入操作得到整数值
usnigned q = (unsigned)round((double)n / (double)d);

// C++中提供了来自C运行时库ldiv函数,它会生成一种同时包含整数和余数的结构
inline unsigned div0(unsigned n, unsigned d)
{
    auto r = ldiv(n, d);
    return (r.rem >= (d >> 1)) ? r.quat + 1: r.quot;
}

// 对整数除法的结果舍入
inline unsigned div1(unsigned n, unsigned d)
{
    unsigned q = n /d;
    unsigned r = n % d;
    return r >= (d >> 1) ? q + 1 : q;
}

// 对整数除法结果舍入
inline unsigned div1(unsigned n, unsigned d)
{
    return (n + (d >> 1)) / d;
}

7.3.5 双精度类型可能会比浮点型更快

在Visual C++生成的浮点型指令会引用老式x87 FPU coprocessor寄存器栈,float和double值都会移动到FP寄存器中,浮点计算都会以80位格式进行,它们都会被加长。对float进行转换的时间可能比对double进行转换的时间更长。

7.3.6 用闭形式代替迭代计算

有许多特殊情况都需要对置为1的位计数,找到最高有效位,确定一个字的奇偶校验位,确定一个字的位是否是2的幂。这些算法的时间开销为O(n),还有更快的紧凑的闭形式解决方法:计算时间开销为常量,不进行任何跌打。

考虑一个简单的用于确定一个整数是否为2的幂的迭代算法,所有这些值都只有1个位置为1,因此算出置为1的位数量是一种解决方法。

// 判断一个整数是否是2的幂的迭代算法的一种实现
inlien bool is_powr_2_iterative(unsigned n)
{
    for (unsigned one_bits = 0; n != 0; n >> 1)
    {
        if ((n & 1) == 1)
            if (one_bits != 0)
                return false;
            else
                one_bits += 1;
    }

    return true;
}

该问题有一个闭形解决方法。如果x是2的n阶幂,那么它只在第n位有一个置为1的位(以最低有效位作为第0位),接着,我们用x-1作为当置为1的位在第n-1,...,0位时的位掩码,那么x&(x-1)等于0如果x不是2的幂,那么它就有不止一个位置为1的位,那么使用x-1作为掩码计算后只会将最低有效位置为0,

inline bool is_power_2_closed(unsigned n)
{
    return ((n != 0)) && !(n & (n - 1));
}

7.4 优化控制流程惯用法

由于当指令必须被更新为非连续地址时在处理器中会发生流水线停顿,因此计算比控制更快。

7.4.1 用switch代替if-else

if语句的流程控制是线性的,首先测试if条件,然后在测试其他;

switch是将测试值与一系列常量进行比较,这样编译器可以进行一系列有效的优化。

一种常见的情况是被测试的常量一组连续值或是近似一组连续值,这时switch语句会被编译为jump指令表,其索引是要测试的值或是派生于要测试的值的表达式。switch语句会执行一次索引操作,然后跳转到表中地址。无论有多少种要比较的情况,每次比较处理的开销都是O(1)。

对于if-else而言,首先测试最可能出现的条件。

7.4.2 用虚函数代替switch或if

在C++出现之前,如果开发人员想要在程序中引入多态行为,那么他们必须编写一个带有标识变量的结构体或是联合体,然后通过这个标识变量来辨识当前使用的那个结构体或是联合体。类似于:

if (p->animalType == TIGER)
{
    tiger_pounce(p->tiger);
}
else if (p->animalType == RABBIT)
{
    rabit_hop(p->rabbit);
}
else if (...)

C++类中已经包含了一种机制来实现此功能,虚成员函数和作为识别器的虚函数表指针。

7.4.3 使用无开销的异常处理

如果程序不使用异常处理,那么关闭编译器的异常处理开关可以使程序变得更小,而且可能更快。

在C++早期,每个栈帧都包含了一个异常上下文:一个指向包含所有被构建的对象的链表的指针,因此当异常穿过栈帧被抛出时,这些对象也必须被销毁。随着程序执行,这个上下文会被动态地更新。这导致在程序执行的正常路径上增加了运行时开销。

不要使用异常规范:一是因为开发人员很难知道被调用的函数可能会抛出声明异常,二是异常规范对性能有负面影响。

C++11 引入了一种新的异常规范,称为noexcept。编译器要求将移动构造函数和移动赋值语句声明为noexcept来实现移动语义。

7.5 小结

除非一些因素放大了语句的性能开销,否则不值得进行语句级别的性能优化,因为所带来的性能提升不大;

循环中的语句的性能开销被放大的倍数是循环的次数;

函数中语句的性能开销被放大的倍数是函数中被调用的次数;

被频繁调用的编程惯用法的性能开销被放大的倍数是其被调用的次数;

有些C++语句(赋值,初始化,函数参数计算)中包含了隐藏的函数调用;

调用操作系统的函数的开销是昂贵的;

一种有效的移除函数调用开销的方法是内联函数;

现在几乎不需要PIMPL编程惯法;

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值