级别: 中级 冯 宏华, 高级软件工程师, IBM 中国开发中心 2007 年 11 月 29 日 本章从 C++ 的一些语言特性来分析影响性能的方面。 本书主要针对的是 C++ 程序的性能优化,深入介绍 C++ 程序性能优化的方法和实例。全书由 4 个篇组成,第 1 篇介绍 C++ 语言的对象模型,该篇是优化 C++ 程序的基础;第 2 篇主要针对如何优化 C++ 程序的内存使用;第 3 篇介绍如何优化程序的启动性能;第 4 篇介绍了三类性能优化工具,即内存分析工具、性能分析工具和 I/O 检测工具,它们是测量程序性能的利器。 在此我们推出了此书的第 2、6 章供大家在线浏览。更多推荐书籍请访问 developerWorks 图书频道。
|
大 多数开发人员通常都有这个观点,即汇编语言和 C 语言适合用来编写对性能要求非常高的程序。而 C++ 语言的主要应用范围是编写复杂度非常高的程序,但是对性能要求不是那么严格的程序。但是事实往往并非如此,很多时候,一个程序的速度在框架设计完成时大致 已经确定了,而并非是因为采用了C++语言才使其速度没有达到预期的目标。因此当一个程序的性能需要提高时,首先需要做的是用性能检测工具对其运行的时间 分布进行一个准确的测量,找出关键路径和真正的瓶颈所在,然后针对瓶颈进行分析和优化,而不是一味盲目地将性能低劣归咎于所采用的语言。事实上,如果框架 设计不做修改,即使用C语言或者汇编语言重新改写,也并不能保证提高总体性能。
因此当遇到性能问题时,首先检查和反思程序的总体框架。然后用性能检测工具对其实际运行做准确地测量,再针对瓶颈进行分析和优化,这才是正确的思路。
但不可否认的是,确实有一些操作或者C++的一些语言特性比其他因素更容易成为程序的瓶颈,一般公认的有如下因素。
(1)缺页:如第四章中所述,缺页往往意味着需要访问外部存储。因为外部存储访问相对于访问内存或者代码执行,有数量级的差别。因此只要有可能,应该尽量想办法减少缺页。
(2)从堆中动态申请和释放内存:如C语言中的malloc/free和C++语言中的new/delete操作非常耗时,因此要尽可能优先考虑从 线程栈中获得内存。优先考虑栈而减少从动态堆中申请内存,不仅仅是因为在堆中开辟内存比在栈中要慢很多,而且还与"尽量减少缺页"这一宗旨有关。当执行程 序时,当前栈帧空间所在的内存页肯定在物理内存中,因此程序代码对其中变量的存取不会引起缺页;相反,从堆中生成的对象,只有指向它的指针在栈上,对象本 身却是在堆中。堆一般来说不可能都在物理内存中,而且因为堆分配内存的特性,即使两个相邻生成的对象,也很有可能在堆内存位置上相隔很远。因此当访问这两 个对象时,虽然分别指向它们指针都在栈上,但是通过这两个指针引用它们时,很有可能会引起两次"缺页"。
(3)复杂对象的创建和销毁:这往往是一个层次相当深的递归调用,因为一个对象的创建往往只需要一条语句,看似很简单。另外,编译器生成的临时对象 因为在程序的源代码中看不到,更是不容易察觉,因此尤其值得警惕和关注。本章中专门有两节分别讲解对象的构造和析构,以及临时对象。
(4)函数调用:因为函数调用有固定的额外开销,因此当函数体的代码量相对较少,且该函数被非常频繁地调用时,函数调用时的固定额外开销容易成为不 必要的开销。C语言的宏和C++语言的内联函数都是为了在保持函数调用的模块化特征基础上消除函数调用的固定额外开销而引入的,因为宏在提供性能优势的同 时也给开发和调试带来了不便。在C++中更多提倡的是使用内联函数,本章会有一节专门讲解内联函数。
|
构造函数和析构函数的特点是当创建对象时,自动执行构造函数;当销毁对象时,析构函数自动被执行。这两个函数分别是一个对象最先和最后被执行的函 数,构造函数在创建对象时调用,用来初始化该对象的初始状态和取得该对象被使用前需要的一些资源,比如文件/网络连接等;析构函数执行与构造函数相反的操 作,主要是释放对象拥有的资源,而且在此对象的生命周期这两个函数都只被执行一次。
创建一个对象一般有两种方式,一种是从线程运行栈中创建,也称为"局部对象",一般语句为:
{ …… Object obj; ① …… } ② |
销毁这种对象并不需要程序显式地调用析构函数,而是当程序运行出该对象所属的作用域时自动调用。比如上述程序中在①处创建的对象obj在②处会自动 调用该对象的析构函数。在这种方式中,对象obj的内存在程序进入该作用域时,编译器生成的代码已经为其分配(一般都是通过移动栈指针),①句只需要调用 对象的构造函数即可。②处编译器生成的代码会调用该作用域内所有局部的用户自定义类型对象的析构函数,对象obj属于其中之一,然后通过一个退栈语句一次 性将空间返回给线程栈。
另一种创建对象的方式为从全局堆中动态创建,一般语句为:
{ …… Object* obj = new Object; ① …… delete obj; ② …… } ③ |
当执行①句时,指针obj所指向对象的内存从全局堆中取得,并将地址值赋给obj。但指针obj本身却是一个局部对象,需要从线程栈中分配,它所指 向的对象从全局堆中分配内存存放。从全局堆中创建的对象需要显式调用delete销毁,delete会调用该指针指向的对象的析构函数,并将该对象所占的 全局堆内存空间返回给全局堆,如②句。执行②句后,指针obj所指向的对象确实已被销毁。但是指针obj却还存在于栈中,直到程序退出其所在的作用域。即 执行到③处时,指针obj才会消失。需要注意的是,指针obj的值在②处至③处之间,仍然指向刚才被销毁的对象的位置,这时使用这个指针是危险的。在 Win32平台中,访问刚才被销毁对象,可能出现3种情况。第1种情况是该处位置所在的"内存页"没有任何对象,堆管理器已经将其进一步返回给系统,此时 通过指针obj访问该处内存会引起"访问违例",即访问了不合法的内存,这种错误会导致进程崩溃;第2种情况是该处位置所在的"内存页"还有其他对象,且 该处位置被回收后,尚未被分配出去,这时通过指针obj访问该处内存,取得的值是无意义的,虽然不会立刻引起进程崩溃,但是针对该指针的后续操作的行为是 不可预测的;第3种情况是该处位置所在的"内存页"还有其他对象,且该处位置被回收后,已被其他对象申请,这时通过指针obj访问该处内存,取得的值其实 是程序其他处生成的对象。虽然对指针obj的操作不会立刻引起进程崩溃,但是极有可能会引起该对象状态的改变。从而使得在创建该对象处看来,该对象的状态 会莫名其妙地变化。第2种和第3种情况都是很难发现和排查的bug,需要小心地避免。
创建一个对象分成两个步骤,即首先取得对象所需的内存(无论是从线程栈还是从全局堆中),然后在该块内存上执行构造函数。在构造函数构建该对象时,构造函数也分成两个步骤。即第1步执行初始化(通过初始化列表),第2步执行构造函数的函数体,如下:
class Derived : public Base { public : Derived() : i(10), string("unnamed") ① { ... ② } ... private : int i; string name; ... }; |
①步中的 ": i(10), string("unnamed")" 即所谓的"初始化列表",以":"开始,后面为初始化单元。每个单元都是"变量名(初始值)"这样的模式,各单元之间以逗号隔开。构造函数首先根据初始化 列表执行初始化,然后执行构造函数的函数体,即②处语句。对初始化操作,有下面几点需要注意。
(1)构造函数其实是一个递归操作,在每层递归内部的操作遵循严格的次序。递归模式为首先执行父类的构造函数(父类的构造函数操作也相应的包括执行 初始化和执行构造函数体两个部分),父类构造函数返回后构造该类自己的成员变量。构造该类自己的成员变量时,一是严格按照成员变量在类中的声明顺序进行, 而与其在初始化列表中出现的顺序完全无关;二是当有些成员变量或父类对象没有在初始化列表中出现时,它们仍然在初始化操作这一步骤中被初始化。内建类型成 员变量被赋给一个初值。父类对象和类成员变量对象被调用其默认构造函数初始化,然后父类的构造函数和子成员变量对象在构造函数执行过程中也遵循上述递归操 作。一直到此类的继承体系中所有父类和父类所含的成员变量都被构造完成后,此类的初始化操作才告结束。
(2)父类对象和一些成员变量没有出现在初始化列表中时,这些对象仍然被执行构造函数,这时执行的是"默认构造函数"。因此这些对象所属的类必须提 供可以调用的默认构造函数,为此要求这些类要么自己"显式"地提供默认构造函数,要么不能阻止编译器"隐式"地为其生成一个默认构造函数,定义除默认构造 函数之外的其他类型的构造函数就会阻止编译器生成默认构造函数。如果编译器在编译时,发现没有可供调用的默认构造函数,并且编译器也无法生成,则编译无法 通过。
(3)对两类成员变量,需要强调指出即"常量"(const)型和"引用"(reference)型。因为已经指出,所有成员变量在执行函数体之前 已经被构造,即已经拥有初始值。根据这个特点,很容易推断出"常量"型和"引用"型变量必须在初始化列表中正确初始化,而不能将其初始化放在构造函数体 内。因为这两类变量一旦被赋值,其整个生命周期都不能修改其初始值。所以必须在第一次即"初始化"操作中被正确赋值。
(4)可以看到,即使初始化列表可能没有完全列出其子成员或父类对象成员,或者顺序与其在类中声明的顺序不符,这些成员仍然保证会被"全部"且"严 格地按照顺序"被构建。这意味着在程序进入构造函数体之前,类的父类对象和所有子成员变量对象都已经被生成和构造。如果在构造函数体内为其执行赋初值操 作,显然属于浪费。如果在构造函数时已经知道如何为类的子成员变量初始化,那么应该将这些初始化信息通过构造函数的初始化列表赋予子成员变量,而不是在构 造函数体中进行这些初始化。因为进入构造函数体时,这些子成员变量已经初始化一次。
下面这个例子演示了构造函数的这些重要特性:
#include <iostream> using namespace std; class A { public: A() { cout << "A::A()" << endl; } }; class B : public A { public: B() : j(0) { cout << "B::B()" << endl; } private: int j; }; class C1 { public: C1(int i) : a(i) { cout << "C1::C1()" << endl; } private: int a; }; class C2 { public: C2(double val) : d(val) { cout << "C2::C2()" << endl; } private: double d; }; class C3 { public: C3(int v = 0) : j(v) { cout << "C3::C3()" << endl; } private: int j; }; class D : public B { public: D(double v2,int v1):c2(v2),c1(v1){cout<< "D::D()"<<endl;} ② private: C1 c1; C2 c2; C3 c3; }; int main() { D d(1.0, 3); ① return 0; } |
在这段代码中,类D继承自类B,类B继承自类A。然后类D中含有3个成员变量对象c1、c2和c3,分别为类型C1、C2和C3。
此段程序的输出为:
A::A() ③ B::B() ④ C1::C1() ⑤ C2::C2() ⑥ C3::C3() ⑦ D::D() ⑧ |
可以看到,①处调用D::D(double,int)构造函数构造对象d,此构造函数从②处开始引起了一连串的递归构造。从输出可以验证递归操作的如下规律。
(1)递归从父类对象开始,D的构造函数首先通过"初始化"操作构造其直接父类B的构造函数。然后B的构造函数先执行"初始化"部分,该"初始化" 操作构造B的直接父类A,类A没有自己的成员需要初始化,所以其"初始化"不执行任何操作。初始化后,开始执行类A的构造函数,即③的输出。
(2)构造类A的对象后,B的"初始化"操作执行初始化类表中的j(0)对j进行初始化。然后进入B的构造函数的函数体,即④处输出的来源。至此类 B的对象构造完毕,注意这里看到初始化列表中并没有"显式"地列出其父类的构造函数。但是子类在构造时总是在其构造函数的"初始化"操作的最开始构造其父 类对象,而忽略其父类构造函数是否显式地列在初始化列表中。
(3)构造类B的对象后,类D的"初始化"操作接着初始化其成员变量对象,这里是c1,c2和c3。因为它们在类D中的声明顺序就是c1 -> c2 -> c3,所以看到它们也是按照这个顺序构造的,如⑤,⑥,⑦ 3处输出所示。注意这里故意在初始化列表中将c2的顺序放在了c1的前面,c3甚至都没有列在初始化列表中。但是输出显示了成员变量的初始化严格按照它们 在类中的声明顺序进行,而忽略其是否显式地列在初始化列表中,或者显示在初始化列表中的顺序如何。应该尽量将成员变量初始化列表中出现的顺序与其在类中声 明的顺序保持一致,因为如果使用一个变量的值来初始化另外一个变量时,程序的行为可能不是开发人员预想的那样,比如:
class Object { public: Object() : v2(5), v1(v2 * 3) { … } private: int v1, v2; } |
这段程序的本意应该是首先将v2初始化为5,然后用v2的值来初始化v1,从而v1=15。然而通过验证,初始化后的v2确实为5,但v1则是一个 非常奇怪的值(在笔者的电脑上输出是12737697)。这是因为实际初始化时首先初始化v1,这时v2还尚未正确初始化,根据v2计算出来的v1也就不 是一个合理的值了。当然除了将成员变量在初始化列表中的顺序与其在类中声明的顺序保持一致之外,最好还是避免在初始化列表中用某个成员变量的值初始化另外 一个成员变量的值。
(4)随着c1、c2和c3这3个成员变量对象构造完毕,类D的构造函数的"初始化"操作部分结束,程序开始进入其构造函数的第2部分。即执行构造函数的函数体,这就是⑧处输出的来源。
析构函数的调用与构造函数的调用一样,也是类似的递归操作。但有两点不同,一是析构函数没有与构造函数相对应的初始化操作部分,这样析构函数的主要 工作就是执行析构函数的函数体;二是析构函数执行的递归与构造函数刚好相反,而且在每一层的递归中,成员变量对象的析构顺序也与构造时刚好相反。
正是因为在执行析构函数时,没有与构造函数的初始化列表相对应的列表,所以析构函数只能选择成员变量在类中声明的顺序作为析构的顺序参考。因为构造 函数选择了自然的正序,而析构函数的工作又刚好与其相反,所以析构函数选择逆序。因为析构函数只能用成员变量在类中的声明顺序作为析构顺序(要么正序,要 么逆序),这样使得构造函数也只能选择将这个顺序作为构造的顺序依据,而不能采用初始化列表中的作为顺序依据。
与构造函数类似,如果操作的对象属于一个复杂继承体系中的末端节点,那么其析构函数也是十分耗时的操作。
因为构造函数/析构函数的这些特性,所以在考虑或者调整程序的性能时,也必须考虑构造函数/析构函数的成本,在那些会大量构造拥有复杂继承体系对象的大型程序中尤其如此。下面两点是构造函数/析构函数相关的性能考虑。
(5)在C++程序中,创建/销毁对象是影响性能的一个非常突出的操作。首先,如果是从全局堆中生成对象,则需要首先进行动态内存分配操作。众所周 知,动态内存分配/回收在C/C++程序中一直都是非常费时的。因为牵涉到寻找匹配大小的内存块,找到后可能还需要截断处理,然后还需要修改维护全局堆内 存使用情况信息的链表等。因为意识到频繁的内存操作会严重影响性能的下降,所以已经发展出很多技术用来缓解和降低这种影响,比如后续章节中将说明的内存池 技术。其中一个主要目标就是为了减少从动态堆中申请内存的次数,从而提高程序的总体性能。当取得内存后,如果需要生成的目标对象属于一个复杂继承体系中末 端的类,那么该构造函数的调用就会引起一长串的递归构造操作。在大型复杂系统中,大量此类对象的创建很快就会成为消耗CPU操作的主要部分。因为注意和意 识到对象的创建/销毁会降低程序的性能,所以开发人员往往对那些会创建对象的代码非常敏感。在尽量减少自己所写代码生成的对象同时,开发人员也开始留意编 译器在编译时"悄悄"生成的一些临时对象。开发人员有责任尽量避免编译器为其程序生成临时对象,下面会有一节专门讨论这个问题。语义保持完全一致。
(6)已经看到,如果在实现构造函数时,没有注意到执行构造函数体前的初始化操作已经将所有父类对象和成员变量对象构造完毕。而在构造函数体中进行 第2次的赋值操作,那么也会浪费很多的宝贵CPU时间用来重复计算。这虽然是小疏忽,但在大型复杂系统中积少成多,也会造成程序性能的显著下降。
减少对象创建/销毁的一个很简单且常见的方法就是在函数声明中将所有的值传递改为常量引用传递,比如下面的函数声明:
int foo( Object a); |
应该相应改为:
int foo( const Object& a ); |
因为C/C++语言的函数调用都是"值传递",因此当通过下面方式调用foo函数时:
Object a; ① ... int i = foo(a); ② |
②处函数foo内部引用的变量a虽然名字与①中创建的a相同,但并不是相同的对象,两个对象"相同"的含义指其生命周期的每个时间点所指的是内存中 相同的一块区域。这里①处的a和②处的a并不是相同的对象,当程序执行到②句时,编译器会生成一个局部对象。这个局部对象利用①处的a拷贝构造,然后执行 foo函数。在函数体内部,通过名字a引用的都是通过①处a拷贝构造的复制品。函数体内所有对a的修改,实质上也只是对此复制品的修改,而不会影响到①处 的原变量。当foo函数体执行完毕退出函数时,此复制品会被销毁,这也意味着对此复制品的修改在函数结束后都被丢失。
通过下面这段程序来验证值传递的行为特征:
#include <iostream> using namespace std; class Object { public: Object(int i = 1) { n = i; cout << "Object::Object()" << endl; } Object(const Object& a) { n = a.n; cout << "Object::Object(const Object&)" << endl; } ~Object() { cout << "Object::~Object()" << endl; } void inc() { ++n; } int val() const { return n; } private: int n; }; void foo(Object a) { cout << "enter foo, before inc(): inner a = " << a.val() << endl; a.inc(); cout << "enter foo, after inc(): inner a = " << a.val() << endl; } int main() { Object a; ① cout << "before call foo : outer a = " << a.val() << endl; foo(a); ② cout << "after call foo : outer a = " << a.val() << endl; ③ return 0; } |
输出为:
Object::Object() ④ before call foo : outer a = 1 Object::Object(const Object&) ⑤ enter foo, before inc(): inner a = 1 ⑥ enter foo, after inc(): inner a = 2 ⑦ Object::~Object() ⑧ after call foo : outer a = 1 ⑨ Object::~Object() |
可以看到,④处的输出为①处对象a的构造,而⑤处的输出则是②处foo(a)。调用开始时通过构造函数生成对象a的复制品,紧跟着在函数体内检查复 制品的值。输出与外部原对象的值相同(因为是通过拷贝构造函数),然后复制品调用inc()函数将值加1。再次打印出⑦处的输出,复制品的值已经变成了 2。foo函数执行后需要销毁复制品a,即⑧处的输出。foo函数执行后程序又回到main函数中继续执行,重新打印原对象a的值,发现其值保持不变(⑨ 处的输出)。
重新审视foo函数的设计,既然它在函数体内修改了a。其原意应该是想修改main函数的对象a,而非复制品。因为对复制品的修改在函数执行后被" 丢失",那么这时不应该传入Object a,而是传入Object& a。这样函数体内对a的修改,就是对原对象的修改。foo函数执行后其修改仍然保持而不会丢失,这应该是设计者的初衷。
如果相反,在foo函数体内并没有修改a。即只对a执行"读"操作,这时传入const Object& a是完全胜任的。而且还不会生成复制品对象,也就不会调用构造函数/析构函数。
综上所述,当函数需要修改传入参数时,如果函数声明中传入参数为对象,那么这种设计达不到预期目的。即是错误的,这时应该用应用传入参数。当函数不 会修改传入参数时,如果函数声明中传入参数为对象,则这种设计能够达到程序的目的。但是因为会生成不必要的复制品对象,从而引入了不必要的构造/析构操 作。这种设计是不合理和低效的,应该用常量引用传入参数。
下面这个简单的小程序用来验证在构造函数中重复赋值对性能的影响,为了放大绝对值的差距,将循环次数设置为100 000:
#include <iostream> #include <windows.h> using namespace std; class Val { public: Val(double v = 1.0) { for(int i = 0; i < 1000; i++) d[i] = v + i; } void Init(double v = 1.0) { for(int i = 0; i < 1000; i++) d[i] = v + i; } private: double d[1000]; }; class Object { public: Object(double d) : v(d) {} ① /*Object(double d) ② { v.Init(d); }*/ private: Val v; }; int main() { unsigned long i, nCount; nCount = GetTickCount(); for(i = 0; i < 100000; i++) { Object obj(5.0); } nCount = GetTickCount() - nCount; cout << "time used : " << nCount << "ms" << endl; return 0; } |
类Object中包含一个成员变量,即类Val的对象。类Val中含一个double数组,数组长度为1 000。Object在调用构造函数时就知道应为v赋的值,但有两种方式,一种方式是如①处那样通过初始化列表对v成员进行初始化;另一种方式是如②处那 样在构造函数体内为v赋值。两种方式的性能差别到底有多大呢?测试机器(VC6 release版本,Windows XP sp2,CPU为Intel 1.6 GHz内存为1GB)中测试结果是前者(①)耗时406毫秒,而后者(②)却耗时735毫秒,如图2-1所示。即如果改为前者,可以将性能提高 44.76%。
图2-1 两种方式的性能对比
从图中可以直观地感受到将变量在初始化列表中正确初始化,而不是放置在构造函数的函数体内。从而对性能的影响相当大,因此在写构造函数时应该引起足够的警觉和关注。
|
虚拟函数是C++语言引入的一个很重要的特性,它提供了"动态绑定"机制,正是这一机制使得继承的语义变得相对明晰。
(1)基类抽象了通用的数据及操作,就数据而言,如果该数据成员在各派生类中都需要用到,那么就需要将其声明在基类中;就操作而言,如果该操作对各派生类都有意义,无论其语义是否会被修改或扩展,那么就需要将其声明在基类中。
(2)有些操作,如果对于各个派生类而言,语义保持完全一致,而无需修改或扩展,那么这些操作声明为基类的非虚拟成员函数。各派生类在声明为基类的 派生类时,默认继承了这些非虚拟成员函数的声明/实现,如同默认继承基类的数据成员一样,而不必另外做任何声明,这就是继承带来的代码重用的优点。
(3)另外还有一些操作,虽然对于各派生类而言都有意义,但是其语义并不相同。这时,这些操作应该声明为基类的虚拟成员函数。各派生类虽然也默认继 承了这些虚拟成员函数的声明/实现,但是语义上它们应该对这些虚拟成员函数的实现进行修改或者扩展。另外在实现这些修改或扩展过程中,需要用到额外的该派 生类独有的数据时,将这些数据声明为此派生类自己的数据成员。
再考虑更大背景下的继承体系,当更高层次的程序框架(继承体系的使用者)使用此继承体系时,它处理的是一个抽象层次的对象集合(即基类)。虽然这个 对象集合的成员实质上可能是各种派生类对象,但在处理这个对象集合中的对象时,它用的是抽象层次的操作。并不区分在这些操作中,哪些操作对各派生类来说是 保持不变的,而哪些操作对各派生类来说有所不同。这是因为,当运行时实际执行到各操作时,运行时系统能够识别哪些操作需要用到"动态绑定",从而找到对应 此派生类的修改或扩展的该操作版本。
也就是说,对继承体系的使用者而言,此继承体系内部的多样性是"透明的"。它不必关心其继承细节,处理的就是一组对它而言整体行为一致的"对象"。 即只需关心它自己问题域的业务逻辑,只要保证正确,其任务就算完成了。即使继承体系内部增加了某种派生类,或者删除了某种派生类,或者某某派生类的某个虚 拟函数的实现发生了改变,它的代码不必任何修改。这也意味着,程序的模块化程度得到了极大的提高。而模块化的提高也就意味着可扩展性、可维护性,以及代码 的可读性的提高,这也是"面向对象"编程的一个很大的优点。
下面通过一个简单的实例来展示这一优点。
假设有一个绘图程序允许用户在一个画布上绘制各种图形,如三角形、矩形和圆等,很自然地抽象图形的继承体系,如图2-2所示。
图2-2 图形的继承体系
这个图形继承体系的设计大致如下:
class Shape { public: Shape(); virtual ~Shape(); virtual void Draw(); virtual void Rotate(); private: ... }; class Triangle : class Shape { public: Triangle(); ~Triangle(); void Draw(); void Rotate(int angle); ... }; class Circle : class Shape { public: Circle(); ~ Circle(); void Draw(); void Rotate(int angle); ... }; class Rectangle : class Shape { public: Rectangle(); ~ Rectangle(); void Draw(); void Rotate(int angle); ... }; |
为简单起见,让每个Shape对象都支持"绘制"和"旋转"操作,每个Shape的派生类对这两个操作都有自己的实现:
void Triangle::Draw() { ... } void Circle::Draw() { ... } void Rectangle::Draw() { ... } void Triangle::Rotate(int angle) { ... } void Circle::Rotate(int angle) { ... } void Rectangle::Rotate(int angle) { ... } |
再来考虑这个图形继承体系的使用,这里很自然的一个使用者是画布,设计其类名为"Canvas":
public Canvas { public: Canvas(); ~Canvas(); void Paint(); void RotateSelected(int angle); ... private: ShapeList shapes; }; ... void Canvas::Paint() { while(shapes.GetNext()) { Shape* sh = shapes.GetNext(); sh->Draw(); ① shapes.Next(); } ... } void RotateSelected(int angle) { Shape* select_shape = GetCurrentSelected(); if(select_shape) select_shape->Rotate(angle); ② ... } |
Canvas类中维护一个包含所有图形的shapes,Canvas类在处理自己的业务逻辑时并不关心shapes实际上都是哪些具体的图形;相 反,如①处和②处所示,它只将这些图形作为一个抽象,即Shape。在处理每个Shape时,调用每个Shape的某个操作即可。
这样做的一个好处是当图形继承体系发生变化时,作为图形继承体系的使用者Canvas而言,它的改变几乎没有,或者很小。
比如说,在程序的演变过程中发现需要支持多边型(Polygon)和贝塞尔曲线(Bezier)类型,只需要在图形继承体系中增加这两个新类型即可:
class Polygon : class Shape { public: Polygon(); ~Polygon(); void Draw(); void Rotate(int angle); ... }; void Polygon::Draw() { ... } void Polygon::Rotate(int angle) { ... } class Bezier : class Shape { public: Bezier(); ~Bezier(); void Draw(); void Rotate(int angle); ... }; void Bezier::Draw() { ... } void Bezier::Rotate(int angle) { ... } |
而不必修改Canvas的任何代码,程序即可像以前那样正常运行。同理,如果以后发现不再支持某种类型,也只需要将其从图形继承体系中删除,而不必 修改Canvas的任何代码。可以看到,从对象继承体系的使用者(Canvas)的角度来看,它只看到Shape对象,而不必关心到底是哪一种特定的 Shape,这是面向对象设计的一个重要特点和优点。
虚拟函数的"动态绑定"特性虽然很好,但也有其内在的空间以及时间开销,每个支持虚拟函数的类(基类或派生类)都会有一个包含其所有支持的虚拟函数 指针的"虚拟函数表"(virtual table)。另外每个该类生成的对象都会隐含一个"虚拟函数指针"(virtual pointer),此指针指向其所属类的"虚拟函数表"。当通过基类的指针或者引用调用某个虚拟函数时,系统需要首先定位这个指针或引用真正对应的"对象 "所隐含的虚拟函数指针。"虚拟函数指针",然后根据这个虚拟函数的名称,对这个虚拟函数指针所指向的虚拟函数表进行一个偏移定位,再调用这个偏移定位处 的函数指针对应的虚拟函数,这就是"动态绑定"的解析过程(当然C++规范只需要编译器能够保证动态绑定的语义即可,但是目前绝大多数的C++编译器都是 用这种方式实现虚拟函数的),通过分析,不难发现虚拟函数的开销:
- 空间:每个支持虚拟函数的类,都有一个虚拟函数表,这个虚拟函数表的大小跟该类拥有的虚拟函数的多少成正比,此虚拟函数表对一个类来说,整个程序只有一个,而无论该类生成的对象在程序运行时会生成多少个。
- 空间:通过支持虚拟函数的类生成的每个对象都有一个指向该类对应的虚拟函数表的虚拟函数指针,无论该类的虚拟函数有多少个,都只有一个函数指针,但是因为与对象绑定,因此程序运行时因为虚拟函数指针引起空间开销跟生成的对象个数成正比。
- 时间:通过支持虚拟函数的类生成的每个对象,当其生成时,在构造函数中会调用编译器在构造函数内部插入的初始化代码,来初始化其虚拟函数指针,使其指向正确的虚拟函数表。
- 时间:当通过指针或者引用调用虚拟函数时,跟普通函数调用相比,会多一个根据虚拟函数指针找到虚拟函数表的操作。
内联函数:因为内联函数常常可以提高代码执行的速度,因此很多普通函数会根据情况进行内联化,但是虚拟函数无法利用内联化的优势,这是因为内联函数 是在"编译期"编译器将调用内联函数的地方用内联函数体的代码代替(内联展开),但是虚拟函数本质上是"运行期"行为,本质上在"编译期"编译器无法知道 某处的虚拟函数调用在真正执行的时候会调用到那个具体的实现(即在"编译期"无法确定其绑定),因此在"编译期"编译器不会对通过指针或者引用调用的虚拟 函数进行内联化。也就是说,如果想利用虚拟函数的"动态绑定"带来的设计优势,那么必须放弃"内联函数"带来的速度优势。
根据上面的分析,似乎在采用虚拟函数时带来和很多的负面影响,但是这些负面影响是否一定是虚拟函数所必须带来的?或者说,如果不采用虚拟函数,是否一定能避免这些缺陷?
还是分析以上图形继承体系的例子,假设不采用虚拟函数,但同时还要实现与上面一样的功能(维持程序的设计语义不变),那么对于基类Shape必须增加一个类型标识成员变量用来在运行时识别到底是哪一个具体的派生类对象:
class Shape { public: Shape(); virtual ~Shape(); int GetType() { return type; } ① void Draw(); ③ void Rotate(); ④ private: int type; ② ... }; |
如①处和②处所示,增加type用来标识派生类对象的具体类型。另外注意这时③处和④处此时已经不再使用virtual声明。
其各派生类在构造时,必须设置具体类型,以Circle派生类为例:
class Circle : class Shape { public: Circle() : type(CIRCLE) {...} ① ~Circle(); void Draw(); void Rotate(int angle); ... }; |
对图形继承体系的使用者(这里是Canvas)而言,其Paint和RotateSelected也需要修改:
void Canvas::Paint() { while(shapes.GetNext()) { Shape* sh = shapes.GetNext(); //sh->Draw(); switch(sh->GetType()) { case(TRIANGLE) ((Triangle*)sh)->Draw(); case(CIRCLE) ((Circle*)sh)->Draw(); case(RECTANGLE) ((Rectangle*)sh)->Draw(); ... } shapes.Next(); } ... } void RotateSelected(int angle) { Shape* select_shape = GetCurrentSelected(); if(select_shape) { //select_shape->Rotate(angle); switch(select_shape->GetType()) { case(TRIANGLE) ((Triangle*)select_shape)->Rotate(angle); case(CIRCLE) ((Circle*)select_shape)->Rotate(angle); case(RECTANGLE) ((Rectangle*)select_shape)->Rotate(angle); ... } } ... } |
因为要实现相同的程序功能(语义),已经看到,每个对象虽然没有编译器生成的虚拟函数指针(析构函数往往被设计为virtual,如果如此,仍然免 不了会隐含增加一个虚拟函数指针,这里假设不是这样),但是还是需要另外增加一个type变量用来标识派生类的类型。构造对象时,虽然不必初始化虚拟函数 指针,但是仍然需要初始化type。另外,图形继承体系的使用者调用函数时虽然不再需要一次间接的根据虚拟函数表找寻虚拟函数指针的操作,但是再调用之 前,仍然需要一个switch语句对其类型进行识别。
综上所述,这里列举的5条虚拟函数带来的缺陷只剩下两条,即虚拟函数表的空间开销及无法利用"内联函数"的速度优势。再考虑虚拟函数表,每一个含有虚拟函数的类在整个程序中只会有一个虚拟函数表。可以想像到虚拟函数表引起的空间开销实际上是非常小的,几乎可以忽略不计。
这样可以得出结论,即虚拟函数引入的性能缺陷只是无法利用内联函数。
可以进一步设想,非虚拟函数的常规设计假如需要增加一种新的图形类型,或者删除一种不再支持的图形类型,都必须修改该图形系统所有使用者的所有与类 型相关的函数调用的代码。这里使用者只有Canvas一个,与类型相关的函数调用代码也只有Paint和RotateSelected两处。但是在一个复 杂的程序中,其使用者很多。并且类型相关的函数调用很多时,每次对图形系统的修改都会波及到这些使用者。可以看出不使用虚拟函数的常规设计增加了代码的耦 合度,模块化不强,因此带来的可扩展性、可维护性,以及代码的可读性方面都极大降低。面向对象编程的一个重要目的就是增加程序的可扩展性和可维护性,即当 程序的业务逻辑发生变化时,对原有程序的修改非常方便。而不至于对原有代码大动干戈,从而降低因为业务逻辑的改变而增加出错的可能性。根据这点分析,虚拟 函数可以大大提升程序的可扩展性及可维护性。
因此在性能和其他方面特性的选择方面,需要开发人员根据实际情况进行权衡和取舍。当然在权衡之前,需要通过性能检测确认性能的瓶颈是由于虚拟函数没有利用到内联函数的优势这一缺陷引起;否则可以不必考虑虚拟函数的影响。
|
从2.1节"构造函数和析构函数"中已经知道,对象的创建与销毁对程序的性能影响很大。尤其当该对象的类处于一个复杂继承体系的末端,或者该对象包 含很多成员变量对象(包括其所有父类对象,即直接或者间接父类的所有成员变量对象)时,对程序性能影响尤其显著。因此作为一个对性能敏感的开发人员,应该 尽量避免创建不必要的对象,以及随后的销毁。这里"避免创建不必要的对象",不仅仅意味着在编程时,主要减少显式出现在源码中的对象创建。还有在编译过程 中,编译器在某些特殊情况下生成的开发人员看不见的隐式的对象。这些对象的创建并不出现在源码级别,而是由编译器在编译过程中"悄悄"创建(往往为了某些 特殊操作),并在适当时销毁,这些就是所谓的"临时对象"。需要注意的是,临时对象与通常意义上的临时变量是完全不同的两个概念,比如下面的代码:
void swap(int *px, int *py) { int temp; ① temp = *px; *px = *py; *py = temp; } |
习惯称①句中的temp为临时变量,其目的是为了暂时存放指针px指向的int型值。但是它并不是这里要考察的"临时对象",不仅仅是因为一般开发 人员不习惯称一个内建类型的变量为"对象"(所以不算临时"对象")。而且因为temp出现在了源码中,这里考察的临时对象并不会出现在源码中。
到底什么才是临时对象?它们在什么时候产生?其生命周期有什么特征?在回答这些问题之前,首先来看下面这段代码:
#include <iostream> #include <cstring> using namespace std; class Matrix { public: Matrix(double d = 1.0) { cout << "Matrix::Matrix()" << endl; for(int i = 0; i < 10; i++) for(int j = 0; j < 10; j++) m[i][j] = d; } Matrix(const Matrix& mt) { cout << "Matrix::Matrix(const Matrix&)" << endl; memcpy(this, &mt, sizeof(Matrix)); } Matrix& operator=(const Matrix& mt) { if(this == &mt) return *this; cout << "Matrix::operator=(const Matrix&)" << endl; memcpy(this, &mt, sizeof(Matrix)); return *this; } friend const Matrix operator+(const Matrix&, const Matrix&); //... private: double m[10][10]; }; const Matrix operator+(const Matrix& arg1, const Matrix& arg2) { Matrix sum; ① for(int i = 0; i < 10; i++) for(int j = 0; j < 10; j++) sum.m[i][j] = arg1.m[i][j] + arg2.m[i][j]; return sum; ② } int main() { Matrix a(2.0), b(3.0), c; ③ c = a + b; ④ return 0; } |
分析代码,③处生成3个Matrix对象a,b,c,调用3次Matrix构造函数。④处调用operator+(const Matrix&, const Matrix&)执行到①处时生成临时变量(注意此处的sum并不是"临时对象"),调用一次Matrix构造函数。④处c = a + b最后将a + b的结果赋值给c,调用的是赋值操作,而不会生成新的Matrix对象,因此从源码分析,此段代码共生成4个Matrix对象。
但是输出结果:
Matrix::Matrix() ① Matrix::Matrix() ② Matrix::Matrix() ③ Matrix::Matrix() ④ Matrix::Matrix(const Matrix&) ⑤ Matrix::operator=(const Matrix&) ⑥ |
①、②、③3处输出分别对应对象a、b和c的构造,④处输出对应的是operator+(const Matrix&, const Matrix&)中sum的构造,⑥处输出对应的是c = a + b句中最后用a + b的结果向c赋值,那么⑤处输出对应哪个对象?
答案是在这段代码中,编译器生成了一个"临时对象"。
a + b实际上是执行operator+(const Matrix& arg1, const Matrix& arg2),重载的操作符本质上是一个函数,这里a和b就是此函数的两个变量。此函数返回一个Matrix变量,然后进一步将此变量通过Matrix:: operator=(const Matrix& mt)对c进行赋值。因为a + b返回时,其中的sum已经结束了其生命周期。即在operator+(const Matrix& arg1, const Matrix& arg2)结束时被销毁,那么其返回的Matrix对象需要在调用a + b函数(这里是main()函数)的栈中开辟空间用来存放此返回值。这个临时的Matrix对象是在a + b返回时通过Matrix拷贝构造函数构造,即⑤处的输出。
既然如上所述,创建和销毁对象经常会成为一个程序的性能瓶颈所在,那么有必要对临时对象产生的原因进行深入探究,并在不损害程序功能的前提下尽可能地规避它。
临时对象在C++语言中的特征是未出现在源代码中,从堆栈中产生的未命名对象。这里需要特别注意的是,临时对象并不出现在源代码中。即开发人员并没有声明要使用它们,没有为其声明变量。它们由编译器根据情况产生,而且开发人员往往都不会意识到它们的产生。
产生临时对象一般来说有如下两种场合。
(1)当实际调用函数时传入的参数与函数定义中声明的变量类型不匹配。
(2)当函数返回一个对象时(这种情形下也有例外,下面会讲到)。
另外,也有很多开发人员认为当函数传入参数为对象,并且实际调用时因为函数体内的该对象实际上并不是传入的对象,而是该传入对象的一份拷贝,所以认 为这时函数体内的那个拷贝的对象也应该是一个临时对象。但是严格说来,这个拷贝对象并不符合"未出现在源代码中"这一特征。当然只要能知道并意识到对象参 数的工作原理及背后隐含的性能特征,并能在编写代码时尽量规避之,那么也就没有必要在字面上较真了,毕竟最终目的是写出正确和高效的程序。
因为类型不匹配而生成临时对象的情况,可以通过下面这段程序来认识:
class Rational { public: Rational (int a = 0, int b = 1 ) : m(a), n(b) {} ① private: int m; int n; }; ... void foo() { Rational r; r = 100; ② ... } |
当执行②处代码时,因为Rational类并没有重载operator=(int i),所以此处编译器会合成一个operator=(const Rational& r)。并且执行逐位拷贝(bitwise copy)形式的赋值操作,但是右边的一个整型常量100并不是一个Rational对象,初看此处无法通过编译。但是,需要注意的一点是C++编译器在 判定这种语句不能成功编译前,总是尽可能地查找合适的转换路径,以满足编译的需要。这里,编译器发现Rational类有一个如①处所示的 Rational(int a=0, int b=1)型的构造函数。因为此构造函数可以接受0、1或2个整数作为参数,这时编译器会"贴心"地首先将②式右边的100通过调用Rational:: Rational(100, 1)生成一个临时对象,然后用编译器合成的逐位拷贝形式的赋值符对r对象进行赋值。②处语句执行后,r对象内部的m为100,n为1。
从上面例子中,可以看到C++编译器为了成功编译某些语句,往往会在私底下"悄悄"地生成很多从源代码中不易察觉的辅助函数,甚至对象。比如上段代码中,编译器生成的赋值操作符、类型转换,以及类型转换的中间结果,即一个临时对象。
很多时候,这种编译器提供的自动类型转换确实提高了程序的可读性,也在一定程度上简化了程序的编写,从而提高了开发速度。但是类型转换意味着临时对 象的产生,对象的创建和销毁意味着性能的下降,类型转换还意味着编译器还需要生成额外的代码等。因此在设计阶段,预计到不需要编译器提供这种自动类型转换 的便利时,可以明确阻止这种自动类型转换的发生,即阻止因此而引起临时对象的产生。这种明确阻止就是通过对类的构造函数增加"explicit"声明,如 上例中的代码,可以通过如下声明来阻止:
class Rational { public: explicit Rational (int a = 0, int b = 1 ) : m(a), n(b) {} ① private: int m; int n; }; ... void foo() { Rational r; r = 100; ② ... } |
此段代码编译时在②处报一个错误,即"binary '=' : no operator defined which takes a right-hand operand of type 'const int' (or there is no acceptable conversion)",这个错误说明编译器无法将100转换为一个Rational对象。编译器合成的赋值运算符只接受Rational对象,而不能 接受整型。编译器要想能成功编译②处语句,要么提供一个重载的"="运算符,该运算符接受整型作为参数;要么能够将整型转换为一个Rational对象, 然后进一步利用编译器合成的赋值运算符。要想将整型转换为一个Rational对象,一个办法就是提供能只传递一个整型作为参数的Rational构造函 数(不一定非要求该构造函数只有一个整型参数,因为考虑到默认值的原因。如上面的例子,Rational的构造函数接受两个整型参数。但是因为都有默认 值,因此调用该构造函数可以有3种方式,即无参、一个参数和两个参数),这样编译器就可以用该整型数作为参数调用该构造函数生成一个Rational对象 (临时对象)。
但是上面没有重载以整型为参数的"="操作符,虽然提供了一个能只传入一个整型作为参数的构造函数,但是用"explicit"限制了此构造函数。 因为explicit的含义是开发人员只能显式地根据这个构造函数的定义调用,而不允许编译器利用其来进行隐式的类型转换。这样编译器无办法利用它来将 100转换为一个临时的Rational对象,②处语句也无法编译。
上面提到,可以通过重载以整型为参数的"="操作符使②处成功编译的目的,看这种方法:
class Rational { public: explicit Rational (int a = 0, int b = 1 ) : m(a), n(b) {} ① Rational& operator=(int a) {m=a; n=1; return *this; } ③ private: int m; int n; }; ... void foo() { Rational r; r = 100; ② ... } |
如③处所示,重载了"="操作符。这样当编译②处时,编译器发现右边是一个整型数,它首先寻找是否有与之匹配的重载的"="操作符。找到③处的声明,及定义。这样它利用③处来调用展开②处为r.Rational::operator=(100),顺利通过编译。
需要指出的是,重载"="操作符后达到了程序想要的效果,即程序的可读性及代码编写的方便性。同时还有一个更重要的效果(对性能敏感的程序而言), 即成功避免了一个临时对象的产生。因为"="操作符的实现,仅仅是修改了被调用对象的内部成员对象,整个过程中都不需要产生临时对象。但是重载"="操作 符也增加了设计类Rational的成本,如果一个类可能会支持多种其他类型对它的转换,则需要进行多次重载,这无疑会使得这个类变得十分臃肿。同样,如 果一个大型程序有很多这样的类,那么因为代码臃肿引起的维护难度也相应会增加。
因此在设计阶段,在兼顾程序的可读性、代码编写时的方便性、性能,以及程序大小和可维护性时,需要仔细分析和斟酌。尤其要对每个类在该应用程序实际运行时的调用次数及是否在性能关键路径上等情况进行预估和试验,然后做到合理的折衷和权衡。
如前所述,还有一种情形往往导致临时对象的产生,即当一个函数返回的是某个非内建类型的对象时。这时因为返回结果(一个对象)必须要有一个地方存 放。所以编译器会从调用该函数的函数栈桢中开辟空间,并用返回值作为参数调用该对象所属类型的拷贝构造函数在此空间中生成该对象。在被调用函数结束并返回 后,可以继续利用此对象(返回值),如:
#include <iostream> using namespace std; class Rational { friend const Rational operator+(const Rational& a, const Rational& b); public: Rational (int a = 0, int b = 1 ) : m(a), n(b) { cout << "Rational::Rational(int,int)" << endl; } Rational (const Rational& r) : m(r.m), n(r.n) { cout << "Rational::Rational(const Rational& r)" << endl; } Rational& operator=(const Rational& r) { if(this == &r) return(*this); m=r.m; n=r.n; cout << "Rational::operator=(const Rational& r)" << endl; return *this; } private: int m; int n; }; const Rational operator+(const Rational& a, const Rational& b) { cout << "operator+() begin" << endl; Rational temp; temp.m = a.m + b.m; temp.n = a.n + b.n; cout << "operator+() end" << endl; return temp; ② } int main() { Rational r, a(10,10), b(5,8); r = a + b; ① return 0; } |
执行①的处语句时,相当于在main函数中调用operator+(const Rational& a, const Rational& b)函数。在main函数栈中会开辟一块Rational对象大小的空间。在operator+(const Rational& a, const Rational& b)函数的②处,函数返回被销毁的temp对象为参数调用拷贝构造函数在main函数栈中开辟的空间中生成一个Rational对象,然后在r=a+b的 "="部分执行赋值运算符操作,输出如下:
Rational::Rational(int,int) Rational::Rational(int,int) Rational::Rational(int,int) operator+() begin Rational::Rational(int,int) operator+() end Rational::Rational(const Rational& r) Rational::operator=(const Rational& r) |
但r在之前的默认构造后并没有用到,此时可以将其生成延迟,如下所示:
#include <iostream> using namespace std; class Rational { friend const Rational operator+(const Rational& a, const Rational& b); public: Rational (int a = 0, int b = 1 ) : m(a), n(b) { cout << "Rational::Rational(int,int)" << endl; } Rational (const Rational& r) : m(r.m), n(r.n) { cout << "Rational::Rational(const Rational& r)" << endl; } Rational& operator=(const Rational& r) { if(this == &r) return(*this); m=r.m; n=r.n; cout << "Rational::operator=(const Rational& r)" << endl; return *this; } private: int m; int n; }; const Rational operator+(const Rational& a, const Rational& b) { cout << "operator+() begin" << endl; Rational temp; temp.m = a.m + b.m; temp.n = a.n + b.n; cout << "operator+() end" << endl; return temp; ② } int main() { Rational a(10,10), b(5,8); Rational r = a + b; ① return 0; } |
这时输出为:
Rational::Rational(int,int) Rational::Rational(int,int) operator+() begin Rational::Rational(int,int) operator+() end Rational::Rational(const Rational& r) |
已经发现,经过简单改写,这段程序竟然减少了一次构造函数和一次赋值操作。为什么?原来改写后,在执行①处时的行为发生了很大的变化。编译器对"= "的解释不再是赋值运算符,而是对象r的初始化。在取得a+b的结果值时,也不再需要在main函数栈桢中另外开辟空间。而是直接使用为r对象预留的空 间,即编译器在执行②处时直接使用temp作为参数调用了Rational的拷贝构造函数对r对象进行初始化。这样,也消除了临时对象的生成,以及原本发 生在①处的赋值运算。
通过这个简单的优化,已经消除了一个临时对象的生成,也减少了一次函数调用(赋值操作符本质上也是一个函数)。这里已经得到一个启示,即对非内建类型的对象,尽量将对象延迟到已经确切知道其有效状态时。这样可以减少临时对象的生成,如上面所示,应写为:
Rational r = a + b。 |
而不是:
Rational r; … r = a + b; |
当然这里有一个前提,即在r = a + b调用之前未用到r,因此不必生成。再进一步,已经看到在operator+(const Rational& a, const Rational& b)实现中用到了一个局部对象temp,改写如下:
#include <iostream> using namespace std; class Rational { friend const Rational operator+(const Rational& a, const Rational& b); public: Rational (int a = 0, int b = 1 ) : m(a), n(b) { cout << "Rational::Rational(int,int)" << endl; } Rational (const Rational& r) : m(r.m), n(r.n) { cout << "Rational::Rational(const Rational& r)" << endl; } Rational& operator=(const Rational& r) { if(this == &r) return(*this); m=r.m; n=r.n; cout << "Rational::operator=(const Rational& r)" << endl; return *this; } private: int m; int n; }; const Rational operator+(const Rational& a, const Rational& b) { cout << "operator+() begin" << endl; return Rational(a.m + b.m, a.n + b.n); ② } int main() { Rational a(10,10), b(5,8); Rational r = a + b; ① return 0; } |
这时输出如下:
Rational::Rational(int,int) Rational::Rational(int,int) operator+() begin Rational::Rational(int,int) |
如上,确实消除了temp。这时编译器在进入operator+(const Rational& a, const Rational& b)时看到①处是一个初始化,而不是赋值。所以编译器传入参数时,也传入了在main函数栈桢中为对象r预留的空间地址。当执行到②处时,实际上这个构造 函数就是在r对象所处的空间内进行的,即构造了r对象,这样省去了用来临时计算和存放结果的temp对象。
需要注意的是,这个做法需要与前一个优化配合才有效。即a+b的结果用来初始化一个对象,而不是对一个已经存在的对象进行赋值操作,如果①处是:
r = a + b; |
那么operator+(const Rational& a, const Rational& b)的实现中虽然没有用到temp对象,但是仍然会在调用函数(这里是main函数)的栈桢中生成一个临时对象用来存放计算结果,然后利用这个临时对象对 r对象进行赋值操作。
对于operator+(const Rational& a, const Rational& b)函数,常常看到有如下调用习惯:
Rational a, b; … a = a + b; |
这种写法也经常会用下面这种写法代替:
Rational a, b; … a += b; |
这两种写法除了个人习惯之外,在性能方面有无区别?回答是有区别。而且有时还会很大,视对象大小而定。因此设计某类时,如果需要重载 operator+,最好也重载operator+=,并且考虑到维护性,operator+用operator+=来实现。这样如果这个操作符的语义有 所改变需要修改时,只需要修改一处即可。
对Rational类来说,一般operator+=的实现如下:
Rational& operator+=(const Rational& rhs) { m += rhs.m; n += rhs.n; return (*this); } |
这里可以看到,与operator+不同,operator+=并没有产生临时变量,operator+则只有在返回值被用来初始化一个对象,而不 是对一个已经生成的对象进行赋值时才不产生临时对象。而且往往返回值被用来赋值的情况并不少见,甚至比初始化的情况还要多。因此使用operator+= 不产生临时对象,性能会比operator+要好,为此尽量使用语句:
a += b; |
而避免使用:
a = a + b; |
相应地,也应考虑到程序的代码可维护性(易于修改,因为不小心的修改会导致不一致等)。即尽量利用operator+=来实现operator+,如下:
const Rational operator+(const Rational& a, const Rational& b) { return Rational(a) += b; } |
同理,这个规律可以扩展到-=、*=和/=等。
操作符中还有两个比较特殊的,即++和--。它们都可以前置或者后置,比如i++和++i。二者的语义是有区别的,前者先将其值返回,然后其值增1;后者则是先将值增1,再返回其值。但当不需要用到其值,即单独使用时,比如:
i++; ++i; |
二者的语义则是一样的,都是将原值增1。但是对于一个非内建类型,在重载这两个操作符后,单独使用在性能方面是否有差别?来考察它们的实现。仍以 Rational类作为例子,假设++的语义为对分子(即m)增1,分母不变(暂且不考虑这种语义是否符合实际情况),那么两个实现如下:
const Rational& operator++() //prefix { ++m; return (*this); } const Rational operator++(int) //postfix { Rational tmp(*this); ① ++(*this); return tmp; } |
可以看到,因为考虑到后置++的语义,所以在实现中必须首先保留其原来的值。为此需要一个局部变量,如①处所示。然后值增1后,将保存其原值的局部 变量作为返回值返回。相比较而言,前置++的实现不会需要这样一个局部变量。而且不仅如此,前置的++只需要将自身返回即可,因此只需返回一个引用;后置 ++需要返回一个对象。已经知道,函数返回值为一个对象时,往往意味着需要生成一个临时对象用来存放返回值。因此如果调用后置++,意味着需要多生成两个 对象,分别是函数内部的局部变量和存放返回值的临时变量。
有鉴于此,对于非内建类型,在保证程序语义正确的前提下应该多用:
++i; |
而避免使用:
i++; |
同样的规律也适用于前置--和后置--(与=/+=相同的理由,考虑到维护性,尽量用前置++来实现后置++)。
至此,已经考察了临时对象的含义、产生临时对象的各种场合,以及一些避免临时对象产生的方法。最后来查看临时对象的生命周期。在C++规范中定义一个临时对象的生命周期为从创建时开始,到包含创建它的最长语句执行完毕,比如:
string a, b; const char* str; … if( strlen( str = (a + b).c_str() ) > 5) ① { printf("%s/n", str); ② … } |
在①处,首先创建一个临时对象存放a+b的值。然后从这个临时string对象中通过c_str()函数得到其字符串内容,赋给str。如果str的长度大于5,就会进入if内部,执行②处语句。问题是,这时的str还合法否?
答案是否定的,因为存放a+b值的临时对象的生命在包含其创建的最长语句结束后也相应结束了,这里是①处语句。当执行到②处时,该临时对象已经不存在,指向它内部字符串内容的str指向的是一段已经被回收的内存。这时的结果是无法预测的,但肯定不是所期望的。
但这条规范也有一个特例,当用一个临时对象来初始化一个常量引用时,该临时对象的生命会持续到与绑定到其上的常量引用销毁时,如:
string a, b; … if( …) { const string& c = a + b ① cout << c << endl; ② … } |
这时c这个常量string引用在①处绑定在存放a+b结果的临时对象后,可以继续在其使用域(scope)内正常使用,如在②处语句中那样。这是因为c是一个常量引用,因为被它绑定。所以存放a+b的临时对象并不会在①处语句执行后销毁,而是保持与c一样的生命周期。
|
在C++语言的设计中,内联函数的引入可以说完全是为了性能的考虑。因此在编写对性能要求比较高的C++程序时,非常有必要仔细考量内联函数的使 用。所谓"内联",即将被调用函数的函数体代码直接地整个插入到该函数被调用处,而不是通过call语句进行。当然,编译器在真正进行"内联"时,因为考 虑到被内联函数的传入参数、自己的局部变量,以及返回值的因素,不仅仅只是进行简单的代码拷贝,还需要做很多细致的工作,但大致思路如此。
开发人员可以有两种方式告诉编译器需要内联哪些类成员函数,一种是在类的定义体外;一种是在类的定义体内。
(1)当在类的定义体外时,需要在该成员函数的定义前面加"inline"关键字,显式地告诉编译器该函数在调用时需要"内联"处理,如:
class Student { public: String GetName(); int GetAge(); void SetAge(int ag); …… private: String name; int age; …… }; inline String GetName() { return name; } inline int GetAge() { return age; } inline void SetAge(int ag) { age = ag; } |
(2)当在类的定义体内且声明该成员函数时,同时提供该成员函数的实现体。此时,"inline"关键字并不是必需的,如:
class Student { public: String GetName() { return name; } int GetAge() { return age; } void SetAge(int ag) { age = ag; } …… private: String name; int age; …… }; |
当普通函数(非类成员函数)需要被内联时,则只需要在函数的定义时前面加上"inline"关键字,如:
inline int DoSomeMagic(int a, int b) { return a * 13 + b % 4 + 3; } |
因为C++是以"编译单元"为单位编译的,而一个编译单元往往大致等于一个".cpp"文件。在实际编译前,预处理器会将"#include"的各 头文件的内容(可能会有递归头文件展开)完整地拷贝到cpp文件对应位置处(另外还会进行宏展开等操作)。预处理器处理后,编译真正开始。一旦C++编译 器开始编译,它不会意识到其他cpp文件的存在。因此并不会参考其他cpp文件的内容信息。联想到内联的工作是由编译器完成的,且内联的意思是将被调用内 联函数的函数体代码直接代替对该内联函数的调用。这也就意味着,在编译某个编译单元时,如果该编译单元会调用到某个内联函数,那么该内联函数的函数定义 (即函数体)必须也包含在该编译单元内。因为编译器使用内联函数体代码替代内联函数调用时,必须知道该内联函数的函数体代码,而且不能通过参考其他编译单 元信息来获得这一信息。
如果有多个编译单元会调用到某同一个内联函数,C++规范要求在这多个编译单元中该内联函数的定义必须是完全一致的,这就是"ODR"(one- definition rule)原则。考虑到代码的可维护性,最好将内联函数的定义放在一个头文件中,用到该内联函数的各个编译单元只需#include该头文件即可。进一步 考虑,如果该内联函数是一个类的成员函数,这个头文件正好可以是该成员函数所属类的声明所在的头文件。这样看来,类成员内联函数的两种声明可以看成是几乎 一样的,虽然一个是在类外,一个在类内。但是两个都在同一个头文件中,编译器都能在#include该头文件后直接取得内联函数的函数体代码。讨论完如何 声明一个内联函数,来查看编译器如何内联的。继续上面的例子,假设有个foo函数:
#include "student.h" ... void foo() { ... Student abc; abc.SetAge(12); cout << abc.GetAge(); ... } |
foo函数进入foo函数时,从其栈帧中开辟了放置abc对象的空间。进入函数体后,首先对该处空间执行Student的默认构造函数构造abc对 象。然后将常数12压栈,调用abc的SetAge函数(开辟SetAge函数自己的栈帧,返回时回退销毁此栈帧)。紧跟着执行abc的GetAge函 数,并将返回值压栈。最后调用cout的<<操作符操作压栈的结果,即输出。
内联后大致如下:
#include "student.h" ... void foo() { ... Student abc; { abc.age = 12; } int tmp = abc.age; cout << tmp; ... } |
这时,函数调用时的参数压栈、栈帧开辟与销毁等操作不再需要,而且在结合这些代码后,编译器能进一步优化为如下结果:
#include "student.h" ... void foo() { ... cout << 12; ... } |
这显然是最好的优化结果;相反,考虑原始版本。如果SetAge/GetAge没有被内联,因为非内联函数一般不会在头文件中定义,这两个函数可能 在这个编译单元之外的其他编译单元中定义。即foo函数所在编译单元看不到SetAge/GetAge,不知道函数体代码信息,那么编译器传入12给 SetAge,然后用GetAge输出。在这一过程中,编译器不能确信最后GetAge的输出。因为编译这个编译单元时,不知道这两个函数的函数体代码, 因而也就不能做出最终版本的优化。
从上述分析中,可以看到使用内联函数至少有如下两个优点。
(1)减少因为函数调用引起开销,主要是参数压栈、栈帧开辟与回收,以及寄存器保存与恢复等。
(2)内联后编译器在处理调用内联函数的函数(如上例中的foo()函数)时,因为可供分析的代码更多,因此它能做的优化更深入彻底。前一条优点对于开发人员来说往往更显而易见一些,但往往这条优点对最终代码的优化可能贡献更大。
这时,有必要简单介绍函数调用时都需要执行哪些操作,这样可以帮助分析一些函数调用相关的问题。假设下面代码:
void foo() { ... i = func(a, b, c); ① ... ② } |
调用者(这里是foo)在调用前需要执行如下操作。
(1)参数压栈:这里是a、b和c。压栈时一般都是按照逆序,因此是c->b->c。如果a、b和c有对象,则需要先进行拷贝构造(前面章节已经讨论)。
(2)保存返回地址:即函数调用结束返回后接着执行的语句的地址,这里是②处语句的地址。
(3)保存维护foo函数栈帧信息的寄存器内容:如SP(堆栈指针)和FP(栈帧指针)等。到底保存哪些寄存器与平台相关,但是每个平台肯定都会有对应的寄存器。
(4)保存一些通用寄存器的内容:因为有些通用寄存器会被所有函数用到,所以在foo调用func之前,这些寄存器可能已经放置了对foo有用的信 息。这些寄存器在进入func函数体内执行时可能会被func用到,从而被覆写。因此foo在调用func前保存一份这些通用寄存器的内容,这样在 func返回后可以恢复它们。
接着调用func函数,它首先通过移动栈指针来分配所有在其内部声明的局部变量所需的空间,然后执行其函数体内的代码等。
最后当func执行完毕,函数返回时,foo函数还需要执行如下善后处理。
(1)恢复通用寄存器的值。
(2)恢复保存foo函数栈帧信息的那些寄存器的值。
(3)通过移动栈指针,销毁func函数的栈帧,
(4)将保存的返回地址出栈,并赋给IP寄存器。
(5)通过移动栈指针,回收传给func函数的参数所占用的空间。
在前面章节中已经讨论,如果传入参数和返回值为对象时,还会涉及对象的构造与析构,函数调用的开销就会更大。尤其是当传入对象和返回对象是复杂的大对象时,更是如此。
因为函数调用的准备与善后工作最终都是由机器指令完成的,假设一个函数之前的准备工作与之后的善后工作的指令所需的空间为SS,执行这些代码所需的时间为TS,现在可以更细致地从空间与时间两个方面来分析内联的效果。
(1)在空间上,一般印象是不采用内联,被调用函数的代码只有一份,调用它的地方使用call语句引用即可。而采用内联后,该函数的代码在所有调用 其处都有一份拷贝,因此最后总的代码大小比采用内联前要大。但事实不总是这样的,如果一个函数a的体代码大小为AS,假设a函数在整个程序中被调用了n 次,不采用内联时,对a的调用只有准备工作与善后工作两处会增加最后的代码量开销,即a函数相关的代码大小为:n * SS + AS。采用内联后,在各处调用点都需要将其函数体代码展开,即a函数相关的代码大小为n * AS。这样比较二者的大小,即比较(n * SS + AS)与(n*AS)的大小。考虑到n一般次数很多时,可以简化成比较SS与AS的大小。这样可以得出大致结论,如果被内联函数自己的函数体代码量比因为 函数调用的准备与善后工作引入的代码量大,内联后程序的代码量会变大;相反,当被内联函数的函数体代码量比因为函数调用的准备与善后工作引入的代码量小, 内联后程序的代码量会变小。这里还没有考虑内联的后续情况,即编译器可能因为获得的信息更多,从而对调用函数的优化做得更深入和彻底,致使最终的代码量变 得更小。
(2)在时间上,一般而言,每处调用都不再需要做函数调用的准备与善后工作。另外内联后,编译器在做优化时,看到的是调用函数与被调用函数连成的一 大块代码。即获得的代码信息更多,此时它对调用函数的优化可以做得更好。最后还有一个很重要的因素,即内联后调用函数体内需要执行的代码是相邻的,其执行 的代码都在同一个页面或连续的页面中。如果没有内联,执行到被调用函数时,需要跳到包含被调用函数的内存页面中执行,而被调用函数所属的页面极有可能当时 不在物理内存中。这意味着,内联后可以降低"缺页"的几率,知道减少"缺页"次数的效果远比减少一些代码量执行的效果。另外即使被调用函数所在页面可能也 在内存中,但是因为与调用函数在空间上相隔甚远,所以可能会引起"cache miss",从而降低执行速度。因此总的来说,内联后程序的执行时间会比没有内联要少。即程序的速度更快,这也是因为内联后代码的空间 "locality"特性提高了。但正如上面分析空间影响时提到的,当AS远大于SS,且n非常大时,最终程序的大小会比没有内联时要大很多。代码量大意 味着用来存放代码的内存页也会更多,这样因为执行代码而引起的"缺页"也会相应增多。如果这样,最终程序的执行时间可能会因为大量的"缺页"而变得更多, 即程序的速度变慢。这也是为什么很多编译器对于函数体代码很多的函数,会拒绝对其进行内联的请求。即忽略"inline"关键字,而对如同普通函数那样编 译。
综合上面的分析,在采用内联时需要内联函数的特征。比如该函数自己的函数体代码量,以及程序执行时可能被调用的次数等。当然,判断内联效果的最终和最有效的方法还是对程序的大小和执行时间进行实际测量,然后根据测量结果来决定是否应该采用内联,以及对哪些函数进行内联。
如下根据内联的本质来讨论与其相关的一些其他特点。
如前所述,因为调用内联函数的编译单元必须有内联函数的函数体代码信息。又因为ODR规则和考虑到代码的可维护性,所以一般将内联函数的定义放在一 个头文件中,然后在每个调用该内联函数的编译单元中#include该头文件。现在考虑这种情况,即在一个大型程序中,某个内联函数因为非常通用,而被大 多数编译单元用到对该内联函数的一个修改,就会引起所有用到它的编译单元的重新编译。对于一个真正的大型程序,重新编译大部分编译单元往往意味着大量的编 译时间。因此内联最好在开发的后期引入,以避免可能不必要的大量编译时间的浪费。
再考虑这种情况,如果某开发小组在开发中用到了第三方提供的程序库,而这些程序库中包含一些内联函数。因为该开发小组的代码中在用到第三方提供的内 联函数处,都是将该内联函数的函数体代码拷贝到调用处,即该开发小组的代码中包含了第三方提供代码的"实现"。假设这个第三方单位在下一个版本中修改了某 些内联函数的定义,那么虽然这个第三方单位并没有修改任何函数的对外接口,而只是修改了实现,该开发小组要想利用这个新的版本,仍然需要重新编译。考虑到 可能该开发小组的程序已经发布,那么这种重新编译的成本会相当高;相反,如果没有内联,并且仍然只是修改实现,那么该开发小组不必重新编译即可利用新的版 本。
因为内联的本质就是用函数体代码代替对该函数的调用,所以考虑递归函数,如:
[inline] int foo(int n) { ... return foo(n-1); } |
如果编译器编译某个调用此函数的编译单元,如:
void func() { ... int m = foo(n); ... } |
考虑如下两种情况。
(1)如果在编译该编译单元且调用foo时,提供的参数n不能知道其实际值,则编译器无法知道对foo函数体进行多少次代替。在这种情况下,编译器会拒绝对foo函数进行内联。
(2)如果在编译该编译单元且调用foo时,提供的参数n能够知道其实际值,则编译器可能会视n值的大小来决定是否对foo函数进行内联。因为如果n很大,内联展开可能会使最终程序的大小变得很大。
如前所述,因为内联函数是编译期行为,而虚拟函数是执行期行为,因此编译器一般会拒绝对虚拟函数进行内联的请求。但是事情总有例外,内联函数的本质 是编译器编译调用某函数时,将其函数体代码代替call调用,即内联的条件是编译器能够知道该处函数调用的函数体。而虚拟函数不能够被内联,也是因为在编 译时一般来说编译器无法知道该虚拟函数到底是哪一个版本,即无法确定其函数体。但是在两种情况下,编译器是能够知道虚拟函数调用的真实版本的,因此虚拟函 数可以被内联。
其一是通过对象,而不是指向对象的指针或者对象的引用调用虚拟函数,这时编译器在编译期就已经知道对象的确切类型。因此会直接调用确定的某虚拟函数实现版本,而不会产生"动态绑定"行为的代码。
其二是虽然是通过对象指针或者对象引用调用虚拟函数,但是编译时编译器能知道该指针或引用对应到的对象的确切类型。比如在产生的新对象时做的指针赋 值或引用初始化,发生在于通过该指针或引用调用虚拟函数同一个编译单元并且二者之间该指针没有被改变赋值使其指向到其他不能确切知道类型的对象(因为引用 不能修改绑定,因此无此之虞)。此时编译器也不会产生动态绑定的代码,而是直接调用该确定类型的虚拟函数实现版本。
在这两种情况下,编译器能够将此虚拟函数内联化,如:
inline virtual int x::y (char* a) { ... } void z (char* b) { x_base* x_pointer = new x(some_arguments_maybe); x x_instance(maybe_some_more_arguments); x_pointer->y(b); x_instance.y(b); |
当然在实际开发中,通过这两种方式调用虚拟函数时应该非常少,因为虚拟函数的语义是"通过基类指针或引用调用,到真正运行时才决定调用哪个版本"。
从上面的分析中已经看到,编译器并不总是尊重"inline"关键字。即使某个函数用"inline"关键字修饰,并不能够保证该函数在编译时真正 被内联处理。因此与register关键字性质类似,inline仅仅是给编译器的一个"建议",编译器完全可以视实际情况而忽略之。
另外从内联,即用函数体代码替代对该函数的调用这一本质看,它与C语言中的函数宏(macro)极其相似,但是它们之间也有本质的区别。即内联是编 译期行为,宏是预处理期行为,其替代展开由预处理器来做。也就是说编译器看不到宏,更不可能处理宏。另外宏的参数在其宏体内出现两次或两次以上时经常会产 生副作用,尤其是当在宏体内对参数进行++或 操作时,而内联不会。还有,预处理器不会也不能对宏的参数进行类型检查。而内联因为是编译器处理的,因此会对内联函数的参数进行类型检查,这对于写出正确 且鲁棒的程序,是一个很大的优势。最后,宏肯定会被展开,而用inline关键字修饰的函数不一定会被内联展开。
最后顺带提及,一个程序的惟一入口main()函数肯定不会被内联化。另外,编译器合成的默认构造函数、拷贝构造函数、析构函数,以及赋值运算符一般都会被内联化。
|
相对C语言而言,C++语言确实引入了很多新的语言特性。而很多开发人员在遇到用C++语言编写的应用程序性能问题时,也往往会倾向于将性能问题归 咎于这些新的语言特性,但实际情形往往并不是这样的。对待性能问题,我们应该采取一个客观的态度。在遇到性能问题并做出真正的性能测量之前,不要轻易假定 瓶颈所在。往往很多时候,应用程序的性能是因为该程序的功能和复杂度引起的,而非语言特性本身。如果实际的性能测量证明瓶颈确实是因为某些语言特性引起 的,这时需要对该语言特性的使用场合进行仔细分析,然后在不损害其带来的设计任务的前提下进行性能改善。本章着重分析了几个可能会对性能引起下降的语言特 性,包括构造函数/析构函数、继承与虚拟、临时对象,以及内联函数,对它们的深刻理解常常能够在编码阶段避免很多性能问题。
冯宏华,清华大学计算机科学与技术系硕士。IBM 中国开发中心高级软件工程师。 2003 年 12 月加入 IBM 中国开发中心,主要从事 IBM 产品的开发、性能优化等工作。兴趣包括 C/C++ 应用程序性能调优,Windows 应用程序开发,Web 应用程序开发等。 |
徐莹,山东大学计算机科学与技术系硕士。2003 年 4 月加入 IBM 中国开发中心,现任 IBM 中国开发中心开发经理,一直从事IBM软件产品在多个操作系统平台上的开发工作。曾参与 IBM 产品在 Windows 和 Linux 平台上的性能优化工作,对 C/C++ 编程语言和跨平台的大型软件系统的开发有较丰富的经验。 |
程远,北京大学计算机科学与技术系硕士。IBM 中国开发中心高级软件工程师。2003 年加入 IBM 中国开发中心,主要从事IBM Productivity Tools 产品的开发、性能优化等工作。兴趣包括 C/C++ 编程语言,软件性能工程,Windows/Linux 平台性能测试优化工具等。 |
汪磊,北京航空航天大学计算机科学与技术系硕士,目前是 IBM 中国软件开发中心高级软件工程师。从 2002 年 12 月加入 IBM 中国开发中心至今一直从事旨在提高企业生产效率的应用软件开发。兴趣包括 C/C++ 应用程序的性能调优,Java 应用程序的性能调优。 |