优化 C++/编写高效代码/性能提升特性
C++ 语言的一些特性,如果使用得当,可以提高生成的软件的速度。
在本节中,将介绍利用这些特性的指南。
在定义一个对象来存储整数时,使用 int
或 unsigned int
类型,除非需要更长的类型;在定义一个对象来存储字符时,使用 char
类型,除非需要 wchar_t
类型;在定义一个对象来存储浮点数时,使用 double
类型,除非需要 long double
类型。如果生成的聚合对象是中等或大型的,用最小整数类型替换每个整数类型(但不要使用位域),该类型足以包含它(但不要使用位域),并用 float
类型替换浮点类型,除非需要更高的精度。
根据定义,int
和 unsigned int
类型是在平台上最有效的类型,这些平台至少可以容纳 16 位范围。如果你只需要 8 位宽度,并且正在为 8 位处理器编译,那么 char
可能会更有效,但除此之外,int
类型之一可能是你可以使用的最有效的类型。
double
类型比 float
类型效率低两到三倍,但它具有更高的精度。
某些处理器类型对 signed char
对象的处理效率更高,而其他处理器类型对 unsigned char
对象的处理效率更高。因此,在 C 和 C++ 中,char
类型与 signed char
类型不同,被定义为目标处理器最有效的字符类型。
char
类型只能包含小的字符集;通常最多可以包含 255 个不同的字符。为了处理更大的字符集,你应该使用 wchar_t
类型,尽管它效率较低。
在包含在中等或大型聚合对象中或可能为中等或大型的集合中的数字的情况下,最好将聚合对象或集合的字节大小最小化。这可以通过用处理器字大小的原语替换大于字大小的原语来完成。short
实际上占用的内存量与字大小相同,即使 short
的字段大小更小。
位域也可以用来最小化聚合对象的大小,但是由于它们的处理速度较慢,这可能会适得其反。因此,请将它们的引入推迟到优化阶段。
不要将函数指针作为参数传递给函数,而是传递一个 函数对象(或者,如果使用 C++11 标准,则传递一个 lambda 表达式)。
例如,如果你有以下结构数组
struct S {
int a, b;
};
S arr[n_items];
… 并且你希望按 b
字段对其进行排序,你可以定义以下比较函数
bool compare(const S& s1, const S& s2) {
return s1.b < s2.b;
}
… 并将其传递给标准排序算法
std::sort(arr, arr + n_items, compare);
但是,定义以下函数对象类(也称为仿函数)可能效率更高
struct Comparator {
bool operator()(const S& s1, const S& s2) const {
return s1.b < s2.b;
}
};
… 并将它的一个临时实例传递给标准排序算法
std::sort(arr, arr + n_items, Comparator());
函数对象通常会被内联扩展,因此与就地代码一样高效,而通过指针传递的函数很少被内联。lambda 表达式是作为函数对象实现的,因此它们的性能相同。
不要使用 qsort
和 bsearch
C 标准库函数,而是使用 std::sort
和 std::lower_bound
C++ 标准库函数。
前两个函数需要一个函数指针作为参数,而后两个函数可以接受一个函数对象参数(或者,使用 C++11 标准,可以接受一个 lambda 表达式)。函数指针通常不会被内联扩展,因此效率低于函数对象,函数对象几乎总是被内联的。
封装(使用类)一个可以从多个编译单元访问的集合。
在设计时,很难决定在使用软件时哪个数据结构的性能最优。在优化时,可以测量性能,并可以查看更改容器类型是否会导致性能提升,例如从 std::vector
更改为 std::list
。但是,这种实现更改可能会传播到代码的用户。
如果一个集合是一个编译单元的私有集合,那么实现更改只会影响该单元的源代码,并且封装集合是不必要的。但是,如果集合不是私有的(换句话说,它可以直接从其他编译单元访问),那么实现更改可能会导致需要进行大量的更改。因此,为了使这种优化变得可行,将集合封装在一个类中,当容器实现更改时,该类的接口不会更改。
STL 容器已经使用了这个原则,但是某些操作仍然只对某些容器可用(如 operator[]
,对 std::vector
可用,但对 std::list
不可用)。
使用 STL 容器时,如果多个等效表达式具有相同的性能,请选择更通用的表达式。
例如,调用 a.empty()
而不是 a.size() == 0
,调用 iter != a.end()
而不是 iter < a.end()
,调用 distance(iter1, iter2)
而不是 iter2 - iter1
。前几个表达式对每种容器类型都有效,而后几个表达式只对某些容器类型有效。前者也比后者效率不低,甚至可能更高。例如,要获取链表的大小,必须遍历链表,而要查看链表是否为空是一个常数时间操作。
不幸的是,并不总是能够编写对每种容器类型都同样正确和高效的代码。但是,减少依赖于容器类型的语句的数量,将减少如果容器类型稍后更改,必须更改的语句的数量。
选择可变长度容器时,如果有疑问,请选择 vector
。
对于具有少量元素的数据集,vector
是对任何操作最有效的可变长度容器。
对于较大的集合,其他容器在某些操作中可能效率更高,但是 vector
仍然具有最低的空间开销(只要没有多余的容量)和最大的引用局部性。
如果您的编译器允许进行整个程序优化和自动内联函数扩展,请使用这些选项,并且不要将任何函数声明为 inline
。如果这些编译器功能不可用,请在头文件中将合适的函数声明为 inline
;合适的函数包含不超过三行代码,并且没有循环。
内联函数扩展可以避免函数调用的开销。随着函数参数数量的增加,开销也会增加。此外,由于内联代码靠近调用者代码,因此具有更好的局部性。并且由于编译器为内联函数生成的中间代码与调用者代码合并,因此编译器可以更轻松地对其进行优化。
内联扩展一个很小的函数,例如仅包含简单赋值或简单 return
语句的函数,会导致生成的机器代码大小减小。
相反,每次内联包含大量代码的函数时,机器代码都会被复制,程序的总大小也会增加。增加程序大小也可能降低指令缓存的性能,从而增加延迟。
内联代码更难分析。如果一个非内联函数是瓶颈,分析器可以找到它。但是,如果相同的函数在任何调用它的位置都内联了,它的运行时间就会分散在许多函数中,分析器无法检测到瓶颈。
对于包含大量代码的函数,在优化期间应仅将性能关键的函数声明为 inline
。
要表示内部符号,请使用枚举而不是字符串。
例如,不要使用以下代码
const char* const directions[] = { "North", "South", "East", "West" };
请使用以下代码
enum directions { North, South, East, West };
枚举被实现为一个整数。与整数相比,字符串占用更多空间,并且复制和比较速度更慢。(此外,使用字符串而不是整数来表示内部状态可能会在处理多个区域设置的代码中引入字符串比较错误。)
如果必须将整数值与一组常数值进行比较,请使用 switch
语句,而不是一系列 if
语句。
例如,不要使用以下代码
if (a[i] == 1) f();
else if (a[i] == 2) g();
else if (a[i] == 5) h();
编写以下代码
switch (a[i]) {
case 1: f(); break;
case 2: g(); break;
case 5: h(); break;
}
编译器可能会利用 switch
语句的规律性来应用一些优化,特别是如果应用了本节中的“switch
语句的 case 值”指南。
作为 switch
语句 case 的常量,请使用紧凑的序列值,即没有间隙或只有少量小间隙的序列。
编译一个 case 值包含整数区间中大多数值的 switch
语句时,优化编译器将生成一个跳转表,而不是生成一系列 if
语句。该表是一个数组,包含每个 case 的代码的起始地址。执行 switch
语句时,该表用于跳转到与 case 编号关联的代码。
例如,以下 C++ 代码
switch (i) {
case 10:
case 13:
func_a();
break;
case 11:
func_b();
break;
}
可能会生成与以下伪代码相对应的机器代码
// N.B.: This is not C++ code
static address jump_table[] = { case_a, case_b, end, case_a };
unsigned int index = i - 10;
if (index > 3) goto end;
goto jump_table[index];
case_a: func_a(); goto end;
case_b: func_b();
end:
相反,以下 C++ 代码
switch (i) {
case 100:
case 130:
func_a();
break;
case 110:
func_b();
break;
}
可能会生成与以下代码相对应的机器代码
if (i == 100) goto case_a;
if (i == 130) goto case_a;
if (i == 110) goto case_b;
goto end;
case_a: func_a(); goto end;
case_b: func_b();
end:
对于如此少的 case,这两种情况之间可能没有太大区别,但随着 case 数量的增加,前一种代码变得更加高效,因为它只执行一次计算的 goto,而不是一系列分支。
在 switch
语句中,请将典型的 case 放在最前面。
如果编译器不使用跳转表,则 case 按出现顺序进行评估;因此,更频繁的 case 执行的比较次数更少。
在调用参数数量超过寄存器数量的函数的循环中,请考虑传递一个结构或对象。
例如,不要使用以下代码
for (int i = 0; i < 1000; ++i) {
f(i, a1, a2, a3, a4, a5, a6, a7, a8);
}
请考虑编写以下内容
struct {
int i;
type a1, a2, a3, a4, a5, a6, a7, a8;
} s;
s.a1 = a1; s.a2 = a2; s.a3 = a3; s.a4 = a4;
s.a5 = a5; s.a6 = a6; s.a7 = a7; s.a8 = a8;
for (int i = 0; i < 1000; ++i) {
s.i = i;
f(s);
}
如果所有函数参数都可以直接放入处理器寄存器中,则可以快速传递和操作这些参数。如果参数数量超过可用寄存器数量,则无法放入寄存器的那些参数将在每次函数调用开始时被推入堆栈,并在调用结束时从堆栈中移除。如果传递一个结构或对象,则可以使用一个寄存器,并且在初始化结构或对象之后,只需要更新在连续调用之间发生更改的结构或对象的部分。
编译器在用于函数参数的寄存器数量方面有所不同。依赖于任何特定编译器版本的使用的寄存器数量是不明智的。假设使用 4 个寄存器是合理的。
要搜索容器中的元素,请使用容器成员函数,而不是 STL 算法。
如果容器提供了一个重复通用 STL 算法的成员函数,那么是因为该成员函数更有效。
例如,要搜索一个 std::set
对象,您可以使用 std::find
通用算法,或者 std::set::find
成员函数。前者具有线性复杂度 (O(n)),而后者具有对数复杂度 (O(log(n)))。
要搜索排序的序列,请使用 std::lower_bound
、std::upper_bound
、std::equal_range
或 std::binary_search
通用算法。
鉴于所有引用的算法都使用对数复杂度 (O(log(n))) 二分搜索,它们比使用线性复杂度 (O(n)) 顺序扫描的 std::find
算法更快。
在每个类中,将所有不访问类非static
成员的成员函数声明为 static
。
换句话说,将所有可以声明为 static
的成员函数都声明为 static
。
这样,就不会传递隐式 this
参数。