五.不同c++结构的效率

1.不同种类变量存储

变量和对象如果在cpp中声明的方式不同,它们在内存中的位置也不同,这会影响数据缓存的效率,如果数据在内存中分散那么数据缓存效率就会很差,所以要了解变量存储的方式。

1.1栈(stack)

在函数内部声明的变量和对象存储在栈上(特殊情况会在下面声明)

栈是前进后出的(FILO),主要存储函数返回地址(即函数被调用的地方),函数参数,局部变量,以及保存函数返回前必须恢复的寄存器(的值)。每次调用函数都会在栈中分配所需的空间,然后在函数返回时释放该部分空间,这就意味着这部分内存空间被一次又一次的重复利用,所以栈是存储数据的最有效的存储空间,这部分内存被镜像在一级数据缓存中,访问速度很快。

从中我们学到,尽可能的将变量和对象声明在它们被使用的函数中而不是函数外。

1.2全局(global)或者静态(static)存储

静态内存区主要存储全局变量和静态变量(static修饰的变量),在函数之外声明的变量称为全局变量(当然了函数内部的是局部变量),被static修饰的变量有几种情况

  • 修饰局部变量时,这个局部变量只初始化一次,并且延长了这个局部变量的生命周期,直到程序运行结束后才释放
  • 修饰全局变量时,这个全局变量只能在本文件中访问,不能再其他文件中访问,即使是extern外部声明都不行
  • (如果static修饰函数,则表示这个函数只能在本文件中调用)

除此之外静态内存区还存储浮点常量,字符串常量,数组初始化列表,switch语句跳转表和虚函数表。

尽量不要将变量设置为全局变量,除非是不同线程间的通信这种情况才必须设置全局变量。

尽量将查找表静态化(查找表就是下面代码展现的结构)

1
2
3
4
float fun(int x){
static const float list[]={1.1,0.3,-2.0,4.4,2.5};
return list[x];
}

需要先介绍下这个数组是如何初始化的,用浮点常数来初始化数组,其实是将浮点数存储在静态内存区,然后从静态内存区复制到栈中的(整形常量通常包含在指令中,所以不会有这种缓存问题)。

我们在这里将其声明为static,就是让这个数组只初始化一次,不会每次调用函数都会优化,然后将其声明为const,其实是告诉编译器这是个常量,如果不声明const函数不知道这是第一次调用还是之后的调用,每次都会进行检查,使用const则提高了效率

1.3寄存器(register)

有限数量的变量可以之间存储在cpu的寄存器中,但是数量很少

1.4Volatile

volatite关键字声明的变量可以被某些编译器未知的因素更改,比如操作系统、硬件或者其他线程。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。特别要注意的是volatile并不保证原子性。

1.5线程本地存储(thread-local storage TLS)

我们知道多线程共享同一个进程的地址空间,对全局变量来说,某一个线程对其修改会影响其他所有线程。如果我们需要一个变量在每个线程中都能访问,并且值在每个线程中互不影响,这就是 Thread Local Storage。

通过thread_local, __thread or __declspec(thread) 关键字声明的变量,每个线程将会拥有一个实例,称为全局的线程本地存储。线程本地存储是低效的,因为它是通过存储在线程环境块中的指针进行访问的。我们应该尽量将变量存储在栈上(即在函数内部声明的局部变量),存储在栈上的变量总是属于创建它们的线程。

1.6动态内存分配

使用new delete molloc free完成的内存分配和回收称为动态内存分配,从内存中选取一部分称为堆用于分配内存。动态内存分配若没有回收则会造成内存泄漏,若在释放对象后访问指针还会造成空指针错误。

1.7类中声明的变量

在cpp中,数据和函数是分开存放的,函数在代码区,数据主要在栈、堆、静态内存区。

类的静态成员变量在编译时被分配到静态内存区,非静态成员变量只有在初始化对象时才被分配内存。

静态成员函数(静态方法)和非静态成员函数(非静态方法)都是存储在代码区的。

接下来说一下对象在内存中是如何存储的:当对象在函数内部被创建时(即局部变量),对象的成员变量存储在栈;当对象是全局变量,则对象的成员变量存储在静态区;当对象是static修饰的局部变量,对象的成员变量存储在静态区;如果对象是通过new创建出来的,对象的成员变量存储在堆区。

有必要说明一下cpp中的对象有两种创建方式,直接创建和通过new创建,前者存储在栈或者静态区,后者存储在堆上

1
2
3
4
5
//存储在栈或者静态区
ClassName object(param);
//存储在堆上,必须delete否则内存泄漏
ClassName *object=new ClassName(param);
delete object;

(再扯一嘴,看起来用new创建对象好像很麻烦没有必要比较低效,但是好处是一不用担心内存空间不足,因为是动态拓展的,二即使是在函数内部创建对象,只要不delete,对象就一直存在,解决了对象的生命周期问题。)

2.整型变量

2.1整数大小

不同类型的整型变量有不同的大小,大小和在stdint.h中的名称可以参照下图

image-20220317144109932

因为不同平台声明特定大小整型的方式不同,所以如果stdint.h or inttypes.h 存在,可以用它们来定义特定大小的整型,以达到可移植的目的。

在大小无关紧要,不存在溢出风险的情况下建议使用默认大小的整型,比如简单的变量,循环计数器等等。

在大型数组中,推荐使用够用的最小类型的正数,以便能够更好的使用数据缓存。

无符号整数类型size_t在32位系统中为32位,在62位系统中为64位,此类型常用于表示数组大小和数组索引,能够确保不会发生溢出。

在进行整型计算时,最重要的就是检查是否会发生溢出,比如a=(b*c)/d,即使abc都小于最大值,(b*c)也有可能溢出。

2.2无符号数和有符号数

无符号数在除以常量及取模时比有符号数快。

有符号数转换为浮点数比无符号数快。

关于溢出:无符号数溢出会变为较小的正数(相当于取模),有符号数溢出一般会变成负值

另外需要特别注意的是不要混合使用有符号数和无符号数,在进行两者的计算或比较时,会将有符号数转为无符号数比较(可以参考csapp的笔记),尽量避免无符号数除非进行模运算或者使用变量代表位的集合。

2.3整型运算

加法、减法、比较、移位基本只需要一个时钟周期。

乘法可能需要3-4个时钟周期,除法需要40-80个时钟周期,具体取决于cpu

3.浮点变量

X86架构的现代cpu有两种不同类型的浮点寄存器,以及对应的两种不同类型的浮点指令。

最开始执行浮点操作的方法涉及到8个浮点寄存器(称为FPU),它们组织成寄存器栈,称为x87寄存器。这些寄存器都以long double(80 bits)存储数据,当其他类型数据压入寄存器时,会自动拓展成80位。

这类寄存器栈的优点为:

  • 所有计算以long double类型完成
  • 不同精度之间的转换不需要额外的时间
  • 某些数学函数比如对数和三角函数使用内部指令完成
  • 代码紧凑,在代码缓存中占用很少空间

缺点:

  • 由于使用寄存器栈,编译器很难创建寄存器变量
  • 除非启用Pentium-II 或者更高的版本,否则浮点计算较为缓慢
  • 整数和浮点数转换效率低下
  • 使用long double导致除法、平方根等数学函数耗时更长

另一种新的浮点运算使用八个或者更多的向量寄存器(XMM,YMM,ZMM),浮点运算使用单精度或者双精度,中间结果的精度总是与操作数相同。

这类向量寄存器的优点为:

  • 很容易创建寄存器变量
  • 可以向量化运算,即SIMD并行计算

缺点:

  • 不支持long double
  • 如果涉及混合精度的计算,需要显示的进行精度转换,这可能会很耗时(而上文提到的方式精度转换不需要额外的时间)
  • 执行数学函数必须依赖写好的数学库,但这通常要比内在硬件函数要快

关于单双精度执行计算的速度问题,当使用矢量计算时,单精度除法、平方根和数学函数的计算速度要快于双精度,不使用矢量寄存器时,加减乘等速度相同。(有待细化考究)

当使用XMM这种类似寄存器时,不要混合使用单精度和双精度,另外,尽量避免浮点变量和整数的转换。

4.布尔变量

布尔运算有短路现象,可以利用这一特点加快程序运行

布尔变量是超定的???

5.指针和引用

  • 指针和引用其实在做同样的事,它们经过编译器编译后的结果是相同的,引用可以看作常量指针。
  • 使用引用语法更简单(引用直接代表所指变量的值,而指针代表地址,需要*p才能得到所指变量的值)
  • 引用比指针更加安全,因为引用在定义时必须进行初始化,而指针很可能因为未初始化导致空指针问题
  • 引用和指针计算的意义不同,指针加减某数或者自增自减都是改变地址,指向了另一块内存,而引用的计算直接改变的是引用的值

在计算出指针的值后大约需要两个时钟周期才能访问指向的对象,所以x = *(p++)x = *(++p) 更有效

6.智能指针

将基本类型指针封装为类对象指针(这个类肯定是个模板,以适应不同基本类型的需求),并在析构函数里编写delete语句删除指针指向的内存空间。

通过智能指针访问对象和普通指针一样不需要付出额外的代价,,但是当智能指针创建、删除、复制或者从一个函数转移到另一个函数时都会产生额外的成本。

7.类型转换

7.1无符号到有符号转换

只是编译器以不同的方式解释整数的位,转换不需要额外的时间

7.2整型大小转换

整型拓展:无符号数添0拓展,有符号数通过拓展符号位来将整数转换为更长的大小

整型缩小:简单的忽略高位,不做溢出检查,不需要额外的时间,当然,如果数太大就会溢出

7.3浮点数精度转换

这涉及到前面讲的两种浮点计算单元,如果是浮点寄存器栈,双、单、半精度的转换不需要额外的时间。如果使用XMM寄存器,就需要2-15个时钟周期,使用向量浮点计算时,做精度转换的成本很高。

7.4整数到浮点数

有符号数转浮点需要4到16个时钟周期,无符号数更长。如果没有溢出风险,先将无符号数转为有符号数再转浮点会更快。

7.5浮点到整数的转换

除非开启SSE2或者更高版本的指令集,否则将浮点转整数需要很长的时间,通常50-100个时钟周期。 原因是C/C++标准规定使用截断模式,因浮点舍入模式要更改为截断,然后再次改回来。

如果在代码的关键部分中存在浮点到整数转换,那么是有必要做一些事情是。 可能的解决方案是:

  • 通过使用不同类型的变量来避免转换。
  • 将中间结果存储为浮点数,把转换移出最内层循环。
  • 使用64位模式或启用SSE2指令集(需要支持此功能的微处理器)。
  • 使用舍入而不是截断,并使用汇编语言创建舍入函数。有关舍入的详细信息,后面会进行说明

7.6指针类型转换

指针的类型是可以转换的,并且不会产生额外的代码,只是以不同的方式解释相同数据位,当然,这种转换并不安全。

我们来看个例子

1
2
float x;
*(int*)&x|=0x80000000

第一眼看上去会很奇怪不知道在干什么,其实慢慢从右往左看,先取了x的地址,得到一个float类型的指针,然后将其强转为int指针,再用*取指针所指的值进行计算,进行的是一个位或计算,将其最高位置为1,所以这行代码的功能就是将x转为int类型并且符号位置为1,即负数。

通过对其地址进行类型转换,可以使编译器将变量或对象视为具有不同的类型

7.7常量强制转换

const_cast运算符用于规避常量指针的常量限制

1
2
3
4
5
6
7
8
class c1 {
const int x; // constant data
public:
c1() : x(0) {}; // constructor initializes x to 0
void xplus2() { // this function can modify x
*const_cast<int*>(&x) += 2; // add 2 to x
}
};

8.分支和switch语句

现代微处理器的高速度是通过流水线获得的。然而,流水线结构有一个大问题。一旦代码中有分支出现(例如,if-else),微处理器并不能预先知道该把两个分支中的哪一个送入流水线。 如果错误的分支被喂给了流水线,该错误直到10至20个时钟周期后才能被发现,这期间所有的取指,解码,甚至推测性指令执行工作都被浪费掉了。

现代微处理器使用先进的算法,根据该分支及附近分支的历史数据来预测分支的走向,在微处理器做出正确预测的情况下,分支指令通常需要0-2个时钟周期。从分支错误预测中恢复,所需的时间大约为12-25个时钟周期,具体取决于处理器。 这被称为分支错误预测惩罚

for循环或while循环也是一种分支。每次循环迭代后,会决定是重复循环还是退出。 如果循环计数很小并且不变,循环分支通常可以很好的被预测。 可以完美预测的最大循环计数通常介于9到64之间,具体取决于处理器。 仅仅某些处理器上可以较好地预测嵌套循环。 在许多处理器上,一个循环内包含多个分支,不能很好地被预测

switch 语句是一种超过两条路的分支。 下面情形 switch 语句最为高效:

  • 每个 case 的标签遵循一个序列
  • 在该序列中,某个标签的当前值等于上一个标签加一。

这是因为,这种情况可以使用跳转目标表来实现。 具有许多标签的 switch 语句,并且标签的值离得很远,该情形是很低效的,因为编译器必须将其转换为分支树。

在程序的关键部分,分支和switch语句的数量最好保持较小,特别是如果分支的可预测性很差。如果可以消除分支,则展开循环可能很有用。下一段将会细讲。

9.循环

循环的效率取决于微处理器对于循环控制分支预测的好坏,一个固定重复计数器并且没有分支的循环能够被完美预测,而嵌套循环只在一些有特定循环预测器的处理器上预测的很好,在其他的处理器上只有内部循环被预测的很好。

9.1循环展开

在某些情况下循环展开是一个优势,例如:

1
2
3
4
5
6
7
8
9
10
int i;
for (i = 0; i < 20; i++) {
if (i % 2 == 0) {
FuncA(i);
}
else {
FuncB(i);
}
FuncC(i);
}

该循环重复20次并交替调用FuncAFuncB,然后调用FuncC。 将循环展开两次代码如下:

1
2
3
4
5
6
7
8
// Example 7.30b
int i;
for (i = 0; i < 20; i += 2) {
FuncA(i);
FuncC(i);
FuncB(i+1);
FuncC(i+1);
}

这样做有三个优势:

  1. 这样做循环执行10次而不是20次
  2. 重复次数减少到10,意味着在某些处理器上可以完美预测
  3. 移除了if分支

只有在可以获得特定优势的情况下才应使用循环展开。 如果循环包含浮点计算并且循环计数器是整数,那么通常可以假设整个计算时间由浮点代码而不是循环控制分支确定。 在这种情况下,通过展开循环没有任何好处。

在具有微操作高速缓存(例如,Sandy Bridge)的处理器上应该避免循环展开,因为重要的是节省微操作高速缓存的使用。 如果看上去有好处,编译器通常会自动展开循环。

程序员不必手动展开循环,除非能获得特别的好处,比如上面的代码例子消除了if分支

9.2循环控制条件

如果循环控制分支依赖于循环内的计算,则效率很低,所以尽量将循环控制条件脱离循环内部,比如:

以下示例将以零结尾的ASCII字符串转换为小写:

1
2
3
// Example 7.31a
char string[100], *p = string;
while (*p != 0) *(p++) |= 0x20;

如果已知字符串的长度,则使用循环计数器更有效:

1
2
3
// Example 7.31b
char string[100], *p = string; int i, StringLength;
for (i = StringLength; i > 0; i--) *(p++) |= 0x20;

循环计数器应该优先选择整数,如果循环需要浮点数,可以再创建一个整数作为循环计数器

1
2
3
// Example 7.32a
double x, n, factorial = 1.0;
for (x = 2.0; x <= n; x++) factorial *= x;

这可以通过添加整数计数器,并在循环控制条件中使用整数来加以改进:

1
2
3
// Example 7.32b
double x, n, factorial = 1.0; int i;
for (i = (int)n - 2, x = 2.0; i >= 0; i--, x++) factorial *= x;

9.3不要使用循环复制或者清除数组

将循环用于琐碎的任务,例如复制数组或将数组设置为全零,可能不是最佳选择。 例如:

1
2
3
4
5
6
7
// Example 7.33a
const int size = 1000; int i;
float a[size], b[size];
// set a to zero
for (i = 0; i < size; i++) a[i] = 0.0;
// copy a to b
for (i = 0; i < size; i++) b[i] = a[i];

使用函数 memsetmemcpy 一般更快一些:

1
2
3
4
5
6
7
// Example 7.33b
const int size = 1000;
float a[size], b[size];
// set a to zero
memset(a, 0, sizeof(a));
// copy a to b
memcpy(b, a, sizeof(b));

至少在简单的情况下,大多数编译器会通过调用memset和memcpy自动替换这样的循环。 显式使用memsetmemcpy是不安全的,因为如果size参数大于目标数组,则可能发生严重错误。 但是如果循环计数太大,循环也会发生相同的错误。

10.函数

调用函数可能会降低程序速度。原因如下:

  • 函数调用会让微处理器跳转到不同的代码地址然后返回。这可能需要多达4个时钟周期。 在大多数情况下,微处理器能够将调用及返回操作与其他计算重叠运行以节省时间。
  • 如果代码碎片化地分散在内存中,代码缓存的效率会降低。
  • 函数参数以32位模式存储在堆栈中。 将参数存储在堆栈上并再次读取它们需要额外的时间。 如果某个参数处于关键依赖链条中,因此带来的延时是需要重视的。
  • 设置堆栈帧,保存和恢复寄存器,以及保存异常处理信息(可能需要,也可能不需要),都需要额外的时间。
  • 每个函数调用语句占用分支目标缓冲区(BTB)中的一个单元。 如果程序的关键部分有很多函数调用和分支,BTB中的争用可能导致分支预测错误

可以采用以下方法解决此问题

10.1避免非必要的函数

可以牺牲部分代码的重用性,将多行代码合为一个函数,以加快速度(当然这里要见仁见智)

10.2使用内联函数

使用inline关键字定义内联函数

10.3避免在嵌套循环的最内层出现函数嵌套

调用其他函数的函数叫帧函数,不调用其他函数的函数叫叶函数,叶函数效率更高。如果在嵌套循环的最内层包含对帧函数的调用,可以通过以下方法来改进

  • 内联帧函数
  • 把帧函数转换为叶函数(具体方法就是内联帧函数中被调用的函数)

10.4使用宏代替函数

使用#define声明的宏肯定会被内联

10.5使用fastcall函数

  • 在32位模式下,关键字__fastcall更改函数调用方法,以便前两个(CodeGear编译器上是三个)整型参数使用寄存器传输,而不是用堆栈传输。 这可以提高具有整型参数函数的速度。浮点参数不受__fastcall的影响。 类的成员函数中的隐式“this”指针也被视为参数,因此可能只剩下一个空闲寄存器来传输其他参数。 因此,在使用__fastcall时,请确保最关键的整型参数首先出现在函数参数中。
  • 在64位模式下,函数参数默认传输到寄存器中。 因此,在64位模式下无法识别__fastcall关键字。

10.6函数本地化

将关键字static添加到函数声明中,使得函数被设置为本地函数

10.7使用64位模式

参数传输在64位模式下比32位模式下更高效,在64位Linux中比在64位Windows中更高效。

  • 在64位Linux中,前六个整数参数和前八个浮点参数在寄存器中传输,总计最多十四个寄存器参数。
  • 在64位Windows中,前四个参数在寄存器中传输,无论它们是整数还是浮点数。

因此,如果函数具有四个以上的参数,则64位Linux比64位Windows更有效。 在参数传递这方面,32位Linux和32位Windows之间没有区别

11.函数参数

在大多数情况下,函数参数按值传输。这意味着参数的值将复制到局部变量。 这对于简单类型(如int,float,double,bool,enum以及指针和引用)都很有效。

数组总是作为指针传递,除非它们被封装到类或结构中。

如果参数具有复合类型(例如结构或类),则情况会更复杂。 如果满足以下所有条件,则复合类型参数的传输效率可以很高:

  • 对象足够小,可以装进单个寄存器
  • 对象没有复制构造函数,也没有析构函数
  • 对象没有虚拟成员(虚拟函数)
  • 对象不使用运行时类型标识(RTTI)(什么是RTTI参考这个连接 [C++中的RTTI](C++中的RTTI机制 - 简书 (jianshu.com)))

如果上述某一或某些条件不满足,则将使用指向对象的指针或引用来传递对象通常会更快。 如果对象很大,那么复制整个对象显然需要时间。

  • 将对象复制到参数时,必须调用复制构造函数,
  • 并且函数返回之前,如果有析构函数的话,还必须调用析构函数

将复合对象传递给函数的首选方法是使用const引用。 const引用确保不修改原始对象。 与指针或非const引用不同,const引用允许函数参数是表达式或匿名对象。 如果函数被内联,编译器可以轻松地优化掉const引用。

另一种方法是使该函数成为对象类或结构的成员。 这种方法效率同等的高。

简单的函数参数

  • 在32位系统中传输到堆栈中,
  • 但在64位系统的寄存器中传输。

后者更有效率。

  • 64位Windows允许在寄存器中传输最多四个参数。
  • 64位Unix系统允许在寄存器中传输多达14个参数(8个浮点数或2个加6个整数,指针或引用参数)。

成员函数中的this指针也计入一个参数。进一步的细节在手册5中给出:“不同的C++编译器和操作系统调用约定”。

12.函数返回类型

函数返回类型最好是

  • 简单类型
  • 指针
  • 引用
  • void

返回复合类型太复杂且效率低下,如果要返回一个复合对象的话,有几种解决方法

  • 使该函数成为该对象的构造函数
  • 修改已有的对象而不创建新对象,函数参数通过指针或者引用传对象,返回值也通过指针或者引用返回对象
  • 在函数内部创建静态对象并返回指针,这样效率高,但是有风险, 返回的指针或引用仅在下次调用函数前有效,此对象也可能被在不同的线程中改写。 如果您忘记将此本地对象设置为静态,则只要函数返回它就会立即失效。
  • 使用new在函数内部创建对象,但是效率低,且有内存泄漏的风险

13.函数尾调用优化

尾调用:指某个函数的最后一步是调用另一个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//下面情况是尾调用
function f(x){
return g(x);
}
//但是下面两种就不属于尾调用了
// 情况一
function f(x){
let y = g(x);
return y;
}
// 情况二
function f(x){
return g(x) + 1;
}

我们知道,函数调用会在内存形成一个”调用记录”,又称”调用帧”(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用记录上方,还会形成一个B的调用记录。等到B运行结束,将结果返回到A,B的调用记录才会消失。如果函数B内部还调用函数C,那就还有一个C的调用记录栈,以此类推。所有的调用记录,就形成一个“调用栈”(call stack)。

img

尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用记录,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用记录,取代外层函数的调用记录就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function f() {
let m = 1;
let n = 2;
return g(m + n);
}
f();

// 等同于
function f() {
return g(3);
}
f();

// 等同于
g(3);

上面代码中,如果函数g不是尾调用,函数f就需要保存内部变量m和n的值、g的调用位置等信息。但由于调用g之后,函数f就结束了,所以执行到最后一步,完全可以删除 f() 的调用记录,只保留 g(3) 的调用记录。

这就叫做”尾调用优化”(Tail call optimization),即只保留内层函数的调用记录。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用记录只有一项,这将大大节省内存。这就是”尾调用优化”的意义。

另一个非常重要的应用是尾递归,递归非常耗费内存,因为需要同时保存成千上百个调用记录,很容易发生”栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用记录,所以永远不会发生”栈溢出”错误。

1
2
3
4
5
6
function factorial(n) {
if (n === 1) return 1;
return n * factorial(n - 1);
}

factorial(5) // 120

上面代码是一个阶乘函数,计算n的阶乘,最多需要保存n个调用记录,复杂度 O(n) 。

如果改写成尾递归,只保留一个调用记录,复杂度 O(1) 。

1
2
3
4
5
6
function factorial(n, total) {
if (n === 1) return total;
return factorial(n - 1, n * total);
}

factorial(5, 1) // 120

递归尾调用虽然效率会高一些,但是仍然比循环效率低,尽量将递归变为循环。

14.结构体和类

如今,编程教科书推荐使用面向对象编程,使软件开发更加清晰和模块化。 对象是结构和类的实例。 面向对象的编程风格对程序性能,既有正面又有负面的影响。积极影响是:

  • 如果一组变量是相同结构或类的成员,则它们一起使用,也存储在一起。这使数据缓存更有效。
  • 类成员变量不需要作为参数传递给类成员函数,避免了参数传输的开销。

面向对象编程的负面影响是:

  • 非静态成员函数有一个’this‘指针,它作为隐式参数传递给函数。所有非静态成员函数都会产生’this‘参数传输的开销。

  • this‘指针占用一个寄存器。寄存器是32位系统中的稀缺资源。

  • 虚拟成员函数效率较低

关于面向对象编程的正面影响,还是负面影响占主导地位,没有一般性的定论。至少,可以说使用类和成员函数,代价并不昂贵。 你可以使用面向对象的编程风格:
如果它对程序的逻辑结构和清晰度有好处,只要在程序的最关键部分避免过多的函数调用,
单纯结构的使用(没有成员函数)对性能没有负面影响。

14.1实例变量

无论在什么时间创建类或结构的实例,其数据成员都按照它们被声明的顺序连续存储。 将数据组织到类或结构中,并不会导致性能损失。 访问类或结构对象的数据成员并不会比访问简单变量花费更多的时间。大多数编译器会将数据成员与地址对齐,以便优化访问。

结构或类中如果混有不同大小的数据成员,数据对齐会导致某些字节未被使用,比如:

1
2
3
4
5
6
7
8
struct S1 {
short int a; // 2个字节. 第一个在偏移位置0, 第二个在位置1
// 6个未使用的字节
double b; // 8个字节. 第一个在偏移位置8, 最后一个在位置15
int d; // 4个字节. 第一个在偏移位置16, 最后一个在位置19
// 4个未使用字节
};
S1 ArrayOfStructures[100];

上面的结构体由于对其浪费了8个字节,在整个数组中浪费了800个字节。

我们可以通过调整数据成员声明的顺序来避免浪费。

通过重新安排数据成员的编码顺序,通常可以使结构和类对象更小。 如果类有至少一个虚拟函数,则在第一个数据成员之前,或最后一个成员之后,有一个指向虚拟表的指针。 该指针在32位系统中为4个字节,在64位系统中为8个字节。 如果你对结构或其每个成员的大小有疑问,可以使用sizeof运算符进行测试。 sizeof运算符返回的值包括对象末尾的任何未使用的字节的大小。

  • 如果数据成员相对于结构或类的开头的偏移量小于128,则生成的访问数据成员的代码更为紧凑,因为偏移量可以表示为8位有符号数。
  • 如果相对于结构或类的开头的偏移量大于等于128字节,则偏移量必须表示为32位数(指令集在介于8位和32位偏移之间,没有任何区别)。

例如:

1
2
3
4
5
6
7
// Example 7.40
class S2 {
public:
int a[100]; // 400字节. 首字节偏移量0, 尾字节偏移量399
int b; // 4字节. 首字节偏移量400, 尾字节偏移量403
int ReadB() {return b;}
};

这里b的偏移量为400。通过指针或成员函数(如ReadB)访问b的任何代码都需要将偏移量编码为32位数。 如果交换ab,则可以使用编码为8位有符号数的偏移量访问这两个变量,或者根本不用偏移。 这使代码更紧凑,从而更有效地使用代码缓存。 因此,对于大型数组和其他大型对象,建议在结构或类声明中排在最后。并且最常用的数据成员放在前面。 如果不能把所有数据成员包含前128个字节内,则将最常用的成员放在前128个字节中

14.2方法(类成员函数)

所有的类成员函数都把隐含的this指针作为参数。

因此下面三个方法一样快

1
2
3
4
5
6
7
8
class S3 {
public:
int a;
int b;
int Sum1() {return a + b;}
};
int Sum2(S3 * p) {return p->a + p->b;}
int Sum3(S3 & r) {return r.a + r.b;}

但是有些编译器会通过寄存器传递this指针而不是堆栈,所以会导致Sum1的效率略高些

14.3虚函数

先回顾一下面向对象中多态的概念,多态分为静态多态和动态多态,也叫编译时多态和与运行时多态。前者即静态多态指函数重载和模板编程,后者动态多态指父类的指针(对象引用变量)指向不同类的对象时,所调用的方法会有不同的效果,需要注意cpp中只有虚函数才能被重写,如果没有virtual修饰,父类指针始终调用父类的方法,而不会调用子类中重写的方法。

虚函数的实现原理:

存在虚函数的类中都有一个一维的表叫做虚表,每一个类的对象都有一个指向虚表头部的虚指针,每一个类对应一个虚表,每一个对象对应一个虚表指针。

编译器在编译的时候,发现Base类中有虚函数,此时编译器会为每个包含虚函数的类创建一个虚表(即vtable),该表是一个一维数组(而不是一个链表),在这个数组中存放每个虚函数的地址。由于Base类和Derive类都包含了一个虚函数func(),编译器会为这两个类都建立一个虚表。

那么如何定位虚表呢?编译器另外还为每个带有虚函数的类的对象自动创建一个虚表指针(即vptr),这个指针指向了对象所属类的虚表。在程序运行时,根据对象的类型去初始化vptr,从而让vptr正确的指向所属类的虚表。所以在调用虚函数时,就能够找到正确的函数。对于上述程序,由于实际指向的对象类型是Derive,因此vptr指向的Derive类的vtable,当调用func()时,根据虚表中的函数地址找到的就是Derive类的func()函数。

正是由于每个对象调用的虚函数都是通过虚表指针来索引的,也就决定了虚表指针的正确初始化是非常重要的。换句话说,在虚表指针没有正确初始化之前,我们不能够去调用虚函数。那么虚表指针在什么时候,或者说在什么地方初始化呢?
答案是在构造函数中进行虚表的创建和虚表指针的初始化。

构造函数的调用顺序,在构造子类对象时,要先调用父类的构造函数,之后再完成子类的构造。在调用父类的构造函数时,编译器只“看到了”父类,并不知道后面是否后还有继承者,它初始化虚表指针,将该虚表指针指向父类的虚表。当执行子类的构造函数时,虚表指针被重新赋值,指向自身的虚表。对于以上的例子,当Derive类的对象构造完毕后,其内部的虚表指针也就被初始化为指向Derive类的虚表。在类型转换后,调用func(),由于实际指向的是Derive类的对象,该对象内部的虚表指针指向的是Derive类的虚表,因此最终调用的是Derive类的func()函数。

要注意:对于虚函数调用来说,每一个对象内部都有一个虚表指针,该虚表指针被初始化为本类的虚表。所以在程序中,不管你的对象类型如何转换,但该对象内部的虚表指针是固定的,所以呢,才能实现动态的对象函数调用,这就是C++多态性实现的原理。


多态性时面向对象程序比非面向对象程序效率低的主要原因之一。 如果可以避免使用虚函数,那么你可以获得面向对象编程的大部分优势,而无需支付性能成本。

  • 如果函数调用语句总是调用相同版本的虚函数,调用虚拟成员函数所花费的时间比调用非虚拟成员函数所花费的时间,只多几个时钟周期。
  • 如果虚函数版本发生变化,那么可能会得到10到20个时钟周期的错误预测惩罚。(意味着主要性能成本来自流水线预测失败)

为了效率的话,尽量使用编译时多态,或者使用模板,但是要权衡模板编程的复杂性。

14.4运行时类型识别(RTTI)

运行时类型标识会向所有类对象添加额外信息,其效率不高。 如果编译器有RTTI选项,把它关闭,寻找其它替代方法

14.5继承

编译器对于派生类的对象的实现方式,与包含父类和子类成员的简单类的对象相同。 父类和子类的成员访问同样的快。通常,可以假定使用继承几乎没有任何性能损失。

由于以下原因,代码缓存性能可能会略有下降:

  • 父类数据成员的大小将添加到子类成员的偏移量中。 访问总偏移量大于127字节的数据成员的代码,会稍微变得不那么紧凑。
  • 父和子的成员函数通常存储在不同的模块中。 这可能会导致大量跳转,代码缓存效率降低。 解决这个问题的方法是,确保彼此相邻调用的函数也存储在彼此附近。

14.6构造函数和析构函数

构造函数在内部实现为成员函数,该函数返回该对象的引用。 新对象的内存分配不一定由构造函数自身完成。 因此,构造函数与任何其他成员函数效率没有区别。 此结论适用于默认构造函数,拷贝构造函数和任何其它构造函数。

一个类可以不需要构造函数。

  • 如果对象不需要初始化,可以没有默认构造函数。
  • 如果只需复制所有数据成员就可以复制对象,则不需要拷贝构造函数。

简单的构造函数可以被内联,以提高性能。

当通过赋值,函数参数或函数返回值来复制对象时,拷贝构造函数都可能被调用。 如果拷贝构造函数涉及内存或其他资源的分配,可能要花费一定时间。 有多种方法可以避免这种内存复制浪费,例如:

  • 使用指向对象的引用或指针,而不是复制对象本身。
  • 使用“移动构造函数”来转移内存块的所有权。这需要一个支持C++ 0x的编译器。
  • 创建一个类的成员函数,或者友元函数,或者运算符,将内存块的所有权从一个对象转移到另一个对象。 失去内存块所有权的对象应将其指针设置为NULL。 当然应该有一个析构函数来销毁对象拥有的任何内存块。

析构函数与成员函数效率相同。如果没有必要,不要写析构函数。 虚拟析构函数与虚拟成员函数效率相同。

补充:移动构造函数

移动构造函数以及左值右值是cpp11标准引入的新标准,通过std::move()可以避免复制内存,提高效率。

首先介绍左值和右值,字面意义上,左值既能出现在等号左边,也能出现在等号右边。右值只能出现在等号右边。左值指指向对象的表达式,指在内存中占据地址的对象,右值是不在内存中某个可识别位置的对象的表达式,左值和右值的根本区别在于能否取内存地址。

举个栗子

1
2
int var;//左值
var = 4;//var是左值,4是右值

赋值操作需要左操作数是一个左值。var 是一个有内存位置的对象,因此它是左值。然而,下面的写法则是错的:

1
2
4 = var;       // 错误!不能将左值赋给右值
(var + 1) = 4; // 错误!(var+1)也是右值,不能将右值赋给另一个右值

接下来说明下左值引用和右值引用。

左值引用和右值引用都是引用,只不过一个用作左值,一个只能用作右值即只能给别的变量赋值。

1
2
int &a=2;//左值引用
int &&b=a;//右值引用

接下来说会移动构造函数,在cpp中通过一个已有对象初始化另一个对象要通过复制构造函数,而当对象中有指针类型的成员变量时,复制构造函数执行的是深拷贝,这是低效且占用内存空间的,而如果不深拷贝的话,多个指针指向同一个地址会造成对该内存空间的多次释放。由此引入了移动构造函数,即将原对象的内存空间移动给新对象,同时将原对象的指针为NULL,避免重复释放同一块内存地址。

移动构造函数的代码实现就是将右值引用作为参数

1
2
3
4
5
6
7
demo(demo &&d):num(d.num){//右值引用
d.num = NULL;
cout<<"move construct!"<<endl;
}
demo(const demo &d):num(new int(*d.num)){//左值引用
cout<<"copy construct!"<<endl;
}

15.unions

 共用体,也叫联合体,在一个“联合”内可以定义多种不同的数据类型, 一个被说明为该“联合”类型的变量中,允许装入该“联合”所定义的任何一种数据,这些数据共享同一段内存,以达到节省空间的目的。union变量所占用的内存长度等于最长的成员的内存长度。

16.模板

模板类似宏,在编译前模板的参数被简单的被值替代。

使用模板传参比直接函数传参要更高效,当然,可读性会变差。

1
2
3
4
5
6
7
8
9
10
11
int Multiply (int x, int m) {
return x * m;
}
template <int m>
int MultiplyBy (int x) {
return x * m;
}
int a, b;
a = Multiply(10,8);
b = MultiplyBy<8>(10);
//下面的函数更高效

使用模板实现多态

先举个栗子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// Example 7.47b. 使用模板实现编译时多态
// 把非多态函数放入祖父类中:
class CGrandParent {
public:
void NotPolymorphic();
};

// 所有要调用多态函数的函数放入父类。
// 子类类名作为父类的模板参数:
template <typename MyChild>
class CParent : public CGrandParent {
public:
void Hello() {
cout << "Hello ";
// call polymorphic child function:
(static_cast<MyChild*>(this))->Disp();
}
};

// 多个子类,每个子类实现一个版本的函数
class CChild1 : public CParent<CChild1> {
public:
void Disp() {
cout << 1;
}
};

class CChild2 : public CParent<CChild2> {
public:
void Disp() {
cout << 2;
}
};

void test () {
CChild1 Object1; CChild2 Object2;
CChild1 * p1;
p1 = &Object1;
p1->Hello(); // Writes "Hello 1"
CChild2 * p2;
p2 = &Object2;
p2->Hello(); // Writes "Hello 2"
}

使用模板实现多态的原理就是将子类类型作为模板的参数填入,然后直接将this指针转为子类类型,从而能够直接调用子类的方法,这是静态多态,提高了效率。可读性仍然降低。 如果编译器能够自动去虚拟化(devirtualization),依靠编译器进行优化的方法,肯定比使用复杂的模板方法更方便。

17.线程

在优化多线程应用程序时,我们必须考虑多线程的四种成本:

  • 启动和停止线程的成本。
    • 如果任务的持续时间短于启动和停止线程所需的时间,则不要将任务放入单独的线程中。
  • 任务切换的成本。
    • 如果具有相同优先级的线程数不超过CPU核心数,则此成本最低。
  • 线程之间同步和通信的成本。
    • 信号量,互斥量等的开销很大。如果两个线程经常相互等待以便访问同一资源,那么最好将它们连接到一个线程中。
    • 必须将多个线程之间共享的变量声明为volatile。这可以防止编译器把该变量进行优化消失。
  • 不同的线程需要单独存储。
    • 多个线程使用的函数或类都不应该依赖静态或全局变量。(参见第28页的线程本地存储)
    • 线程有自己的堆栈。如果线程共享相同的缓存,则可能导致缓存争用。

多线程程序必须使用线程安全函数。 线程安全函数永远不应该使用静态变量。

18.异常

异常处理旨在检测很少发生的错误,并从错误中恢复,只要不发生错误,异常处理就不会占用额外的时间,但是一旦出现异常,程序就需要大量时间来进行恢复。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class C1 {
public:
...
~C1();
};
void F1() {
C1 x;
...
}
void F0() {
try {
F1();
}
catch (...) {
...
}
}

我们可以以上述代码为例讲解,F1方法创建了C1对象,F0方法又调用F1方法,当F0调用F1时出现异常,执行跳出F1,程序没有返回,就无法清理已经分配的内存(比如C1对象x),所以F1必须保存所有相关信息才能保证能够清理垃圾。

如果F1调用另一个函数,该函数又调用另一个函数等,并且如果在最里面的函数中发生异常,则异常处理程序需要有关函数调用链的所有信息,并且它需要通过函数调用向后跟踪路径,检查所有必要的清理工作。 这称为堆栈展开。

所有函数都必须为异常处理程序保存一些信息,即使没有异常发生。 这就是为什么在某些编译器中异常处理可能代价高昂的原因。 如果你的应用程序异常处理不是必需的,你应该禁用它,这样代码更小,也更高效。

  • 你可以通过关闭编译器中的异常处理选项,来禁用整个程序的异常处理。

  • 你可以通过向函数原型添加throw()来禁用单个函数的异常处理:

    1
    void F1() throw();

C++11推荐使用 noexceptthrow()已经过时。

这允许编译器假定F1永远不会抛出任何异常,因此它不必保存函数F1的恢复信息。 然而,如果F1调用另一个可能抛出异常的函数F2,则F1必须检查F2抛出的异常,并在F2实际抛出异常的情况下调用std::unexpected()函数。 因此,只有当F1调用的所有函数都使用throw()限定符时,才能把throw()应用于F1throw()对库函数很有用。