当前位置: 首页 > 科技观察

揭秘代码效率真相_0

时间:2023-03-18 16:03:17 科技观察

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