C++代码优化方法总结 |
优化是一个非常大的主题,本文并不是去深入探讨性能分析理论,算法的效率,况且我也没有这个能力。我只是想把一些可以简单的应用到你的C++代码中的优化技术总结在这里,这样,当你遇到几种不同的编程策略的时候,就可以对每种策略的性能进行一个大概的估计。这也是本文的目的之所在。 目录: 六. 最后的求助一. 优化之前 在进行优化之前,我们首先应该做的是发现我们代码的瓶颈(bottleneck)在哪里。然而当你做这件事情的时候切忌从一个debug-version进行推断,因为debug-version中包含了许多额外的代码。一个debug-version可执行体要比release-version大出40%。那些额外的代码都是用来支持调试的,比如说符号的查找。大多数实现都为debug-version和release-version提供了不同的operator new以及库函数。而且,一个release-version的执行体可能已经通过多种途径进行了优化,包括不必要的临时对象的消除,循环展开,把对象移入寄存器,内联等等。 另外,我们要把调试和优化区分开来,它们是在完成不同的任务。 debug-version 是用来追捕bugs以及检查程序是否有逻辑上的问题。release-version则是用来做一些性能上的调整以及进行优化。 下面就让我们来看看有哪些代码优化技术吧! 二. 声明的放置程序中变量和对象的声明放在什么位置将会对性能产生显著影响。同样,对postfix和prefix运算符的选择也会影响性能。这一部分我们集中讨论四个问题:初始化v.s 赋值,在程序确实要使用的地方放置声明,构造函数的初始化列表,prefix v.s postfix运算符。 (1) 请使用初始化而不是赋值 (2) 把声明放在合适的位置上 void use() { C c1; if (is_C_Needed() == false) { return; //c1 was not needed } //use c1 here return; } 上面这段代码中对象c1即使在有可能不使用它的情况下也会被创建,这样我们就会为它付出不必要的花费,有可能你会说一个对象c1能浪费多少时间,但是如果是这种情况呢:C c1[1000];我想就不是说浪费就浪费了。但是我们可以通过移动声明c1的位置来改变这种情况: void use() { if (is_C_Needed() == false) { return; //c1 was not needed } C c1; //moved from the block&#8217;s beginning //use c1 here return; } 怎么样,程序的性能是不是已经得到很大的改善了呢?因此请仔细分析你的代码,把声明放在合适的位置上,它所带来的好处是你难以想象的。 (3) 初始化列表 { private: C c_1; C c_2; public: Person(const C& c1, const C& c2 ): c_1(c1), c_2(c2) {} }; 当然构造函数我们也可以这样写: Person::Person(const C& c1, const C& c2){ c_1 = c1; c_2 = c2; } 那么究竟二者会带来什么样的性能差异呢,要想搞清楚这个问题,我们首先要搞清楚二者是如何执行的,先来看初始化列表:数据成员的声明操作都是在构造函数执行之前就完成了,在构造函数中往往完成的只是赋值操作,然而初始化列表直接是在数据成员声明的时候就进行了初始化,因此它只执行了一次copy constructor。再来看在构造函数中赋值的情况:首先,在构造函数执行前会通过default constructor创建数据成员,然后在构造函数中通过operator =进行赋值。因此它就比初始化列表多进行了一次函数调用。性能差异就出来了。但是请注意,如果你的数据成员都是基本类型的话,那么为了程序的可读性就不要使用初始化列表了,因为编译器对两者产生的汇编代码是相同的。 (4) postfix VS prefix 运算符 三. 内联函数 内联函数既能够去除函数调用所带来的效率负担又能够保留一般函数的优点。然而,内联函数并不是万能药,在一些情况下,它甚至能够降低程序的性能。因此在使用的时候应该慎重。 1.我们先来看看内联函数给我们带来的好处:从一个用户的角度来看,内联函数看起来和普通函数一样,它可以有参数和返回值,也可以有自己的作用域,然而它却不会引入一般函数调用所带来的负担。另外,它可以比宏更安全更容易调试。 当然有一点应该意识到,inline specifier仅仅是对编译器的建议,编译器有权利忽略这个建议。那么编译器是如何决定函数内联与否呢?一般情况下关键性因素包括函数体的大小,是否有局部对象被声明,函数的复杂性等等。 2.那么如果一个函数被声明为inline但是却没有被内联将会发生什么呢?理论上,当编译器拒绝内联一个函数的时候,那个函数会像普通函数一样被对待,但是还会出现一些其他的问题。例如下面这段代码: // filename Time.h#include #include using namespace std; class Time 因为成员函数Time::Show()包括一个局部变量和一个for循环,所以编译器一般拒绝inline,并且把它当作一个普通的成员函数。但是这个包含类声明的头文件会被单独的#include进各个独立的编译单元中: // filename f1.cpp#include &#8220;Time.hj&#8221; void f1() { Time t1; t1.Show(); } // filename f2.cpp 结果编译器为这个程序生成了两个相同成员函数的拷贝: void f1();void f2(); int main() { f1(); f2(); return 0; } 当程序被链接的时候,linker将会面对两个相同的Time::Show()拷贝,于是函数重定义的连接错误发生。但是老一些的C++实现对付这种情况的办法是通过把一个un-inlined函数当作static来处理。因此每一份函数拷贝仅仅在自己的编译单元中可见,这样链接错误就解决了,但是在程序中却会留下多份函数拷贝。在这种情况下,程序的性能不但没有提升,反而增加了编译和链接时间以及最终可执行体的大小。 但是幸运的是,新的C++标准中关于un-inlined函数的说法已经改变。一个符合标准C++实现应该只生成一份函数拷贝。然而,要想所有的编译器都支持这一点可能还需要很长时间。 另外关于内联函数还有两个更令人头疼的问题。第一个问题是该如何进行维护。一个函数开始的时候可能以内联的形式出现,但是随着系统的扩展,函数体可能要求添加额外的功能,结果内联函数就变得不太可能,因此需要把inline specifier去除以及把函数体放到一个单独的源文件中。另一个问题是当内联函数被应用在代码库的时候产生。当内联函数改变的时候,用户必须重新编译他们的代码以反映这种改变。然而对于一个非内联函数,用户仅仅需要重新链接就可以了。 这里想要说的是,内联函数并不是一个增强性能的灵丹妙药。只有当函数非常短小的时候它才能得到我们想要的效果,但是如果函数并不是很短而且在很多地方都被调用的话,那么将会使得可执行体的体积增大。最令人烦恼的还是当编译器拒绝内联的时候。在老的实现中,结果很不尽人意,虽然在新的实现中有很大的改善,但是仍然还是不那么完善的。一些编译器能够足够的聪明来指出哪些函数可以内联哪些不能,但是,大多数编译器就不那么聪明了,因此这就需要我们的经验来判断。如果内联函数不能增强行能,就避免使用它! 四. 优化你的内存使用通常优化都有几个方面:更快的运行速度,有效的系统资源使用,更小的内存使用。一般情况下,代码优化都是试图在以上各个方面进行改善。重新放置声明技术被证明是消除多余对象的建立和销毁,这样既减小了程序的大小又加快了运行速度。然而其他的优化技术都是基于一个方面&#8212;&#8212;更快的速度或者是更小的内存使用。有时,这些目标是互斥的,压缩了内存的使用往往却减慢了代码速度,快速的代码却又需要更多的内存支持。下面总结两种在内存使用上的优化方法: 1. Bit Fields 在我们的现实生活中,一个数据的许多位都被浪费了,因为某些应用根本就不会有那么大的数据范围。也许你会说,bit是如此之小,通过它就能减小存储空间的使用吗?的确,在数据量很小的情况下不会看出什么效果,但是在数据量惊人的情况下,它所节省的空间还是能够让我们的眼睛为之一亮的。也许你又会说,现在内存和硬盘越来越便宜,何苦要费半天劲,这省不了几个钱。但是还有另外一个原因一定会使你信服,那就是数字信息传输。一个分布式数据库都会在不同的地点有多份拷贝。那么数百万的纪录传输就会显得十分昂贵。Ok,现在我们就来看看该如何做吧,首先看下面这段代码: struct BillingRec{ long cust_id; long timestamp; enum CallType { toll_free, local, regional, long_distance, international, cellular } type; enum CallTariff 上面这个结构体在32位的机器上将会占用16字节,你会发现其中有许多位都被浪费了,尤其是那两个enum型,浪费更是严重,所以请看下面做出的改进: struct BillingRec{ int cust_id: 24; // 23 bits + 1 sign bit int timestamp: 24; enum CallType {//&#8230; }; enum CallTariff {//&#8230; }; unsigned call: 3; unsigned tariff: 2; }; 现在一个数据从16字节缩减到了8字节,减少了一半,怎么样,效果还是显著的吧:) 2. Unions union { long n; void * p}; // anonymous n = 1000L; // members are directly accessed p = 0; // n is now also 0 不像命名的union,它不能有成员函数以及非public的数据成员。 { private: char * name; long ID; //&#8230; public: PersonalDetails(const char *nm); //key is of type char * used PersonalDetails(long id) : ID(id) {} //numeric key used }; 上面这段代码中就会造成内存的浪费,因为在一个时间只能有一个关键字有效。anonymous union可以在这里使用来减少内存的使用,例如: class PersonalDetails{ private: union //anonymous { char * name; long ID; }; public: PersonalDetails(const char *nm); PersonalDetails(long id) : ID(id) {/**/} // direct access to a member //&#8230; }; 通过使用union,PersonalDetails类的大小被减半。但是这里要说明的是,节省4 个字节内存并不值得引入union所带来的麻烦,除非这个类作为数百万数据库记录的类型或者纪录在一条很慢的通信线路传输。值得注意的是unions并不引入任何运行期负担,所以这里不会有什么速度上的损失。anonymous union的优点就是它的成员可以被直接访问。 在一些对速度要求非常苛刻的应用系统中,每一个CPU周期都是要争取的。这个部分展现了一些简单方法来进行速度优化。 1. 使用类来包裹长的参数列表 把参数列表包裹进一个单独的类中并且通过引用进行传递,这样将会节省很多的时间。当然,如果函数本身就很长,那么建立堆栈的时间就可以忽略了,因此也就没有必要这样做。然而,对于那些执行时间很短而且经常被调用的函数来说,包裹一个长的参数列表在对象中并且通过引用传递将会提高性能。 2. 寄存器变量 { int *p = new int[3000000]; register int *p2 = p; //store the address in a register for (register int j = 0; j<3000000; j++) { *p2++ = 0; } //&#8230;use p delete [] p; } 循环计数是应用寄存器变量的最好的候选者。当它们没有被存入一个寄存器中,大部分的循环时间都被用在了从内存中取出变量和给变量赋新值上。如果把它存入一个寄存器中的话,将会大大减少这种负担。需要注意的是,register specifier仅仅是对编译器的一个建议。就好比内联函数一样,编译器可以拒绝把一个对象存储到寄存器中。另外,现代的编译器都会通过把变量放入寄存器中来优化循环计数。Register storage specifier并不仅仅局限在基本类型上,它能够被应用于任何类型的对象。如果对象太大而不能装进寄存器的话,编译器仍然能够把它放入一个高速存储器中,例如cache。 用register storage specifier声明函数型参将会是建议编译器把实参存入寄存器中而不是堆栈中。例如: void f(register int j, register Date d);3. 把那些保持不变的对象声明为const 4. Virtual function的运行期负担 using namespace std; class V { public: virtual void show() const { cout<<&#8220;I&#8217;m V&#8221;< class W : public V void f(V & v, V *pV) void g() int main() 如果整个程序出现在一个单独的编译单元中,编译器能够对main()中的g()进行内联替换。并且在g()中f()的调用也能够被内联处理。因为传给f()的参数的动态类型能够在编译期被知晓,因此编译器能够把对虚函数的调用静态化。但是不能保证每个编译器都这样做。然而,一些编译器确实能够利用在编译期获得参数的动态类型从而使得函数的调用在编译期间就确定了下来,避免了动态绑定的负担。 5. Function objects VS function pointers 迄今为止为大家展示的优化技术并没有在设计以及代码的可读性上做出妥协。事实上,它们中的一些还提高了软件的稳固性和可维护性。但是在一些对时间和内存有严格限制的软件开发中,上面的技术可能还不够;有可能还需要一些会影响软件的可移植性和扩展性的技术。但是这些技术只能在所有其他的优化技术都被应用但是还不符合要求的情况下使用。 1. 关闭RTTI和异常处理支持 2. 内联汇编 3. 直接和操作系统进行交互 using namespace std; int main() { system(&#8220;dir&#8221;); //execute the &#8220;dir&#8221; command } 注意:这里是在速度和可移植性以及可扩展性之间做出的折衷。 |