C++程序时间性能优化方法

摘要: C++是一种广泛使用且功能灵活的语言,其时间性能难以把握。随着计算机硬件的发展,对程序内存使用的限制越来越弱,但对程序时间性能的要求依然很高,对优化C++程序时间性能的需求仍然存在。有很多方法可以优化C++程序的时间性能。一些方法利用了C++语言自身的特点;一些方法牺牲空间性能换取时间性能的提升。软件工程师应根据具体的应用场景选择适当的方法优化C++程序的时间性能。

关键词:C++; STL;时间性能;优化

中图分类号:TP312 文献标识码:A 文章编号:1009-3044(2015)01-0066-04

Methods to Optimize Time Performance of C++ Application

LIN Guang-dong

(NO. 38th Research Institute of China Electronic Technology Group Corporation, Hefei 230088, China)

Abstract:C++ is a widely used flexible programming language. It is difficult to control its timer performance. As the development of hardware of computing system, the limitation of memory usage is becoming weaker and weaker. However, the demand for time performance is still stringent. It is still necessary to optimize the time performance of C++ application. There are many methods to optimize time performance of C++ application. Some methods make advantage of characteristics of C++ language itself; some methods sacrifice space performance to optimize time performance. Software engineers to optimize time performance should select suitable methods according to specific application scenarios.

Key words: C++; STL; Time Performance; Optimization

C++是一种广泛使用而且功能强大的语言,既支持抽象语言的面向对象编程方式,又支持比较底层的C语言功能,还支持范型编程[1-2]。C++语言已经不仅仅是一种单纯的语言,而是同时支持多种编程语言的编程语言集合。为了支持C++语言灵活的功能,编译器会生成程序中没有显式出现的数据结构和代码[3]。程序设计人员是看不到这些数据结构和代码的。只有经验丰富的软件工程师才能全面了解编译器自动生成代码的时机和条件。因此,C++程序的时间性能和空间性能非常难以把握。

随着硬件技术的发展,计算机硬件(如内存)越来越廉价,已经不是计算的瓶颈。应用程序可以使用的内存空间越来越宽裕,对程序使用内存的限制越来越小。但程序的时间性能在很多应用场景下依然关键,如:实时嵌入式系统、需要处理大量数据的场景、对通信速率要求高的场景、对程序响应速度要求高的场景等等。在这些应用场景中,可以通过牺牲空间性能的方式换来时间性能的提升。

本文介绍了几种提高C++程序时间性能的方法。有些方法利用了C++语言的特点,可以单纯地提高程序的时间性能,如1.1、1.2、1.5、1.7中介绍的方法。有些方法牺牲了程序的空间性能来优化时间性能,如1.3、1.6、1.8.1、1.8.3中介绍的方法。其他方法有些是编程习惯,有些是一种策略,需要软件设计师根据应用场景需要合理使用。

1 时间性能优化方法

1.1 尽可能减少临时对象

临时对象指C++程序中并未显式定义,由编译器生成的对象。临时对象一般在以下场景下出现:

1) 对象作为参数传入函数;

2) 对象作为返回结果由函数返回;

作为一种面向对象的编程语言,C++程序中,大量使用类。每个类都有用户自定义的或编译器默认生成的构造函数和析构函数。这些类的实例作为参数传入函数时,或作为返回结果返回调用者时,都会产生临时对象,并会调用相应的复制构造函数,同时还会调用这些临时对象的析构函数。如下代码所示:

Class A

{String name;

int a;

};

A fun(A a)

{A b;

Return b;}

A类虽然没有定义复制构造函数,但编译器会为A类生成默认的复杂构造函数,该函数调用string类的复杂构造函数来复制name成员变量,并复制成员变量a。fun函数的调用会导致A类的复制构造函数两次被调用。一次是在A类实例作为参数传入fun函数时。一次是在A类实例作为结果返回给调用者时。如果A类的定义比较复杂,例如其中包含大量的成员变量或继承自复杂的基类,则A类复杂构造函数将会调用这些成员变量(或基类)的复制构造函数,导致大量不必要的计算时间浪费。优化的fun函数定义方法是:

Int fun(const A &a, A &b)

{…

Return OK;}

此时,参数a以常量引用的方式使用,不会导致复制构造函数的调用。而返回结果则存储在以引用方式传入的参数b中,同样不会导致复制构造函数的调用。最后,fun函数返回整型作为成功标志或错误码。

1.2 尽可能使用初始化列表

对象的构造函数在对象被创建时调用,完成基类构造、成员变量初始化等初始化工作。在构造函数体内,成员变量实际上已经创建了。如果在构造函数体内对成员变量初始化,相当于对成员变量进行重新赋值。而使用初始化列表,则相当于使用参数初始化成员变量,仅仅调用一次成员变量的构造函数。如下代码:

A::A(int pa, char *p)

{a=pa;

Name=p; (1)

}

当进入A的构造函数体内时,string类成员变量name实际上已经创建了,语句(1) 实际上调用的是string类的重载的“=”操作符函数。这样A类的构造过程中,调用了一次name成员变量的构造函数和“=”重载函数。更高效的代码如下:

A::A(int pa, char *p):a(pa),name(p){};

1.3 合理使用inline

inline函数使编译器在函数调用处用函数体代码代替函数调用指令。在生成的可执行文件中,在调用函数处插入函数体中的代码。这样,程序执行到该函数的调用时,实际上并没有进行调用函数时在函数栈上的进栈出栈等操作,而是直接执行函数体中的代码。

inline函数至少在如下三个方面提高了程序的时间性能:

1) 避免了函数调用必须执行的压栈出栈等操作;

2) 由于函数体代码被移到函数调用处,编译器可以获得更多的上下文信息,并根据上下文信息对调用者的代码和函数体代码进行更进一步的优化;

3) 若不使用inline函数,程序执行至函数调用处,需要转而去执行函数体所在位置的代码。一般函数调用位置与函数代码所在位置在代码段中并不相近,这样很容易形成操作系统的缺页中断。操作系统需要把缺页地址处的代码从硬盘移入内存,所需时间将成数量级增加。而使用inline函数则可以减少缺页中断发生的机会。

由于inline函数在函数调用处插入函数体代码代替函数调用,若该函数在程序的很多位置被调用,有可能造成内存空间的浪费。一般函数的压栈出栈操作也需要一定代码,这段代码完成栈指针调整、参数传递、现场保存和恢复等操作。若函数的函数体代码量小于编译器生成的函数压栈出栈代码,则可以放心地将其定义为inline。在这种情况下使用函数体代码替换函数调用代码反而会减小代码占用的内存空间。而当函数体代码大于函数压栈出栈代码时,将函数定义为inline就会增加内存空间的使用,且函数调用位置越多,这种内存空间浪费就越多。但若某个函数在应用程序中被高频度地调用,已经成为影响程序时间性能的瓶颈,则可以牺牲程序的空间性能,将该函数定义为inline,以提高其调用时间性能。C++程序应该根据应用的具体场景,根据函数体的大小、调用位置多少、函数调用的频率、应用场景对时间性能的要求、应用场景对内存性能的要求等各方面因素,合理决定是否定义inline函数。

1.4 谨慎使用虚函数

虚函数是C++语言最重要的特性之一,它使得运行时多态成为可能。很多基于C++语言的设计模式都是基于虚函数实现的。虚函数统一了一个基类的不同子类的接口,使子类的实现者和使用者可以分别独立设计。虚函数虽然有助于设计,但是它会带来额外的内存负担和时间负担。每一个定义虚函数的类都需要有一张虚函数表,用以存储虚函数指针。每个该类的实例都带有一个虚函数表指针,指向该类的虚函数表。调用虚函数时,首先通过虚函数表指针找到虚函数表,然后在虚函数表中找虚函数地址,最后再利用该地址调用找到的虚函数。显然,虚函数的时间性能不如非虚函数。因此,应用程序应当综合考虑虚函数对于设计的益处和对时间性能的负担。当虚函数调用成为系统时间性能的瓶颈时,如非必要,尽量减少虚函数的使用。

1.5 合理使用智能指针

指针是C++程序中常用的类型。若合理使用指针,既可以节省内存,也可以节省运行时间。例如,若我们需要把很多复杂的信息存储在程序内部。我们把一个信息单元存储在一个类中,然后把该类的对象存储在某种STL容器中。若直接存储对象,一方面会使一个信息单存储在内存中多个对象中,造成内存空间的浪费;另一方面对象从容器中存入、取出时都会造成大量的运行时间浪费。而如果存储对象的指针,则一个信息单元只需要一个对象存储,且指针从容器中存入和取出的时间性能更高。但指针有一个致命的缺陷:容易造成内存泄露,不利于软件设计。软件工程师必须准确控制指针在何时何处被释放。若指针没有释放,会造成内存泄露;若同一个指针被多次释放,程序会产生异常。

C++的开源程序库Boost库中定义了智能指针,包括普通智能指针shared_ptr和智能数组指针shared_array[4]。智能指针实际上是一个类,其中成员变量包括普通指针和其引用计数。引用记数记录程序引用该指针的次数。该类可以在指针不再被使用时自动将其释放。C++11标准已经把智能指针加入到标准中。智能指针既具有普通指针节省内存和时间的优点,又将软件工程师从控制指针的释放这一繁重任务中解脱出来。因此,程序中涉及到对象的存储、转发等应用时,应合理使用智能指针。

1.6 使用vector前先reserve

Vector是一种常见的STL容器,其内部使用连续内存储存数据。若连续内存不足以存储数据时,vector对象会开辟新的连续内存,并把原内存中的实例复制到新内存中。若vector容器中存储的是类,复制过程中会调用该类的复制构造函数。Vector容器的size()方法返回容器中元素的个数,而capacity()方法返回容器当前内存可以容纳的元素的个数。不同的编译器对vector每次开辟内存的大小定义不同。有些编译器定义为原来大小的2倍,有些是1.5倍。例如,Visual Stdio 2008环境下自带的STL,每次vector调整内存大小为原来的1.5倍。若连续地调用push_back向vector中存储数据,则通过capacity()得到的vector可以存储的元素个数以0、1、2、3、4、6、9。。。的规律递增。显然,每次vector调整内存大小,都会调用一次所有元素的复制构造函数。例如,当容器大小由1调整为2时,需要调用其中的第一个元素的复制构造函数,以使其存储在新的位置。若vector容器中存储的是复杂的类,则vector容器在放入最初10个元素的过程中将会引起25次不必要的复制构造函数调用。

Vector的reserve方法可以预先为其分配内存。只有当该内存被填满时,才会分配新的内存,并调用元素的复制构造函数。因此,在使用vector之前,若能根据应用具体场景判断其存储元素的大致个数,应先调用reserve函数,分配一定内存,以避免不必要的复制构造函数调用。

例如,如下程序:

Vector v;//v是存储类A的vector容器

For(int i=0;i<10;i++)

{A a;

初始化a;

v.push_back(a);

}

应改为:

Vector v;

v.reserve(10);

For(int i=0;i<10;i++)

{A a;

初始化a;

v.push_back(a);

}

当A是一个复杂的类时,后者的时间性能将显著优于前者。

1.7 尽可能使用STL的成员函数

STL容器定义了很多常用的操作,如插入、赋值等等,如assign、insert、erase、empty、resize、count等等。软件工程师应熟悉这些成员函数的含义并尽可能使用它们。STL容器的成员函数不仅使程序看起来简洁清晰,还可以提高效率。这些成员函数会针对容器的内部存储结构,对操作进行优化。

若STL容器成员函数操作的对象是一定区间内的一系列元素,这种成员函数称为区间成员函数[5]。区间成员函数包括:区间构造函数、区间插入、区间删除、区间赋值等等。完成同样的功能,区间成员函数的时间性能显著高于每次针对容器中一个元素进行操作以完成相同功能的一段代码。例如,把数组中存储的m个数据插入到大小为n的vector的头部,若针对每个数据单独使用一次insert操作,共需调用n次insert函数。每次插入都需要移动vector内的全部数据,共需移动约n*m次。而若使用insert区间插入成员函数,则只需要调用1次insert函数,该函数直接根据插入数据的多少把vector中的数据移动至最终位置,共需移动约n次。显然,使用区间插入成员函数插入时间性能更好。

1.8 设计良好的数据结构

应用程序是由数据结构和算法组成的。算法是在数据结构的基础上对数据进行操作的方法,因此,设计良好的数据结构是高效率程序的基础。C++标准库提供了很多高效的数据结构,如vector、list、map、set等等。软件工程师应熟悉这些数据结构。合理使用STL容器可以减少软件工程师的开发负担,提高程序的时间性能。

1.8.1 合理选择使用vector和map

vector相当于大小可变的数组,用于存储数量不固定的一组数据。map适合存储需要由关键词定位到数据的信息。但并不是所有需要由关键词定位到数据的信息都适合用map存储。若关键词是整数且关键词的范围和数据量的大小差不多,则可以把数据存储到vector中,关键词经过转换后作为vector的下标访问数据。由下标访问vector中的数据需要的时间复杂度是O(1),而map由关键词定位到数据的时间复杂度是O(logn)。显然,当可以由关键词转换为整数下标时,应使用vector存储数据。

1.8.2 合理选择使用vector和list

vector中的数据存储在连续的内存中,而list的底层数据结构是链表。list中的数据是分散在内存中的,相邻的数据通过指针建立索引。vector和list各有优缺点。

vector的优点:vector中的数据存储在连续的内存中,可以由数据序号直接定位至数据,由下标定位至数据的时间复杂度是O(1);

vector的缺点:若需要在一组数据的中间插入或删除数据,则需要移动该位置以后的所有数据,时间复杂度为O(n);

list的优点:插入数据时,只需要修改相邻数据的指针即可,时间复杂度为O(1)。

list的缺点:由于list中数据之间通过指针链接,无法通过下标定位数据,定位一个数据的时间复杂度是O(n);

软件工程师应根据应用场景的具体需求,合理选择list或vector。若需要频繁地在一组数据的开头和结尾插入和删除数据,应使用list。若需要在一个容器中存储一组数据,存储完成之后不再增加删除,但需要频繁地根据下标访问这组数据,则应使用vector。

1.8.3 熟悉散列容器

C++11标准之前,标准库中没有定义散列容器。但很多STL的实现了散列容器hash_map与hash_set。Boost库中也定义了hash_map与hash_set[4]。与普通的map和set使用红黑树的底层数据结构不同,散列容器使用哈希表来存储数据,并对常用的数据类型定义了默认的哈希算法。C++11标准库中定义了散列容器unodered_map和unordered_set。散列容器的访问时间复杂度是O(1),优于普通map和set。

1.9 使用更好的算法

适合应用场景的数据结构是高效率程序的基础,而优化的算法则可以进一步提高程序的时间性能。在确定一个适合应用场景的数据结构后,软件工程师应根据应用场景和应用程序的特点,设计高效率的算法。

1.9.1 合理使用STL算法

C++标准库定义了很多常用的算法,合理使用这些算法既可以减少工作量,又可以减少出错的机会。C++标准库中的算法包括:查找、计数、遍历、排序、集合算法等等。通常情况下,使用这些成熟的算法比从无到有重新编写这些算法的时间性能要高。

1.9.2 使用函数对象

在使用C++标准库中的算法时,常常需要提供一个供算法使用的函数。如排序需要一个比较大小的函数,遍历算法需要一个对数据进行操作的函数。提供函数的方法有两种:一种是直接提供函数地址(或存储函数地址的函数指针);另一种是提供函数对象。函数对象是重载了“()”运算符的对象。若使用inline方式重载“()”运算符,使用函数对象的时间性能要优于使用函数地址。使用函数地址时,STL算法看不到函数的具体内容,只能通过指针的方式调用算法,无法进行任何代码优化。而使用函数对象,STL算法可以看到函数对象的类型,并且具有了inline函数提高时间性能的优点:一方面STL算法内部不需要函数调用,另一方面编译器可以根据算法的上下文信息对代码进行优化。因此,使用STL算法时,应尽量用函数对象替换函数地址。

2 结论

本文介绍了几种优化C++程序时间性能的方法。有些方法对所有的C++程序都适用;有些方法需要根据具体应用场景选择是否使用。要提高C++程序的时间性能,最重要的还是找到影响程序时间性能的关键代码,分析程序的具体应用场景,结合编程语言的特点,才能找到合适的方法。

参考文献:

[1] 李普曼,拉乔伊,默.C++ Primer中文版[M].王刚,杨巨峰,译.5版.北京:电子工业出版社,2013.

[2] 梅耶斯.Effective C++ 中文版[M].侯捷,译.3版.北京:电子工业出版社,2011.

[3] 冯宏华,徐莹,程远,等,左边.C++应用程序性能优化[M].2版.北京:电子工业出版社,2010.

[4] 罗剑锋. Boost程序库完全开发指南——深入C++“准”标准库[M].2版.北京:电子工业出版社,2013.

[5] 梅耶斯.Effective STL中文版[M].潘爱民,陈铭,邹开红,译.北京:科学出版社,2013.

推荐访问:优化 性能 时间 程序 方法