实用经验 100 改善C++程序运行效率的措施

大多数开发人员通常都有这个观点,即汇编语言和 C 语言适合用来编写对性能要求非常高的程序。而 C++ 语言的主要应用范围是编写复杂度非常高的程序,但是对性能要求不是那么严格的程序。但是事实往往并非如此,很多时候,一个程序的速度在框架设计完成时大致已经确定了,而并非是因为采用了C++语言才使其速度没有达到预期的目标。因此当一个程序的性能需要提高时,首先需要做的是用性能检测工具对其运行的时间分布进行一个准确的测量,找出关键路径和真正的瓶颈所在,然后针对瓶颈进行分析和优化,而不是一味盲目地将性能低劣归咎于所采用的语言。事实上,如果框架设计不做修改,即使用C语言或者汇编语言重新改写,也并不能保证提高总体性能。

因此当遇到性能问题时,首先检查和反思程序的总体框架。然后用性能检测工具对其实际运行做准确地测量,再针对瓶颈进行分析和优化,这才是正确的思路。但不可否认的是,确实有一些操作或者C++的一些语言特性比其他因素更容易成为程序的瓶颈。

C++容易引起瓶颈的因素

  • 缺页:缺页往往意味着需要访问外部存储。因为外部存储访问相对于访问内存或者代码执行,有数量级的差别。因此只要有可能,应该尽量想办法减少缺页。
  • 从堆中动态申请和释放内存:如C语言中的malloc/free和C++语言中的new/delete操作非常耗时,因此要尽可能优先考虑从线程栈中获得内存。优先考虑栈而减少从动态堆中申请内存,不仅仅是因为在堆中开辟内存比在栈中要慢很多,而且还与"尽量减少缺页"这一宗旨有关。当执行程序时,当前栈帧空间所在的内存页肯定在物理内存中,因此程序代码对其中变量的存取不会引起缺页;相反,从堆中生成的对象,只有指向它的指针在栈上,对象本身却是在堆中。堆一般来说不可能都在物理内存中,而且因为堆分配内存的特性,即使两个相邻生成的对象,也很有可能在堆内存位置上相隔很远。因此当访问这两个对象时,虽然分别指向它们指针都在栈上,但是通过这两个指针引用它们时,很有可能会引起两次“缺页”。
  • 复杂对象的创建和销毁:这往往是一个层次相当深的递归调用,因为一个对象的创建往往只需要一条语句,看似很简单。另外,编译器生成的临时对象因为在程序的源代码中看不到,更是不容易察觉,因此尤其值得警惕和关注。本章中专门有两节分别讲解对象的构造和析构,以及临时对象。
  • 函数调用:因为函数调用有固定的额外开销,因此当函数体的代码量相对较少,且该函数被非常频繁地调用时,函数调用时的固定额外开销容易成为不必要的开销。C语言的宏和C++语言的内联函数都是为了在保持函数调用的模块化特征基础上消除函数调用的固定额外开销而引入的,因为宏在提供性能优势的同时也给开发和调试带来了不便。在C++中更多提倡的是使用内联函数

构造函数和析构函数的特点是当创建对象时,自动执行构造函数;当销毁对象时,析构函数自动被执行。创建一个对象一般有两种方式,一种是从线程运行栈中创建,也称为“局部对象”,一般语句为:

{
	……
    Object obj;             ①
    ……
}

销毁这种对象并不需要程序显式地调用析构函数,而是当程序运行出该对象所属的作用域时自动调用。比如上述程序中在①处创建的对象obj在②处会自动调用该对象的析构函数。在这种方式中,对象obj 的内存在程序进入该作用域时,编译器生成的代码已经为其分配(一般都是通过移动栈指针),①句只需要调用对象的构造函数即可。②处编译器生成的代码会调用该作用域内所有局部的用户自定义类型对象的析构函数,对象obj属于其中之一,然后通过一个退栈语句一次性将空间返回给线程栈。

另一种创建对象的方式为从全局堆中动态创建,一般语句为:

 {
	……
    Object* obj = new Object;   ①
    ……
    delete obj;                 ②
    …… 
 }

当执行①句时,指针obj所指向对象的内存从全局堆中取得,并将地址值赋给obj。执行②句后,指针obj所指向的对象确实已被销毁。但是指针obj却还存在于栈中,直到程序退出其所在的作用域。即执行到③处时,指针obj才会消失。需要注意的是,指针obj的值在②处至③处之间,仍然指向刚才被销毁的对象的位置,这时使用这个指针是危险的。

虚拟函数是C++语言引入的一个很重要的特性,它提供了“动态绑定”机制,正是这一机制使得继承的语义变得相对明晰。

对继承体系的使用者而言,继承体系内部的多样性是“透明的”。它不必关心其继承细节,处理的就是一组对它而言整体行为一致的“对象”。即只需关心它自己问题域的业务逻辑,只要保证正确,其任务就算完成了。即使继承体系内部增加了某种派生类,或者删除了某种派生类,或者某某派生类的某个虚拟函数的实现发生了改变,它的代码不必任何修改。这也意味着,程序的模块化程度得到了极大的提高。而模块化的提高也就意味着可扩展性、可维护性,以及代码的可读性的提高,这也是“面向对象”编程的一个很大的优点。

虚拟函数的“动态绑定”特性虽然很好,但也有其内在的空间以及时间开销,每个支持虚拟函数的类(基类或派生类)都会有一个包含其所有支持的虚拟函数指针的“虚拟函数表”(virtual table)。另外每个该类生成的对象都会隐含一个“虚拟函数指针”(virtual pointer),此指针指向其所属类的“虚拟函数表”。当通过基类的指针或者引用调用某个虚拟函数时,系统需要首先定位这个指针或引用真正对应的"对象"所隐含的虚拟函数指针。“虚拟函数指针”,然后根据这个虚拟函数的名称,对这个虚拟函数指针所指向的虚拟函数表进行一个偏移定位,再调用这个偏移定位处的函数指针对应的虚拟函数,这就是“动态绑定”的解析过程(当然C++规范只需要编译器能够保证动态绑定的语义即可,但是目前绝大多数的C++编译器都是用这种方式实现虚拟函数的)。通过分析,不难发现虚拟函数的开销。

虚函数开销

  • 空间:每个支持虚拟函数的类,都有一个虚拟函数表,这个虚拟函数表的大小跟该类拥有的虚拟函数的多少成正比,此虚拟函数表对一个类来说,整个程序只有一个,而无论该类生成的对象在程序运行时会生成多少个。
  • 空间:通过支持虚拟函数的类生成的每个对象都有一个指向该类对应的虚拟函数表的虚拟函数指针,无论该类的虚拟函数有多少个,都只有一个函数指针,但是因为与对象绑定,因此程序运行时因为虚拟函数指针引起空间开销跟生成的对象个数成正比。
  • 时间:通过支持虚拟函数的类生成的每个对象,当其生成时,在构造函数中会调用编译器在构造函数内部插入的初始化代码,来初始化其虚拟函数指针,使其指向正确的虚拟函数表。
  • 时间:当通过指针或者引用调用虚拟函数时,跟普通函数调用相比,会多一个根据虚拟函数指针找到虚拟函数表的操作。

内联函数:因为内联函数常常可以提高代码执行的速度,因此很多普通函数会根据情况进行内联化,但是虚拟函数无法利用内联化的优势,这是因为内联函数是在“编译期”编译器将调用内联函数的地方用内联函数体的代码代替(内联展开),但是虚拟函数本质上是“运行期”行为,本质上在"编译期"编译器无法知道某处的虚拟函数调用在真正执行的时候会调用到那个具体的实现(即在“编译期”无法确定其绑定),因此在“编译期”编译器不会对通过指针或者引用调用的虚拟函数进行内联化。也就是说,如果想利用虚拟函数的"动态绑定"带来的设计优势,那么必须放弃“内联函数”带来的速度优势。

因此在性能和其他方面特性的选择方面,需要开发人员根据实际情况进行权衡和取舍。当然在权衡之前,需要通过性能检测确认性能的瓶颈是由于虚拟函数没有利用到内联函数的优势这一缺陷引起;否则可以不必考虑虚拟函数的影响。

临时对象在C++语言中的特征是未出现在源代码中,从堆栈中产生的未命名对象。这里需要特别注意的是,临时对象并不出现在源代码中。即开发人员并没有声明要使用它们,没有为其声明变量。它们由编译器根据情况产生,而且开发人员往往都不会意识到它们的产生。

产生临时对象一般来说有如下两种场合

  • 当实际调用函数时传入的参数与函数定义中声明的变量类型不匹配。
  • 当函数返回一个对象时(这种情形下也有例外,下面会讲到)。

很多开发人员认为当函数传入参数为对象,并且实际调用时因为函数体内的该对象实际上并不是传入的对象,而是该传入对象的一份拷贝,所以认为这时函数体内的那个拷贝的对象也应该是一个临时对象。

在C++语言的设计中,内联函数的引入可以说完全是为了性能的考虑。因此在编写对性能要求比较高的C++程序时,非常有必要仔细考量内联函数的使用。所谓“内联”,即将被调用函数的函数体代码直接地整个插入到该函数被调用处,而不是通过call语句进行。当然,编译器在真正进行"内联"时,因为考虑到被内联函数的传入参数、自己的局部变量,以及返回值的因素,不仅仅只是进行简单的代码拷贝,还需要做很多细致的工作,但大致思路如此。开发人员可以有两种方式告诉编译器需要内联哪些类成员函数,一种是在类的定义体外;一种是在类的定义体内。

内联的实现方式:

  • 当在类的定义体外时,需要在该成员函数的定义前面加"inline"关键字,显式地告诉编译器该函数在调用时需要“内联”处理,

  • 当在类的定义体内且声明该成员函数时,同时提供该成员函数的实现体。此时,"inline"关键字并不是必需的,

  • 当普通函数(非类成员函数)需要被内联时,则只需要在函数的定义时前面加上"inline"关键字。

从内联,即用函数体代码替代对该函数的调用这一本质看,它与C语言中的函数宏(macro)极其相似,但是它们之间也有本质的区别。即内联是编译期行为,宏是预处理期行为,其替代展开由预处理器来做。也就是说编译器看不到宏,更不可能处理宏。另外宏的参数在其宏体内出现两次或两次以上时经常会产生副作用,尤其是当在宏体内对参数进行++或操作时,而内联不会。还有,预处理器不会也不能对宏的参数进行类型检查。而内联因为是编译器处理的,因此会对内联函数的参数进行类型检查,这对于写出正确且鲁棒的程序,是一个很大的优势。最后,宏肯定会被展开,而用inline关键字修饰的函数不一定会被内联展开。

提醒

  • 一个程序的惟一入口main()函数肯定不会被内联化。
  • 编译器合成的默认构造函数、拷贝构造函数、析构函数,以及赋值运算符一般都会被内联化。

请谨记

  • 深刻理解影响C++性能的各种因素。在编程时小心这些因素给你带来的负面影响。
  • 谨慎的使用C++的继承、多态等高级特性。他们在给带来便利的同时,也会你带来性能的缺失。
  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值