《C++应用程序性能优化::第二章C++语言特性的性能分析》学习和理解
说明:《C++应用程序性能优化》 作者:冯宏华等 2007年版。最近出了新版,看了目录,在前面增加了一章的内容,其它的没变。
知识点:分析可能引起性能下降的几个方面:构造函数/析构函数,继承与虚拟,临时对象,内联函数
1、性能瓶颈
很多时候,一个程序的速度在框架设计完成时大致已经确定了,而并非是因为采用了C++语言才使其速度没有达到预期的目标。当遇到性能问题时,首先应该检查和反思程序的总体框架。然后用性能检测工具对其实际运行做准确地测量,再针对瓶颈进行分析和优化,这才是正确的思路。
确实有一些操作或者C++的一些语言特性比其它因素更容易成为程序的瓶颈,一般公认的有如下因素:
(1) 缺页。缺页往往意味着需要访问外部存储。因为外部存储访问相对于访问内存或者代码执行,有数量级的差别。只要有可能,应该尽量减少缺页。
(2) 从堆中动态申请和释放内存:new/malloc,delete/free都是非常耗时的,因此应该尽可能优先考虑从线程栈中获得内存。优先考虑栈而减少从动态堆中申请内存,不止因为在堆中开辟内存比栈慢,还因为要尽量减少缺页。当程序执行时,当前栈空间所在的内存页肯定在物理内存中,因此不会出现缺页;而访问堆中的对象则可能会引起缺页。
(3) 复杂对象的创建和销毁。这往往是一个层次相当深的递归调用,可能会有临时对象在不知不觉中产生。
(4) 函数调用。函数调用有固定的额外开销,当函数体代码量较少时应该采用C++的内联函数。
2、构造函数和析构函数
创建一个对象一般有两种式:一种是从线程运行栈中创建,也称为“局部对象”:ClassA obj;;一种是从全局堆中动态创建:ClassA *p = new ClassA;…delete p;。
局部对象空间的分配在程序进入该对象的作用域时就已经分配好了,一般是通过移动栈指针,当执行到ClassA obj;时,只需要调用构造函数。
动态创建对象,执行Class *p = new ClassA;时会从全局堆中获取对象内存空间,并将地址指针赋值该p。p是栈中的局部对象,p所指向的对象是全局堆中的空间。在全局堆中要销毁对象必须显式调用delete,delete p会调用p所指向对象的析构函数,并将该对象所占的全局堆内存返回给全局堆。
销毁了对象之后,p指针在程序退出p所在作用域之前还是存在于栈中的,此段时间内p指针称为“悬垂指针”或“野指针”,p仍然指向被销毁的对象的位置。在这段时间内,在win32平台中,访问p指针会出现3种情况:
① 第1种情况是该处位置所在的“内存页”没有任何对象,堆管理器已经将其进一步返回给系统,这种错误会导致进程崩溃;
② 第2种情况是该处位置所在的“内存页”还有其他对象,且该处位置被回收后,尚未被分配出去,这时通过指针指针p访问该处内存,取得的值是无意义的,虽然不会立即引起进程崩溃,但是针对该指针的后续操作的行为是不可预测的;
③ 第3种情况是该处位置所在的“内存页”还有其他对象,且该处位置被回收后,已被其他对象申请,这时通过指针p访问该处内存,取得的值其实是程序其他处生成的对象。
创建一个对象分成两个步骤:首先取得对象所需的内存(无论是从线程栈还是从全局堆中),然后在该块内存上执行构造函数。在构造函数构建该对象时,构造函数也分成两个步骤:第一步执行初始化(通过初始化列表),第二步执行构造函数的函数体。(代码举例见后文)
对初始化操作有几点需要注意:
① 构造函数其实是一个递归操作,每层递归内部的操作遵循严格的次序。递归模式为首先执行父类的构造函数,父类构造函数返回后构造该类自己的成员变量。构造该类的成员变量,一是严格按照成员变量在类中的声明顺序进行,而与其在初始化列表中出现的顺序完全无关;二是当有些成员变量或父类对象没有在初始化列表中出现时,它们仍然在初始化操作这一步骤中被初始化。
② 父类对象和一些成员变量没有出现在初始化列表中时,这些对象仍然被执行构造函数,这时执行的是“默认构造函数”。因此这些对象所属的类必须提供可以调用的默认构造函数,为此要求这些类要么自己“显式”地提供默认构造函数,要么不能阻止编译器“隐式”地为其生成一个默认构造函数,定义除默认构造函数之外的其他类型的构造函数就会阻止编译器生成默认构造函数。
③ 对两类成员变量:“常量”(const)和“引用”(reference)。因为所有成员变量在执行构造函数函数体之前已经被构造,即已经拥有初值。所以“常量”和“引用”类型必须在初始化列表中初始化,而不能将其初始化放在构造函数函数体内。
④ 在程序进入构造函数函数体之前,类的父类对象和所有子成员变量对象已经被生成和构造。如果在构造函数体内为其执行赋值操作,显示属于浪费。如果在构造函数时已经知道如何为类的子成员变量初始化,那么应该将这些初始化信息通过构造函数的初始化列表赋予子成员变量,而不是在构造函数函数体中进行这些初始化。因为进入构造函数函数体之前,这些子成员变量已经初始化过一次了。
在C++程序中,创建/销毁对象是影响性能的一个非常突出的操作。首先,如果是从全局堆中生成对象,则需要首先进行动态内存分配操作。众所周知,动态分配/回收在C/C++程序中一直都是非常耗时的。因为牵涉到寻找匹配大小的内存块,找到后可能还需要截断处理,然后还需要修改维护全局堆内存使用情况信息的链表等。因为意识到频繁的内存操作会严重影响性能,所以已经发展出很多技术用来缓解和降低这种影响,例如内存池技术。
尽量少使用值传递,而使用常量引用传递。
3、继承与虚拟函数
虚拟函数是C++语言引入的一个很重要的特性,它提供了“动态绑定”机制,正是这一机制使得继承的语义变得相对清晰。在继承体系中如何声明操作和变量:
① 基类抽象了通用的数据及操作,就数据而言,如果该数据成员在个派生类中都需要用到,那么就需要将其声明在基类中;就操作而言,如果该操作对各派生类都有意义,无论其语义是否会被修改或扩展,都需要将其声明在基类中。
② 有些操作,如果对于各个派生类而言,语义保持完成一致,而无需修改或扩展,那么这些操作就应该声明为基类的非虚拟成员函数。各派生类默认继承了这些非虚拟函数的声明/实现,如同默认继承基类的数据成员一样,而不必另外做任何声明,这就是继承带来的代码重用的优点。
③ 另外还有一些操作,对于各派生类而言都有意义,但是其语义(实现)并不相同。这时,这些操作应该声明为基类的虚拟成员函数。各派生类虽然也默认继承了这些虚拟成员函数的声明/实现,但是语义上它们应该对这些虚拟成员函数的实现进行修改或扩展。另外在实现这些修改或扩展过程中,需要用到额外的该派生类独有的数据时,将这些数据声明为此派生类自己的数据成员。
再考虑使用者对这个继承体系的使用是如何实现多态、模块化的。
当更高层次的程序框架(继承体系的使用者)使用此继承体系时,它处理的是一个抽象层次的对象集合(即基类)。虽然这个对象集合的成员实质上可能是各种派生类对象,但在处理这个对象集合中的对象时,它用的是抽象层次的操作。并不区分这些操作在派生类是否已经做了修改,也是说使用者并不区分哪些是虚函数哪些是非虚函数,也就是说使用者不理会它使用的是继承体系中的哪个层次的类对象。这是因为,当运行时实际执行到各操作时,运行时系统能够识别哪些操作需要用到“动态绑定”,从而找到应该使用哪个类的函数。
因此,对继承体系的使用者而言,此继承体系是“透明的”。使用者不必关心继承体系里边的继承关系是如何的错综复杂,对它而言它所处理的是一致的对象,它只关心自己的业务逻辑。只要继承体系提供的接口没有变化,无论继承体系内部类的层次如何变更,使用者都不需要做任何改变,这使得程序可以模块化,使用者就是一个程序模块,这也意味着可扩展性(继承体系可以在需要的时候添加类)、可维护性(继承体系可以修改内部的实现)、以及代码的可读性(结构更清晰了)得到提高。(代码举例见后文)
虚函数带来的开销:
① 空间:每个支持虚拟函数的类,都有一个虚拟函数表,这个虚拟函数表的大小跟该类拥有的虚拟函数的多少成正比,虚拟函数表属于类所有,无论有多少个对象,都只有一个虚拟函数表。
② 空间:通过支持虚拟函数的类生成的每一个对象都有一个指向该类虚拟函数表的指针。有多少个对象,就有多少个虚拟函数表指针。
③ 时间:支持虚拟函数的类的每一个对象,在构造时,都会初始化虚拟函数表指针,使其指向虚拟函数表。
④ 时间:当通过指针或引用调用虚拟函数时,跟普通函数调用相比,会多一个根据虚拟函数指针找到虚拟函数表的操作。
⑤ 可能无法使用内联函数。因为内联函数是在“编译期”,编译期将调用内联函数的地方用内联函数体的代码代替(内联展开),但是虚拟函数本质上是“运行期”行为。在“编译期”,编译器无法确定要动态绑定的虚函数会绑定到那个函数上,所以无法内联展开。不过,如果在编译时能够确定调用哪个虚函数,那还是可以内联的,只是,这样它就失去了作为虚拟函数的功能。
据书上分析,采用虚拟函数跟不采用虚函数相比带来的负面影响是:虚函数表的空间开销和无法使用内联函数。虚函数表占的空间较小,可以忽略,所以主要缺点是无法使用内联函数。但是不采用虚函数就使得代码可扩展性和可维护性大大降低,而面向对象编程的一个重要目的就是增加程序的可扩展性和可维护性,即当程序的业务逻辑发生改变时,对原程序的修改非常方便。
因此在性能和其他方面特性的选择方面,需要开发人员根据实际情况进行权衡和取舍。当然在权衡之前,需要通过性能检测确认性能的瓶颈是由于虚拟函数没有利用到内联函数的优势这一缺陷引起;否则可以不必考虑虚拟函数的影响。
4、临时对象
这里所说的临时对象是未出现在源码中、不由程序员定义的,从栈中产生的未命名的对象,程序员可能没有注意到它们的存在,它们是由编译器根据需要产生、销毁的。
书上的分析都是以编译器未进行优化为基础分析的,即分析的是VS编译器的debug模式,VS编译器的release模式做了很多的优化,临时对象已经减少了很多。
里边的一条建议确实可以减少临时对象,release模式下也有用:对于非内建类型的对象,尽量将对象的创建延迟到已经确切知道其有效状态时。
例如:(1)ClassA a;
a= f();//f()内部定义局部对象b,返回该局部变量。
(2)ClassA a = f();
上边的两种代码虽然功能一样,但是在release模式下代码(1)会多一次构造函数操作和一次operator=操作。分析如下(代码见后文)。
Release模式下:
(1)代码分析:①构造对象a;②构造f()内的“局部对象”b,release模式下b的地址空间是在外部分配的,所以再f()函数结束后,b对象仍然有效;③调用operator=操作符,把b对象赋值给a对象。
(2)代码分析:①构造f()内的“局部对象”b,release模式下b的地址空间是在外部分配的,就是a对象的空间,所以返回后什么也不需要做。
operator+=跟operator+的比较。operator+=不需要产生临时对象,operator+往往要产生临时对象。所以尽量使用operator+=。
尽量使用++obj,而尽量不使用obj++。因为obj++会产生临时对象用于返回++之前的值。
临时对象的生命周期:从创建开始,到包含创建它的最长语句执行完毕。但有一个例外:当用一个临时对象来初始化一个常量引用时,该临时对象的生命会持续到与绑定到其上的常量引用销毁时。
5、内联函数
内联函数与非内联函数的空间和时间比较。假设调用一个函数之前的准备工作和之后的善后工作的指令所需空间大小为SS,执行这些代码所需时间为TS。
(1)空间。如果一个函数的函数体代码大小为AS,在程序中被调用N次,不采用内联的情况下,空间开销为:SS*N+AS。采用内联:AS*N。因为N一般很大,所以它们之间的比较就是SS跟AS的比较,得出的结论是:如果SS小于AS,不采用内联,空间开销更少。如果AS小于SS,则采用内联,空间开销更少。
(2) 时间。内联之后每次调用不再需要做函数调用的准备和善后工作;内联之后编译器获得更多的代码信息,可以进行更好的代码优化;内联之后可以降低代码“缺页”的几率。不过,如果内联的函数非常大的话,会使得存放代码的页面增多,“缺页”也会相应增加,速度反而下降,所以很大的函数不适合内联。
内联函数其他方面的负面影响:
(1)在一个大的工程中,某个内联函数被多次使用,如果修改了内联函数,那么就要把用到它的所有编译单元都重新编译,可能会花费大量的时间。
(2)如果某开发小组利用了第三方提供的程序库,使用了第三方的内联函数,那么当第三方更新内联函数的实现时,开发小组要使用新的内联函数版本,就必须重新编译,而如果此时开发小组的程序已经发布了,那么要重新编译代价是很高的。而如果不是采用内联函数,那么就可以不必重新编译即可利用新版本(可能是通过更新包含该函数的库实现的)。
不可以使用内联函数的情况:
(1)递归调用。原因1可能不知道会递归多少次,所以无法内联;原因2递归函数展开之后可能非常庞大,内联不合适。
(2)虚函数。原因在前边已经说过。但也存在例外,通过对象调用函数,这种调用在编译时可以确定调用哪个函数,所以可以内联。不过,此时虚函数已经失去了它本来的意义:“通过基类指针或引用调用,到真正运行时才决定调用哪个版本”。
(3)程序入口main()函数肯定不会被内联。
最后说明:inline关键字仅仅是给编译器一个建议,实际上编译器会不会内联完全是它自己做决定。有的函数掉用即便不加上inline关键字,编译器也会根据需要给内联了。
2010-8-22
cs_wuyg@126.com
附测试代码:
1、构造函数是如何执行的.cpp
//构造函数是如何执行的.cpp //2010.8.21 //coder:cs_wuyg@126.com //参考:《C++应用程序性能优化》2.1节 /* 测试说明:通过输出结果可以发现,构造函数执行初始化的顺序:生成父类实例-->生成子类实例;执行初始化列表-->执行构造函数函数体;按照定义的顺序初始化,跟初始化列表顺序无关。 析构函数顺序:析构子类实例-->析构父类实例。 */ //VS2008 #include <iostream> using namespace std; ////////////////////////////////////////////////////////////////////////// class BaseA { public: BaseA(int a = 10) : a(a) { cout << "Base::BaseA(int a)" << "\t" << a << endl; } virtual ~BaseA() { cout << "BaseA::~BaseA()" << endl; } private: int a; }; class BaseAA : public BaseA { public: BaseAA(int b = 10) : BaseA(b),b(b) { cout << "BaseAA::baseAA(int b)" << "\t" << b << endl; } ~BaseAA() { cout << "BaseAA::~BaseAA()" << endl; } private: int b; }; ////////////////////////////////////////////////////////////////////////// class test { public: test(int t = 10) : t(t) { cout << "test::test()" << "\t" << t << endl; } ~test() { cout << "test::~test()" << endl; } private: int t; }; ////////////////////////////////////////////////////////////////////////// class Derived : public BaseAA { public: Derived(int c = 10) : c(c), BaseAA(c), t2(c)//初始化列表中显式的初始化了父类BaseAA,去掉BaseAA(c)后会发现输出的值有变化 { cout << "Derived::Derived(int c)" << "\t" << c << endl; } ~Derived() { cout << "Derived::~Derived()" << endl; } private: int c; test t1; test t2; test t3; }; ////////////////////////////////////////////////////////////////////////// int main() { BaseAA *p = new Derived(30); cout << "----------------------------------------" << endl; delete p; system("pause"); return 0; } /* Base::BaseA(int a) 30 BaseAA::baseAA(int b) 30 test::test() 10 test::test() 30 test::test() 10 Derived::Derived(int c) 30 ---------------------------------------- Derived::~Derived() test::~test() test::~test() test::~test() BaseAA::~BaseAA() BaseA::~BaseA() 请按任意键继续. . . */
2、使用者跟继承体系简单举例.cpp
//使用者跟继承体系简单举例.cpp //2010.8.21 //coder:cs_wuyg@126.com //VS2005/2008 #include <iostream> using namespace std; ///////////////////////////////////////////////////////////////// /*继承体系*/ class Base { public: void common() { cout << "common work" << endl; } virtual void special() = 0; }; class DerivedA : public Base { public: void special() { cout << "Derived A special work" << endl; } }; class DerivedB : public Base { public: void special() { cout << "Derived B special work" << endl; } }; ///////////////////////////////////////////////////////////////// /*使用者*/ class user { public: void usering(Base *p) { p->common();//公有操作 p->special();//专有操作 } }; ///////////////////////////////////////////////////////////////// int main() { DerivedA aobj; DerivedB bobj; user userobj; userobj.usering(&aobj); userobj.usering(&bobj); system("pause"); return 0; } /* common work Derived A special work common work Derived B special work 请按任意键继续. . . */
3、临时对象release模式下临时对象产生的测试.cpp
//临时对象release模式下临时对象产生的测试.cpp //2010.8.21 //coder:cs_wuyg@126.com //VS2005/2008编译器, release模式 #include <iostream> using namespace std; ////////////////////////////////////////////////////// class Base { public: Base() { cout << "constructor" << endl; } Base(const Base& temp) { cout << "copy constructor" << endl; } Base&operator=(const Base& temp) { cout << "operator=" << endl; return *this; } }; ////////////////////////////////////////////////////// Base fun() { cout << "fun()" << endl; Base a; return a; } ////////////////////////////////////////////////////// int main() { //测试1 cout << "----------测试1:" << endl; Base aobj = fun(); //测试2 cout << endl << "----------测试2:" << endl; Base aaobj; aaobj = fun(); system("pause"); return 0; } /* 测试结果1 ----------测试1: fun() constructor ----------测试2: constructor fun() constructor operator= */ /* 通过测试结果可以发现:“对于非内建类型的对象,尽量将对象延迟到已经确切知道其有效状态时。”这句话的正确性。 */
4、临时对象的生命周期.cpp
//临时对象的生命周期.cpp //2010.8.21 //coder:cs_wuyg@126.com //参考《C++应用程序性能优化》2.3节 //VS2005/2008编译器 #include <iostream> #include <string> using namespace std; int main() { string stra = "frist"; string strb = "second"; const char* str; /*测试1*/ cout << "----------测试1:" << endl; if (strlen(str = (stra + strb).c_str()) > 5) { /*输出失败,因为此时(stra + strb).c_str()这个临时变量已经失效了*/ cout << str << endl; } /*测试2*/ cout << "----------测试2:" << endl; const string& strc = stra + strb; cout << strc << endl; return 0; } /* 测试结果: ----------测试1: ----------测试2: fristsecond */ /* 测试表明: 临时对象的生命周期:从创建开始,到包含创建它的最长语句执行完毕。 但有一个例外:当用一个临时对象来初始化一个常量引用时,该临时对象的生命会持续到与绑定到其上的常量引用销毁时。 */