本文转载自微信公众号「程序喵大人」,作者程序喵大人 。转载本文请联系程序喵大人公众号。
大家好,我是逐渐过气的程序喵。
本篇文章我们将继续分析C++各种操作的效率,包括不同类型变量的存储效率,使用智能指针、循环、函数参数、虚函数、数组等的效率,以及如何做针对性优化,或选择更有效的替代方案。
详细目录看下图:
类和结构体
现今流行面向对象编程,个人也认为这是一种使代码更加清晰和模块化的方法。面向对象编程风格优缺点明显,优点是:
- 如果变量是同一结构体或类的成员,则一起使用的变量也存储在一块,这样数据缓存更有效。
- 类成员变量不需要作为参数传递给类成员函数,省去了参数传递的开销。
缺点是:
- 一些程序员把代码分成太多的小类,没太大必要且效率低。
- 非静态成员函数有一个this指针,它会作为隐式参数传递给函数,这会产生一部分开销,特别是在32位系统中,寄存器是稀缺资源,this指针会占用一个寄存器。
- 虚函数效率较低
类和成员函数的开销其实并没有特别大,如果面向对象风格可以使程序结构更加清晰,我们只要避免在程序最关键的部分使用太多的函数调用,就不要担心它的开销。
类的数据成员
当创建类或结构体的实例时,其数据成员按其声明的顺序连续存储。大多数编译器都会对结构体进行内存对齐,这种对齐可能会在成员大小混合的结构体或类中,造成未使用字节的空洞。
- struct S1 {
- short int a; // 2字节
- // 6个空洞
- double b; // 8
- int d; // 4
- // 4个空洞
- };
- S1 ArrayOfStructures[100];
这里,在a和b之间有6个未使用的字节,因为b必须从一个能被8整除的地址开始。最后还有4个未使用的字节。这样做的原因是,数组中S1的下一个实例必须从一个能被8整除的地址开始,以便将其b成员以8对齐。通过将最小的成员放在最后,未使用的字节数可以减少到2:
- struct S1 {
- double b; // 8
- int d; // 4
- short int a; // 2
- // 2个空洞
- };
- S1 ArrayOfStructures[100];
这种重新排序使结构变小了8个字节,整个数组变小了800个字节。
通过重新排序数据成员,结构对象和类对象通常可以变小。如果不确定一个结构或它的每个成员有多大,可以使用sizeof,它的返回值包括对象末尾的任何未使用的字节。
如果成员相对于结构体或类开头的偏移量小于128,则访问数据成员的代码会更紧凑,因为该偏移量可以表示为8位有符号的数字。如果相对于结构体或类的开头的偏移量是128字节或更大,那么偏移量必须表示为一个32位数字(指令集在8位到32位之间没有偏移量)。例如:
- struct S2 {
- int a[100]; // 400
- int b; // 4
- int ReadB() {return b;}
- };
b的偏移量是400。任何通过指针或成员函数(如ReadB)访问b的代码,都需要将偏移量编码为32位数字。如果交换了a和b,则两者都可以通过编码为8位有符号数字的偏移量来访问,或者根本没有偏移量。这使代码更紧凑,以便更有效地使用Cache。因此,建议在结构或类声明中,大数组和其他大对象排在最后,最常用的数据成员排在前面。如果不能在前128个字节内包含所有数据成员,则将最常用的成员放在前128个字节中。
类的成员函数
每次声明或创建类的新对象时,都会生成数据成员的新实例。但无论多少类的实例,成员函数只有一份。调用成员函数和使用结构指针或引用来调用简单函数一样快。
- struct S3 {
- 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;}
这三个函数Sum1, Sum2和Sum3,做的是完全相同的事情,而且它们的效率是一样的。有些编译器会为这三个函数生成完全相同的代码。Sum1有一个隐式的this指针,它和Sum2和Sum3中的p和r做同样的事情。让函数成为类的成员,还是给它一个指向类或结构的指针或引用,这只是编程风格的问题。一些编译器通过在寄存器中传输this指针,使Sum1在32位Windows中比Sum2和Sum3更有效率。
静态成员函数不能访问任何非静态数据成员或非静态成员函数。静态成员函数比非静态成员函数更快,因为它不需要this指针。如果成员函数,它不需要任何非静态成员访问,可以通过将它们设为静态函数来加快速度。
虚函数
虚函数用于实现运行时多态,多态是面向对象编程相对于非面向对象编程效率低的主要原因之一。如果可以避免虚函数的使用,那可以提高程序的运行效率,一般情况下,可以考虑使用编译时多态替代运行时多态。关于虚函数为什么导致程序效率低,可以看我之前的文章:《》
RTTI
RTTI,运行时类型识别,会向所有类对象添加额外的信息,所以效率不高,可以考虑关闭RTTI选项来提高程序效率。
继承
派生类对象的实现方式,与包含父类和子类成员的简单类对象相同。父类和子类的成员访问速度相同。通常,我们可以认为使用继承几乎不会对性能造成任何损失。
然而这些情况下,效率稍微会有所下降:
- 子类包括父类的所有数据成员,即便子类不需要父类的数据成员。
- 父类数据成员的大小添加到子类成员的偏移量中。访问总偏移量大于127字节的数据成员的代码稍微不那么紧凑。
继承多个父类,可能会导致指向基类指针访问派生类的对象时更复杂。我们可以通过在派生类中创建对象来避免多重继承,即组合替代继承:
- class B1;
- class B2;
- class D : public B1, public B2 {
- public:
- int c;
- };
换成这样:
- class B1;
- class B2;
- class D : public B1 {
- public:
- B2 b2;
- int c;
- };
一般来说,只有当继承对程序的逻辑结构有利时,才应该使用继承。
位域
位可以使数据更加紧凑。访问位成员比访问结构体的普通成员效率低。如果大的数组可以以此来节省代码空间,那么稍微牺牲点效率也未尝不可。
使用<<和|操作组合位字段比单独编写成员更快。例如:
- struct Bitfield {
- int a:4;
- int b:2;
- int c:2;
- };
- Bitfield x;
- int A, B, C;
- x.a = A;
- x.b = B;
- x.c = C;
假设A、B和C的值太小,不会导致溢出,可以通过以下方式改进这段代码:
- union Bitfield {
- struct {
- int a:4;
- int b:2;
- int c:2;
- };
- char abc;
- };
- Bitfield x;
- int A, B, C;
- x.abc = A | (B<<4) | (C<<6);
如果需要防止溢出可以这样:
- x.abc = (A & 0x0F) | ((B & 3) << 4) | ((C & 3) <<6 );
函数重载
重载的函数,只是作为不同的函数来处理,和普通函数相同,没有任何性能代价,可放心使用。
运算符重载
重载运算符等同于函数。使用重载运算符与使用普通函数的效率完全相同。带有多个重载运算符的表达式,会导致为中间结果创建临时对象,这样效率较低。例如:
- class vector {
- public:
- float x, y;
- vector() {}
- vector(float a, float b) {x = a; y = b;}
- vector operator + (vector const &a) {
- return vector(x+a.x, y+a.y);
- }
- };
- vector a, b, c, d;
- a = b + c + d; // 产生了中间对象(b+c)
可以通过加入以下操作来避免为中间结果(b+c)创建临时对象:
- a.x = b.x + c.x + d.x;
- a.y = b.y + c.y + d.y;
在简单情况下,大多数编译器可能会自动进行这种优化。
模板
在编译之前,模板的参数被它们的值所替换,这一点上,模板与宏相似。下面的例子说明了函数参数和模板参数的区别:
- // Example 7.46
- 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);
a和b都会得到值10 * 8 = 80。区别在于m传递到函数的方式。在这个简单函数中,m在运行时从调用方转移到被调用方。但是在模板函数中,m的值在编译时就被替换,这样编译器看到的是常量8而不是变量m。
模板参数相对于使用函数参数的优点是避免了参数传递的开销,缺点是编译器需要为模板参数的每个不同值创建一个模板函数的新实例。如果在这个例子中,用许多不同的因子作为模板参数调用MultiplyBy,那么生成的代码可能会变得非常大。
在上例中,模板函数比简单函数快,因为编译器知道它可以通过使用移位操作乘以2的幂。x*8被替换为x<<3,这样更快。在简单函数的情况下,编译器不知道m的值,因此不能进行优化,除非函数可以内联。(在上面的例子中,编译器能够内联和优化这两个简单的函数函数,将80存于a和b中。但在更复杂的情况下,它可能做不了这种优化)。
模板参数也可以是类型,想必大家也经常使用这种类型不同的模板吧。模板之所以高效,是因为模板参数总是在编译时解析。模板使源代码更复杂,但不会使编译后的代码更复杂。一般来说,使用模板在运行速度方面没有开销。
如果模板参数完全相同,两个或多个模板实例将被连接到一个模板实例中。如果模板参数不同,那么编译器会为每组模板参数生成一个实例。带有许多实例的模板会使编译后的代码变大。模板的过度使用,会使代码难于阅读。如果模板只有一个实例,我们可以使用#define、const或typedef来代替模板形参。
模板可实现编译时多态,在某些情况下,我们可以使用编译时多态替代运行时多态。
线程
线程想必大家都知道,充分利用多核系统的最佳方法,是将任务划分为多个线程。然后,每个线程都可以在自己的CPU内核上运行。
在优化多线程程序时,需要考虑几种开销:
- 启动和停止线程的成本:如果一个任务的执行时间,比它启动和停止线程的时间还要短,那就不要把它放到单独的线程中。
- 上下文切换的成本:如果线程数量不超过CPU的数量,开销最小。
- 线程间同步和通信的成本。信号量、互斥锁等的开销相当大,如果两个线程为了访问同一资源而经常相互等待,那么最好将它们放到一个线程中。在多个线程之间共享的变量须声明为volatile,这可以防止编译器将该变量存储在寄存器中,而该寄存器在线程之间不共享。
异常和错误处理
关于异常和错误处理我之前写过一篇文章你的c++团队还在禁用异常处理吗?,大家可以看看,使用异常来处理错误是个很有效的方法,异常处理目的是以一种优雅的方式检测很少发生的错误,并从错误情况中恢复。使用异常处理有些缺点我们需要知道:
- 打开exception选项会导致程序空间增大10%左右
- 带有try-catch的代码运行效率和普通代码类似,但也肯定没有普通代码效率高(看编译器优化的程度)
- 一旦发生异常,程序运行速度明显下降
某些明确不会产生异常的函数可以考虑加noexcept修饰,让编译器进行最大程度的优化。
如果不需要从错误中恢复,则不需要进行异常处理,建议使用系统的、经过深思熟虑的方法来处理错误。
预处理指令
预处理指令,所有以#开头的指令,在程序性能方面没有任何开销,因为它们是在程序编译之前解析的。
#if指令对于支持使用同一源代码的多个平台或多个配置很有用。#if比if更高效,因为#if在编译时解析,而if在运行时解析。
定义常量时,#define指令等价于const定义。例如,#define ABC 123和const int ABC = 123; 同样有效,因为在大多数情况下,优化编译器可以用整数常量的值替换整数常量。然而,在某些情况下,const int声明可能占用内存空间,而#define指令则不会占用内存空间。使用宏有些时候比普通函数更有效。
命名空间
尽管使用命名空间吧,不用担心,在速度方面没有任何开销。
上面分析了不同操作的效率以及如何针对性做一些优化,在网上我也找到了一个图,图里列出了不同的操作占用的CPU时钟周期:
我们可以仔细看看上图,在编码时选择效率更高的操作。
参考资料
https://www.agner.org/optimize/