大多數開發人員通常都有這個觀點,即彙編語言和 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; } |-------10--------20--------30--------40--------50--------60--------70--------80--------9| |-------- XML error: The previous line is longer than the max of 90 characters ---------| Object(const Object& a) { n = a.n; cout << "Object::Object(const Object&)" << endl; } ~Object() { cout << "Object::~Object()" << endl; } |-------10--------20--------30--------40--------50--------60--------70--------80--------9| |-------- XML error: The previous line is longer than the max of 90 characters ---------| 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++語言編寫的應用程序性能問題時,也往往會傾向於將性能問題歸咎於這些新的語言特性,但實際情形往往並不是這樣的。對待性能問題,我們應該採取一個客觀的態度。在遇到性能問題並做出真正的性能測量之前,不要輕易假定瓶頸所在。往往很多時候,應用程序的性能是因爲該程序的功能和複雜度引起的,而非語言特性本身。如果實際的性能測量證明瓶頸確實是因爲某些語言特性引起的,這時需要對該語言特性的使用場合進行仔細分析,然後在不損害其帶來的設計任務的前提下進行性能改善。本章着重分析了幾個可能會對性能引起下降的語言特性,包括構造函數/析構函數、繼承與虛擬、臨時對象,以及內聯函數,對它們的深刻理解常常能夠在編碼階段避免很多性能問題。