第六章 优化动态分配的内存变量
C++ 变量回顾
变量存储期
每个变量都有它的存储期,也称为生命周期。只有在这段时间内,变量所占用的存储空间或者内存字节中的值才是有意义的。为变量分配内存的开销取决于存储期。
静态存储期
具有静态存储期的变量被分配在编译器预留的内存空间中。在程序编译时,编译器会为每个静态变量分配一个固定位置和固定大小的内存空间。静态变量的内存空间在程序的整个生命周期内都会被一直保留。所有的全局静态变量都会在程序执行进入 main() 前被构建,在退出 main() 之后被销毁。在函数内声明的静态变量则会在“程序执行第一次进入函数前”被构建,这表示它可能会和全局静态变量同时被构建,也可能直到第一次调用该函数时才会被构建。
为静态变量创建存储空间是没有运行时开销的。不过,我们无法再利用这段存储空间。因此,静态变量适用于那些在整个程序的生命周期内都会被使用数据。
在命名空间作用域内定义的变量以及被声明为 static 或是 extern 的变量具有静态存储期。
线程局部存储期
自 C++11 开始, 程序可以声明具有线程局部存储期的变量。在 C++11 之前,有些编译器和框架也以一种非标准的形式提供了类似的机制。
线程局部变量在进入线程时被构建,在退出线程时被析构。它们的生命周期与线程的生命周期一样。每个线程都包含一份这类变量的独立的副本。
访问线程局部变量可能会比访问静态变量开销更高,这取决于操作系统和编译器。在某些系统中,线程局部存储空间是由线程分配的,所以访问线程局部变量的开销比访问全局变量的开销多一次指令。而在其他系统中,则必须通过线程 ID 索引一张全局表来访问线程局部变量。尽管这个操作的时间开销是常量时间,但是会发生一次函数调用和一些计算,导致访问线程局部变量的开销变得更大。
自 C++11 开始,用 thread_local 存储类型指示符关键字声明的变量具有线程局部存储期。
自动存储期
动态存储期
变量所有权
C++ 变量的另一个重要概念是所有权。变量的所有者决定了变量什么时候会被创建,什么时候会被析构。有时,存储期会决定变量什么时候会被创建,什么时候会被析构,但所有权是另外一个单独的概念,而且是对优化动态变量而言非常重要的概念。下面是一些指导原则。
全局所有权
具有静态存储期的变量整体上被程序所有。程序会在进入 main() 前构建它们,并在从main() 返回后销毁它们。
词法作用域所有权
具有自动存储期的变量被一段由大括号括起来的代码块构成的词法作用域所拥有。词法作用域可能是函数体, if、 while、 for 或者 do 控制语句块, try 或者 catch 子句,抑或是由大括号括起来的多条语句。这些变量在程序进入词法作用域时会被构建,在程序退出词法作用域时会被销毁。
最外层的词法作用域,即最先进入和最后退出的词法作用域,是 main() 的函数体。也就是说,声明在 main() 中的自动变量的生命周期与静态变量相同。
成员所有权
类和结构体的成员变量由定义它们的类实例所有。当类的实例被构建时,它们会被类的构造函数构建;当类的实例被销毁时,它们也会随之被销毁。
动态变量所有权
值对象与实体对象
一个变量是实体对象还是值对象决定了复制以及比较相等是否有意义。实体不应当被复制和比较。一个类的成员变量是实体还是值决定了应该如何编写该类的构造函数。
使用智能指针实现动态变量所有权的自动化
使用 std::unique_ptr 时会发生一些小的性能损失,因此当开发人员想要优化性能时, std::unique_ptr 是首选。
std::shared_ptr 也因此比 C 风格指针和std::unique_ptr 的开销更大。
动态变量有着运行时开销
减少动态变量的使用
动态变量对于许多问题来说是一种强大的解决方案。不过,有时使用它们解决某些问题太过于昂贵了。静态创建的变量常常可以用于替代动态变量
静态地创建类实例
虽然我们可以动态创建类的实例,但大多数非容器类实例都能够且应当被静态地创建(即
不使用 new 表达式)
MyClass* myInstance = new MyClass("hello", 123);
这种方案很低效。相反,我们应当像下面这样静态地创建类实例:
MyClass myInstance("hello", 123);
或者
MyClass anotherMC = MyClass("hello", 123); // 可能稍微低效
如果 myInstance 声明于一个可执行代码块中,那么它具有自动存储期。它会在程序退出包含这段声明语句的代码块时被销毁。如果我们希望 myInstance 的存储期更长些,可以将myInstance 定义在更外层的作用域中,或是定义在一个具有较长存储期的对象中,然后将指针传递给那些要使用 myInstance 的函数。如果我们希望 myInstance 的存储期与整个程序的生命周期一样长,那么可以将它的声明移至文件作用域中。
静态地创建类地成员变量
当类的成员变量也是类时,我们可以在创建类时静态地创建这些成员变量。这样可以节省为这些成员变量分配内存的开销。
有时候看起来必须动态地创建类实例,因为它是其他类的成员变量,而且用于创建该成员变量的资源在创建类实例时还未就绪。两段初始化
使用静态数据结构
用std::array替代std::vector
如果在编译时能够知道数组的大小,或是最大的大小,那么可以使用与 std::vector 具有类似接口,但数组大小固定且不会调用内存管理器的 std::array。
在栈上创建大块缓冲区
如果开发人员能够知道字符串可能会增至的最大长度,或者至少估算出一个比较合理的最大长度,那么就可以使用一个具有自动存储期且长度超过可能的最大长度的 C 风格的字符数组作为临时字符串,然后利用这个临时字符串进行字符串连接操作,最后再将结果从临时字符串中复制出来。
静态地创建链式数据结构
可以使用静态初始化的方式构建具有链式数据结构的数据。
在数组中创建二叉树
如果节点的索引是 i,那么它的两个子节点的索引分别是 2i 和 2i+1。这种方法带来的另外一个好处是,能够很快地知道父节点的索引是 i/2。由于这些乘法和除法运算在代码中可以实现为左位移和右位移,因此即使在处理能力非常差的处理器上这些计算也不会太慢。
用环形缓冲区替代双端队列
std::deque 和 std::list 经常被用于 FIFO(first-in-first-out,先进先出)缓冲区,以至于在标准库中有一个称为 std::queue 的容器适配器。
其实,还可以在环形缓冲区上实现双端队列。环形缓冲区是一个数组型的数据结构,其中,队列的首尾两端由两个数组索引对数组的长度取模给定。
使用std::make_shared替代new表达式
像 std::shared_ptr 这 样 的 共 享 指 针 实 际 上 了 包 含 了 两 个 指 针: 一 个 指 针 指 向std::shared_ptr 所指向的对象;另一个指针指向一个动态变量,该变量保存了被所有指向该对象的 std::shared_ptr 所共享的引用计数。因此,下面这条语句:
std::shared_ptr<MyClass> p(new MyClass("hello", 123));
会调用两次内存管理器:第一次用于创建 MyClass 的实例,第二次用于创建被隐藏起来的
引用计数对象。
不要无谓地共享所有权
使用“主指针”拥有动态变量
减少动态变量的重新分配
预分配动态变量以防止重新分配
string 和 vector 都有成员函数 reserve(size_t n),调用该函数会告诉 string 或是 vector请确保至少有存储 n 个元素的空间。如果可以事先计算或是预估出这个大小,那么调用reserve() 为 string 或是 vector 预留足够的内部存储空间,可以避免出现它们到达增长极限后需要重新分配存储空间的情况。在使用 reserve() 预分配 string 或是 vector 后,还可以使用 std::string 或是 std::vector的 shrink_to_fit() 成员函数将未使用的空间返回给内存管理器。
在循环外创建动态变量
在下面这段代码中,循环虽小,问题却大。这段程序会将 namelist 中的每个文件中的每行都添加到 std::string 类型的变量 config 中,接着再从 config 中抽出一小部分数据。问题出在每次循环中都会创建config,并且在每次循环内部,随着 config 的不断增大,都会重新分配内存。接着,在循环末尾离开了它的作用域后, config 将会被销毁,它的存储空间会被返回给内存管理器:
for (auto& filename : namelist) {
std::string config;
ReadFileXML(filename, config);
ProcessXML(config);
}
提高这段循环的性能的一种方法是将 config 的声明移至循环外部。在每次循环中,我都会先清除该变量。不过, clear() 函数并不会释放 config 内部的动态缓冲区,它只是将config 的内容的长度设置为 0。从第二次循环开始,只要接下来的文件没有比第一次循环中使用的文件大很多,就不会重新分配内存:
std::string config;
for (auto& filename : namelist) {
config.clear();
ReadFileXML(filename, config);
ProcessXML(config);
}
移除无谓的复制
在 Kernighan 和 Ritchie(K & R)定义的 C 中,所有可以被直接赋值的实体都是 char、int、 float 和指针等基本类型,它们都会被保存在一个单独的寄存器中。因此,类似 a =b 这样的赋值语句是高效的,只会生成一两个用于获取 b 的值并将其存在 a 中的指令。在C++ 中, char、 int 或是 float 等基本类型的赋值同样高效。
但是,在 C++ 中,也存在着看似简单,但其实并不高效的赋值语句。如果 a 和 b 都是BigClass 类的实例,那么赋值语句 a = b; 会调用 BigClass 的赋值运算符成员函数。赋值运算符可以只是简单地将 b 的字段全部复制到 a 中去。但是问题在于这个函数可能会做任何C++ 函数都会做的事情。 BigClass 可能有很多字段需要复制。如果 BigClass 中有动态变量,复制它们可能会引发对调用内存管理器的调用。如果 BigClass 中有一个保存有数百万元素的 std::map 或是一个保存有数百万字符的字符数组,那么赋值语句的开销会非常大。
实际上,复制可能会发生于以下任何一种情况下:
- 初始化(调用构造函数)
- 赋值(调用赋值运算符)
- 函数参数(每个参数表达式都会被移动构造函数或复制构造函数复制到形参中)
- 函数返回(调用移动构造函数或复制构造函数,甚至可能会调用两次)
- 插入一个元素到标准库容器中(会调用移动构造函数或复制构造函数复制元素)
- 插入一个元素到 vector 中(如果需要重新为 vector 分配内存,那么所有的元素都会通过移动构造函数或复制构造函数复制到新的 vector 中)
在类定义中禁止不希望发生的复制
并非程序中所有的对象都应当被复制。例如,具有实体行为的对象(请参见 6.1.3 节)不应当被复制,否则会失去它们的意义。
// 在C++11中禁止复制的方法
class BigClass {
public:
BigClass(BigClass const&) = delete;
BigClass& operator=(BigClass const&) = delete;
...
};
任何企图对以这种方式声明的类的实例赋值——或通过传值方式传递给函数,或通过传值方式返回,或是将它用作标准库容器的值时——都会导致发生编译错误。
但是还可以用指向该类的指针和引用来赋值或初始化变量,或是在函数中传递和返回指向该类实例的引用或指针。从性能优化的角度看,使用指针或引用进行赋值和参数传递,或是返回指针或引用更加高效,因为指针或引用是存储在寄存器中的。
移除函数调用上的复制
int Sum(std::list<int> v) {
int sum = 0;
for (auto it : v)
sum += *it;
return sum;
}
这里传递实参会对实参进行复制一份到形参中,开销过大
优化做法:
int Sum(const std::list<int>& v) {
int sum = 0;
for (auto it : v)
sum += *it;
return sum;
}
通过引用访问实例也会产生开销:每次访问实例时,都必须解引实现该引用的指针。如果函数很大,而且在函数体中多次使用了参数值,那么连续地解引引用的开销会超过所节省下来的复制开销,导致性能改善收益递减。但是对于小型函数,除了特别小的类以外,通过引用传递参数总是能获得更好的性能。
移除函数返回上的复制
如果函数返回一个值,那么这个值会被复制构造到一个未命名的与函数返回值类型相同的临时变量中。对于 long、 double 或指针等基本类型会进行默认的复制构造,而当变量是类时,复制构造通常都会发生实际的函数调用。类越大越复杂,复制构造函数的时间开销也越大。下面来看一个例子:
std::vector<int> scalar_product(std::vector<int> const& v, int c) {
std::vector<int> result;
result.reserve(v.size());
for (auto val : v)
result.push_back(val * c);
return result;
}
正如在前一节中所讨论的一样,在有些情况下,通过返回引用而不是返回已经创建的返回值,可以避免发生复制构造开销。不幸的是,如果在函数内计算出返回值后,将其赋值给了一个具有自动存储期的变量,那么这个技巧将无法适用。当函数返回后,这个变量将超出它的作用域,导致悬挂引用将会指向一块堆内存尾部的未知字节,而且该区域通常都会很快被其他数据覆盖。更糟糕的是,函数计算返回结果是很普遍的情况,所以多数函数都会返回值,而非引用。
就像返回值的复制构造的开销并不算太糟糕,调用方常常会像 auto res =scalar_product(argarray, 10);
这样将函数返回值赋值给一个变量。因此,除了在函数内部调用复制构造外,在调用方还会调用复制构造函数或赋值运算符。
通过输出参数返回该引用参数:
void scalar_product(
std::vector<int> const& v,
int c,
vector<int>& result) {
result.clear();
result.reserve(v.size());
for (auto val : v)
result.push_back(val * c);
}
这里,我们在函数参数列表中加入了一个称为 result 的输出参数。这种机制有以下几个
优点。
- 当函数被调用时,该对象已经被构建。有时,该对象必须被清除或是重新初始化,但是
这些操作不太可能比构造操作的开销更大。 - 在函数内被更新的对象无需在 return 语句中被复制到未命名的临时变量中。
- 由于实际数据通过参数返回了,因此函数的返回类型可以是 void,也可以用来返回状
态或是错误信息。 - 由于在函数中被更新的对象已经与调用方中的某个名字绑定在了一起,因此当函数返回
时不再需要复制或是赋值。
在 C++ 中有一种情况只能通过值返回对象:运算符函数。当开发人员在编写矩阵计算函数时,如果希望使用通用的运算符 A = B * C;,就无法使用引用参数。在实现运算符函数时必须格外小心,确保它们会使用 RVO 和移动语义,这样才能实现最高效率。
免复制库
实现写时复制惯用法
通常来说,当一个带有动态变量的对象被复制时,也必须复制该动态变量。这种复制被称为深复制。通过复制指针,而不是复制指针指向的变量得到包含无主指针的对象的副本,这种复制被称为浅复制。
写时复制的核心思想是,在其中一个副本被修改之前,一个对象的两个副本一直都是相同的。因此,直到其中一个实例或另外一个实例被修改,两个实例能够共享那些指向复制开销昂贵的字段的指针。写时复制首先会进行一次“浅复制”,然后将深复制推迟至对象的某个元素发生改变时。
切割数据结构
切割(slice)是一种编程惯用法,它指的是一个变量指向另外一个变量的一部分。例如,C++17 中推荐的 string_view 类型就指向一个字符串的子字符串,它包含了一个指向子字符串开始位置的 char* 指针以及到子字符串的长度。
被切割的对象通常都是小的、容易复制的对象,将其内容复制至子数组或子字符串中而分配存储空间的开销不大。如果被分割的数据结构为被共享的指针所有,那么切割是完全安全的。但是经验告诉我,被切割的对象的生命是短暂的。它们在短暂地实现了存在的意义后,就会在被切割的数据结构能够被销毁前超出它们的作用域。例如, string_view 使用一个无主指针指向字符串。
实现移动语义
就性能优化而言, C++11 中加入的移动语义对 C++ 具有非常重要的意义。移动语义解决了之前版本的 C++ 中反复出现的问题,例子如下。
- 将一个对象赋值给一个变量时,会导致其内部的内容被复制。这个运行时开销非常大。而在这之后,原来的对象立即被销毁了。复制的努力也随之化为乌有,因为本来可以复用原来对象的内容的。
- 开发人员希望将一个实体(请参见 6.1.3 节),例如一个 auto_ptr 或是资源处理句柄,赋值给一个变量。在这个对象中,赋值语句中的“复制”操作是未定义的,因为这个对象具有唯一的识别符。
编译器会为这个类自动地生成移动构造函数和移动赋值运算符。如果类的成员定义了移动操作,这些移动运算符就会对这些成员进行一次移动操作;如果没有,则进行一次复制操作。这等同于对每个类成员都执行 this->member = std::move(rhs.member)
。
class Foo {
std::unique_ptr<int> value_;
public:
...
Foo(Foo&& rhs) {
value_ = rhs.release();
}
Foo(Foo const& rhs) : value_(nullptr) {
if (rhs.value_)
value_ = std::make_unique<int*>(*rhs.value_);
}
};
更新代码以使用移动语义
下面这份检查项目清单有助于读者进行这项工作:
- 找出一个从移动语义中受益的问题。例如,在复制构造函数和内存管理函数上花费了太多时间可能表明,增加移动构造函数或移动赋值运算符可能会使那些频繁被使用的类受益。
- 升级 C++ 编译器(如果编译器中不带有标准库,那么还需要升级标准库)到一个更高级的支持移动语义的版本。在升级后要重新运行性能测试,因为改变编译器可能会显著地改变那些使用了字符串和矢量等标准库组件的代码的性能,导致热点函数排行榜也随之发生变化。
- 检查第三方库,查看是否有新的支持移动语义的版本。如果继续使用那些不支持移动语义的库,那么即使编译器支持移动语义,对开发人员也不会任何帮助。
- 当碰到性能问题时,为类定义移动构造函数和移动赋值运算符。
移动语义的微妙之处
1. 移动实例至std::vector
noexcept 关键字告诉编译器,函数中不会发生异常,这有利于编译器对程序做更多的优化。如果在运行时,声明为 noexecpt 的函数向外抛出了异常(如果函数内部捕捉了异常并完成处理,这种情况不算抛出异常),程序会直接终止,调用std::terminate()函数,该函数内部会调用std::abort()终止程序。
右值引用参数是左值
由于 MoveExample 只能接收右值作为参数,但当这个右值被赋予 s 时会被转化为左值,因此需要 move 再将其显示地转化为右值。
不要返回右值引用
移动父类和类成员
扁平数据结构
当一个数据结构中的元素被存储在连续的存储空间中时,我们称这个数据结构为扁平的。相比于通过指针链接在一起的数据结构,扁平数据结构具有显著的性能优势。
第七章 优化热点语句
从循环中移除代码
char s[] = "This string has many space (0x20) chars. ";
...
for (size_t i = 0; i < strlen(s); ++i)
if (s[i] == ' ')
s[i] = '*';
这段代码对字符串中的每个字符都会判断循环条件 i < strlen(s) 是否成立 1。调用strlen() 的开销是昂贵的,遍历参数字符串对它的字符计数使得这个算法的开销从 O(n) 变为了 O(n^2)。
缓存循环结束条件值
我们可以通过在进入循环时预计算并缓存循环结束条件值,即调用开销昂贵的 strlen() 的返回值,来提高程序性能。
for (size_t i = 0, len = strlen(s); i < len; ++i)
if (s[i] == ' ')
s[i] = '*';
使用更高效的循环语句
以下是 C++ 中 for 循环语句的声明语法:
for (初始化表达式 ; 循环条件 ; 继续表达式 ) 语句
粗略地讲, for 循环会被编译为如下代码:
初始化表达式 ;
L1: if ( ! 循环条件 ) goto L2;
语句 ;
继续表达式 ;
goto L1;
L2:
for 循环必须执行两次 jump 指令:一次是当循环条件为 false 时;另一次则是在计算了继续表达式之后。这些 jump 指令可能会降低执行速度。 C++ 还有一种使用不那么广泛的、称为 do 的更简单的循环形式,它的声明语法如下:
do 语句 while ( 循环条件 );
粗略地讲, do 循环会被编译为如下代码:
L1: 语句
if ( 循环条件 ) goto L1;
因此,将一个 for 循环简化为 do 循环通常可以提高循环处理的速度。
size_t i = 0, len = strlen(s); // for循环初始化表达式
do {
if (s[i] == ' ')
s[i] = ' ';
++i; // for循环继续表达式
} while (i < len); // for循环条件
用递减替代递增
缓存循环结束条件的另一种方法是用递减替代递增,将循环结束条件缓存在循环索引变量中。许多循环都有一种结束条件判断起来比其他结束条件更高效。例如,在之前代码的循环判断条件中,一种结束条件是常量 0,而另外一种则是调用开销昂贵的 strlen() 函数。
for (int i = (int)strlen(s)-1; i >= 0; --i)
if (s[i] == ' ')
s[i] = '*';
从循环中移除不变性代码
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;
int tmp = j * x * x;
for (i=0; i<10; ++i) {
a[i] = i + tmp;
}
现代编译器非常善于找出在循环中被重复计算的具有循环不变性的代码(如同这里介绍的),然后将计算移动至循环外部来改善程序性能。
当在循环中有语句调用了函数时,编译器可能无法确定函数的返回值是否依赖于循环中的某些变量。被调用的函数可能很复杂,或是函数体包含在另外一个编译器看不到的编译单元中。这时,开发人员必须自己找出具有循环不变性的函数调用并将它们从循环中移除。
从循环中移除无谓的函数调用
从循环中移除隐含的函数调用
C++ 代码还可能会隐式地调用函数,而没有这种很明显的调用语句。当一个变量是以下类型之一时就可能会发生这种情况:
- 声明一个类实例(调用构造函数)
- 初始化一个类实例(调用构造函数)
- 赋值给一个类实例(调用赋值运算符)
- 涉及类实例的计算表达式(调用运算符成员函数)
- 退出作用域(调用在作用域中声明的类实例的析构函数)
- 函数参数(每个参数表达式都会被复制构造到它的形参中)
- 函数返回一个类的实例(调用复制构造函数,可能是两次)
- 向标准库容器中插入元素(元素会被移动构造或复制构造)
- 向矢量中插入元素(如果矢量重新分配了内存,那么所有的元素都需要被移动构造或是
复制构造)
这些函数调用被隐藏起来了。你从表面上看不出带有名字和参数列表的函数调用。它们看起来更像赋和声明。我们很容易误以为这里没有发生函数调用。如果赋值语句和初始化声明具有循环不变性,那么我们可以将它们移动到循环外部。有时,即使需要每次都将变量传递到循环中,你也可以将声明移动到循环外部,并在每次循环中都执行一次开销较小的函数调用。例如, std::string 是一个含有动态分配内存的字符数组的类。在以下代码中:
for (...) {
std::string s("<p>");
...
s += "</p>";
}
优化为
std::string s;
for (...) {
s.clear();
s += "<p>";
...
s += "</p>";
}
从循环中移除昂贵的、 缓慢改变的调用
有些函数调用虽然并不具有循环不变性,但是也可能变得具有循环不变性。一个典型的例子是在日志应用程序中调用获取当前时间的函数。它只需要几条指令即可从操作系统获取当前时间,但是却需要花费些时间来格式化显示时间。
日志记录必须尽可能地高效,否则会降低程序的性能。如果这降低了程序性能就糟糕了,如果性能的下降改变了程序行为,进而导致在打开日志记录后程序的 bug 消失就更糟了。在这个例子中,获取当前时间决定了记录日志的开销。
相比于现代计算机的指令执行速度,时间的改变非常慢。很明显,我的程序可以在两次时标之间记录 100 万行日志。因此,连续调用 timetoa() 两次获取到的当前时间可能是相同的。如果需要一次记录许多行日志,那么就没有理由在记录每条时都去获取当前时间。
将循环放入函数以减少调用开销
如果程序要遍历字符串、数组或是其他数据结构,并会在每次迭代中都调用一个函数,那么可以通过一种称为**循环倒置(loop inversion)**的技巧来提高程序性能。循环倒置是指将在循环中调用函数变为在函数中进行循环。这需要改变函数的接口,不再接收一条元素作为参数,而是接收整个数据结构作为参数。按照这种方式修改后,如果数据结构中包含 n 条元素,那么可以节省 n-1 次函数调用。
void replace_nonprinting(char& c) {
if (!isprint(c))
c = '.';
}
for (unsigned i = 0, e = str.size(); i < e; ++i)
replace_nonprinting(str[i]);
如果编译器无法对 replace_nonprinting() 内联展开,那么当处理的字符串长度很大时,就会出现很多次函数调用。
库的设计者可以重载 replace_nonprinting() 函数来处理整个字符串:
void replace_nonprinting(std::string& str) {
for (unsigned i = 0, e = str.size(); i < e; ++i)
if (!isprint(str[i]))
c = '.';
}
现在,循环在函数内部了,这样可以节省 n-1 次对 replace_nonprinting() 的调用。
不要频繁地进行操作
从函数中移除代码
函数调用的开销
函数调用的基本开销
虚函数的开销
继承中的成员函数调用
函数指针的开销
函数开销的总结
简短地声明内联函数
移除函数调用开销的一种有效方式是内联函数。要想内联函数,编译器必须能够在函数调用点访问函数定义。那些函数体在类定义中的函数会被隐式地声明为内联函数。通过将在类定义外部定义的函数声明为存储类内联,也可以明确地将它们声明为内联函数。
在使用之前定义函数
在第一次调用函数之前定义函数(提供函数体)给了编译器优化函数调用的机会。当编译器编译对某个函数的调用时发现该函数已经被定义了,那么编译器能够自主选择内联这次函数调用。如果编译器能够同时找到函数体,以及实例化那些发生虚函数调用的类变量、指针或是引用的代码,那么这也同样适用于虚函数。
移除未使用的多态性
放弃不使用的接口
在 C++ 中可以使用虚成员函数实现接口——一组通用函数的声明。这些函数描述了对象行为,而且它们在不同的情况下有不同的实现方式。基类通过声明一组纯虚函数(有函数声明,但没有函数体的函数)定义接口。由于纯虚函数没有函数体,因此 C++ 不允许实例化接口基类。继承类可以通过重写(定义)接口基类中的所有纯虚函来实现接口。 C++ 中接口惯用法的优点在于,继承类必须实现接口中声明的所有函数,否则编译器将不会允许程序创建继承类的实例。
在链接时选择接口实现
在编译时选择接口实现
用模板在编译时选择实现
避免使用PIMPL惯用法
移除对DDL的调用
使用静态成员函数取代成员函数
将虚析构函数移至基类中
优化表达式
在语句级别下面是涉及基本数据类型(整数、浮点类型和指针)的数学计算。这也是最后的优化机会。如果一个热点函数中只有一条表达式,那么它可能是唯一的优化机会。
简化表达式
将常量组合在一起
使用更高效的运算符
使用整数计算替代浮点型计算
双精度类型可能会比浮点型更快
用闭形式替代迭代计算
优化控制流程惯用法
由于当指令指针必须被更新为非连续地址时在处理器中会发生流水线停顿,因此计算比控制流程更快。 C++ 编译器会努力地减少指令指针更新的次数。了解这些知识有助于我们编写更快的代码。
用switch替代if-else
用虚函数替代switch或if
使用无开销的异常处理
第八章 使用更好的库
C++标准库的哲学
哲学上, C++ 标准库之所以提供这些函数和类, 是因为要么无法以其他方式提供这些函数和类, 要么这些函数和类会被广泛地用于多种操作系统上。
使用C++标准库的注意事项
- 标准库的实现中有 bug
- 标准库的实现可能不符合 C++ 标准
- 对标准库开发人员来说,性能并非最重要的事情
- 库的实现可能会让一些优化手段无效
- 并非 C++ 标准库中的所有部分都同样有用
- 标准库不如最好的原生函数高效
优化现有库
- 改动越少越好
- 添加函数, 不要改动功能
设计优化库
面对设计糟糕的库,性能优化开发人员几乎无能为力。但是面对一个空白屏幕时,性能优化开发人员则有更大的使用最佳实践以及避免性能陷阱的余地。
草率编码后悔多
接口的稳定性是设计可持续交付的库的核心。
设计优化库与设计其他 C++ 代码是一样的, 不过风险更高。
具有良好设计的库中的函数都应当能够独立测试。如果在测试一个目标函数前需要实例化许多对象,那么这对于设计人员来说就是一个信号,它表明库的组件之间存在着太多的耦合。
在库的设计上, 简约是一种美德
“简约”表示库应当专注于某项任务,而且只应当使用最小限度的资源来完成这项任务。
不要在库内分配内存
这是简约原则的一个具体示例。由于内存分配非常昂贵,如果可能的话,请在库外部进行内存分配。例如,应当让库函数通过参数接收内存,然后向其中写值,而不要让库函数分配并返回内存。
如果有必要,可以将内存分配放到继承类中,然后在基类中仅仅保存一个指向已分配内存的指针。这种方式可以让继承类以不同的方式分配内存。
若有疑问, 以速度为准
在第 1 章中,我引用了高德纳教授的告诫:“过早优化是万恶之源。”当时,我还是太武断了。不过在设计库时,这条建议特别危险。库在设计之初就应该考虑性能问题。
函数比框架更容易优化
函数的优势在于我们可以独立地测量和优化它们的性能。调用一个框架会牵扯到它内部的所有类和函数,使得修改变得难以隔离和测试。框架违反了分离原则或是单一职责原则。这使得它们难以优化。
扁平继承层次关系
多数抽象都不会有超过三层类继承层次:一个具有通用函数的基类,一个或多个实现多态的继承类,以及一个在非常复杂的情况下可能会引入的多重继承混合层。
扁平调用链
与继承类一样, 绝大多数抽象的实现都不会超过三层嵌套函数调用:一种非成员函数或是成员函数实现策略,调用某个类的成员函数,调用某个实现了抽象或是访问数据的公有或私有的成员函数。
扁平分层设计
避免动态查找
留意“上帝函数
“上帝函数”是指实现了高级策略的函数。如果在程序中使用这种函数,会导致链接器向可执行文件中添加许多库函数。