級別: 中級 馮 宏華, 高級軟件工程師, IBM 中國開發(fā)中心 2007 年 11 月 29 日 本章從 C++ 的一些語言特性來分析影響性能的方面。 本書主要針對的是 C++ 程序的性能優(yōu)化,深入介紹 C++ 程序性能優(yōu)化的方法和實(shí)例。全書由 4 個(gè)篇組成,第 1 篇介紹 C++ 語言的對象模型,該篇是優(yōu)化 C++ 程序的基礎(chǔ);第 2 篇主要針對如何優(yōu)化 C++ 程序的內(nèi)存使用;第 3 篇介紹如何優(yōu)化程序的啟動(dòng)性能;第 4 篇介紹了三類性能優(yōu)化工具,即內(nèi)存分析工具、性能分析工具和 I/O 檢測工具,它們是測量程序性能的利器。 在此我們推出了此書的第 2、6 章供大家在線瀏覽。更多推薦書籍請?jiān)L問 developerWorks 圖書頻道。
大多數(shù)開發(fā)人員通常都有這個(gè)觀點(diǎn),即匯編語言和 C 語言適合用來編寫對性能要求非常高的程序。而 C++ 語言的主要應(yīng)用范圍是編寫復(fù)雜度非常高的程序,但是對性能要求不是那么嚴(yán)格的程序。但是事實(shí)往往并非如此,很多時(shí)候,一個(gè)程序的速度在框架設(shè)計(jì)完成時(shí)大致 已經(jīng)確定了,而并非是因?yàn)椴捎昧薈++語言才使其速度沒有達(dá)到預(yù)期的目標(biāo)。因此當(dāng)一個(gè)程序的性能需要提高時(shí),首先需要做的是用性能檢測工具對其運(yùn)行的時(shí)間 分布進(jìn)行一個(gè)準(zhǔn)確的測量,找出關(guān)鍵路徑和真正的瓶頸所在,然后針對瓶頸進(jìn)行分析和優(yōu)化,而不是一味盲目地將性能低劣歸咎于所采用的語言。事實(shí)上,如果框架 設(shè)計(jì)不做修改,即使用C語言或者匯編語言重新改寫,也并不能保證提高總體性能。 因此當(dāng)遇到性能問題時(shí),首先檢查和反思程序的總體框架。然后用性能檢測工具對其實(shí)際運(yùn)行做準(zhǔn)確地測量,再針對瓶頸進(jìn)行分析和優(yōu)化,這才是正確的思路。 但不可否認(rèn)的是,確實(shí)有一些操作或者C++的一些語言特性比其他因素更容易成為程序的瓶頸,一般公認(rèn)的有如下因素。 (1)缺頁:如第四章中所述,缺頁往往意味著需要訪問外部存儲(chǔ)。因?yàn)橥獠看鎯?chǔ)訪問相對于訪問內(nèi)存或者代碼執(zhí)行,有數(shù)量級的差別。因此只要有可能,應(yīng)該盡量想辦法減少缺頁。 (2)從堆中動(dòng)態(tài)申請和釋放內(nèi)存:如C語言中的malloc/free和C++語言中的new/delete操作非常耗時(shí),因此要盡可能優(yōu)先考慮從 線程棧中獲得內(nèi)存。優(yōu)先考慮棧而減少從動(dòng)態(tài)堆中申請內(nèi)存,不僅僅是因?yàn)樵诙阎虚_辟內(nèi)存比在棧中要慢很多,而且還與"盡量減少缺頁"這一宗旨有關(guān)。當(dāng)執(zhí)行程 序時(shí),當(dāng)前棧幀空間所在的內(nèi)存頁肯定在物理內(nèi)存中,因此程序代碼對其中變量的存取不會(huì)引起缺頁;相反,從堆中生成的對象,只有指向它的指針在棧上,對象本 身卻是在堆中。堆一般來說不可能都在物理內(nèi)存中,而且因?yàn)槎逊峙鋬?nèi)存的特性,即使兩個(gè)相鄰生成的對象,也很有可能在堆內(nèi)存位置上相隔很遠(yuǎn)。因此當(dāng)訪問這兩 個(gè)對象時(shí),雖然分別指向它們指針都在棧上,但是通過這兩個(gè)指針引用它們時(shí),很有可能會(huì)引起兩次"缺頁"。 (3)復(fù)雜對象的創(chuàng)建和銷毀:這往往是一個(gè)層次相當(dāng)深的遞歸調(diào)用,因?yàn)橐粋€(gè)對象的創(chuàng)建往往只需要一條語句,看似很簡單。另外,編譯器生成的臨時(shí)對象 因?yàn)樵诔绦虻脑创a中看不到,更是不容易察覺,因此尤其值得警惕和關(guān)注。本章中專門有兩節(jié)分別講解對象的構(gòu)造和析構(gòu),以及臨時(shí)對象。 (4)函數(shù)調(diào)用:因?yàn)楹瘮?shù)調(diào)用有固定的額外開銷,因此當(dāng)函數(shù)體的代碼量相對較少,且該函數(shù)被非常頻繁地調(diào)用時(shí),函數(shù)調(diào)用時(shí)的固定額外開銷容易成為不 必要的開銷。C語言的宏和C++語言的內(nèi)聯(lián)函數(shù)都是為了在保持函數(shù)調(diào)用的模塊化特征基礎(chǔ)上消除函數(shù)調(diào)用的固定額外開銷而引入的,因?yàn)楹暝谔峁┬阅軆?yōu)勢的同 時(shí)也給開發(fā)和調(diào)試帶來了不便。在C++中更多提倡的是使用內(nèi)聯(lián)函數(shù),本章會(huì)有一節(jié)專門講解內(nèi)聯(lián)函數(shù)。
2.1 構(gòu)造函數(shù)與析構(gòu)函數(shù) 構(gòu)造函數(shù)和析構(gòu)函數(shù)的特點(diǎn)是當(dāng)創(chuàng)建對象時(shí),自動(dòng)執(zhí)行構(gòu)造函數(shù);當(dāng)銷毀對象時(shí),析構(gòu)函數(shù)自動(dòng)被執(zhí)行。這兩個(gè)函數(shù)分別是一個(gè)對象最先和最后被執(zhí)行的函 數(shù),構(gòu)造函數(shù)在創(chuàng)建對象時(shí)調(diào)用,用來初始化該對象的初始狀態(tài)和取得該對象被使用前需要的一些資源,比如文件/網(wǎng)絡(luò)連接等;析構(gòu)函數(shù)執(zhí)行與構(gòu)造函數(shù)相反的操 作,主要是釋放對象擁有的資源,而且在此對象的生命周期這兩個(gè)函數(shù)都只被執(zhí)行一次。 創(chuàng)建一個(gè)對象一般有兩種方式,一種是從線程運(yùn)行棧中創(chuàng)建,也稱為"局部對象",一般語句為:
銷毀這種對象并不需要程序顯式地調(diào)用析構(gòu)函數(shù),而是當(dāng)程序運(yùn)行出該對象所屬的作用域時(shí)自動(dòng)調(diào)用。比如上述程序中在①處創(chuàng)建的對象obj在②處會(huì)自動(dòng) 調(diào)用該對象的析構(gòu)函數(shù)。在這種方式中,對象obj的內(nèi)存在程序進(jìn)入該作用域時(shí),編譯器生成的代碼已經(jīng)為其分配(一般都是通過移動(dòng)棧指針),①句只需要調(diào)用 對象的構(gòu)造函數(shù)即可。②處編譯器生成的代碼會(huì)調(diào)用該作用域內(nèi)所有局部的用戶自定義類型對象的析構(gòu)函數(shù),對象obj屬于其中之一,然后通過一個(gè)退棧語句一次 性將空間返回給線程棧。 另一種創(chuàng)建對象的方式為從全局堆中動(dòng)態(tài)創(chuàng)建,一般語句為:
當(dāng)執(zhí)行①句時(shí),指針obj所指向?qū)ο蟮膬?nèi)存從全局堆中取得,并將地址值賦給obj。但指針obj本身卻是一個(gè)局部對象,需要從線程棧中分配,它所指 向的對象從全局堆中分配內(nèi)存存放。從全局堆中創(chuàng)建的對象需要顯式調(diào)用delete銷毀,delete會(huì)調(diào)用該指針指向的對象的析構(gòu)函數(shù),并將該對象所占的 全局堆內(nèi)存空間返回給全局堆,如②句。執(zhí)行②句后,指針obj所指向的對象確實(shí)已被銷毀。但是指針obj卻還存在于棧中,直到程序退出其所在的作用域。即 執(zhí)行到③處時(shí),指針obj才會(huì)消失。需要注意的是,指針obj的值在②處至③處之間,仍然指向剛才被銷毀的對象的位置,這時(shí)使用這個(gè)指針是危險(xiǎn)的。在 Win32平臺中,訪問剛才被銷毀對象,可能出現(xiàn)3種情況。第1種情況是該處位置所在的"內(nèi)存頁"沒有任何對象,堆管理器已經(jīng)將其進(jìn)一步返回給系統(tǒng),此時(shí) 通過指針obj訪問該處內(nèi)存會(huì)引起"訪問違例",即訪問了不合法的內(nèi)存,這種錯(cuò)誤會(huì)導(dǎo)致進(jìn)程崩潰;第2種情況是該處位置所在的"內(nèi)存頁"還有其他對象,且 該處位置被回收后,尚未被分配出去,這時(shí)通過指針obj訪問該處內(nèi)存,取得的值是無意義的,雖然不會(huì)立刻引起進(jìn)程崩潰,但是針對該指針的后續(xù)操作的行為是 不可預(yù)測的;第3種情況是該處位置所在的"內(nèi)存頁"還有其他對象,且該處位置被回收后,已被其他對象申請,這時(shí)通過指針obj訪問該處內(nèi)存,取得的值其實(shí) 是程序其他處生成的對象。雖然對指針obj的操作不會(huì)立刻引起進(jìn)程崩潰,但是極有可能會(huì)引起該對象狀態(tài)的改變。從而使得在創(chuàng)建該對象處看來,該對象的狀態(tài) 會(huì)莫名其妙地變化。第2種和第3種情況都是很難發(fā)現(xiàn)和排查的bug,需要小心地避免。 創(chuàng)建一個(gè)對象分成兩個(gè)步驟,即首先取得對象所需的內(nèi)存(無論是從線程棧還是從全局堆中),然后在該塊內(nèi)存上執(zhí)行構(gòu)造函數(shù)。在構(gòu)造函數(shù)構(gòu)建該對象時(shí),構(gòu)造函數(shù)也分成兩個(gè)步驟。即第1步執(zhí)行初始化(通過初始化列表),第2步執(zhí)行構(gòu)造函數(shù)的函數(shù)體,如下:
①步中的 ": i(10), string("unnamed")" 即所謂的"初始化列表",以":"開始,后面為初始化單元。每個(gè)單元都是"變量名(初始值)"這樣的模式,各單元之間以逗號隔開。構(gòu)造函數(shù)首先根據(jù)初始化 列表執(zhí)行初始化,然后執(zhí)行構(gòu)造函數(shù)的函數(shù)體,即②處語句。對初始化操作,有下面幾點(diǎn)需要注意。 (1)構(gòu)造函數(shù)其實(shí)是一個(gè)遞歸操作,在每層遞歸內(nèi)部的操作遵循嚴(yán)格的次序。遞歸模式為首先執(zhí)行父類的構(gòu)造函數(shù)(父類的構(gòu)造函數(shù)操作也相應(yīng)的包括執(zhí)行 初始化和執(zhí)行構(gòu)造函數(shù)體兩個(gè)部分),父類構(gòu)造函數(shù)返回后構(gòu)造該類自己的成員變量。構(gòu)造該類自己的成員變量時(shí),一是嚴(yán)格按照成員變量在類中的聲明順序進(jìn)行, 而與其在初始化列表中出現(xiàn)的順序完全無關(guān);二是當(dāng)有些成員變量或父類對象沒有在初始化列表中出現(xiàn)時(shí),它們?nèi)匀辉诔跏蓟僮鬟@一步驟中被初始化。內(nèi)建類型成 員變量被賦給一個(gè)初值。父類對象和類成員變量對象被調(diào)用其默認(rèn)構(gòu)造函數(shù)初始化,然后父類的構(gòu)造函數(shù)和子成員變量對象在構(gòu)造函數(shù)執(zhí)行過程中也遵循上述遞歸操 作。一直到此類的繼承體系中所有父類和父類所含的成員變量都被構(gòu)造完成后,此類的初始化操作才告結(jié)束。 (2)父類對象和一些成員變量沒有出現(xiàn)在初始化列表中時(shí),這些對象仍然被執(zhí)行構(gòu)造函數(shù),這時(shí)執(zhí)行的是"默認(rèn)構(gòu)造函數(shù)"。因此這些對象所屬的類必須提 供可以調(diào)用的默認(rèn)構(gòu)造函數(shù),為此要求這些類要么自己"顯式"地提供默認(rèn)構(gòu)造函數(shù),要么不能阻止編譯器"隱式"地為其生成一個(gè)默認(rèn)構(gòu)造函數(shù),定義除默認(rèn)構(gòu)造 函數(shù)之外的其他類型的構(gòu)造函數(shù)就會(huì)阻止編譯器生成默認(rèn)構(gòu)造函數(shù)。如果編譯器在編譯時(shí),發(fā)現(xiàn)沒有可供調(diào)用的默認(rèn)構(gòu)造函數(shù),并且編譯器也無法生成,則編譯無法 通過。 (3)對兩類成員變量,需要強(qiáng)調(diào)指出即"常量"(const)型和"引用"(reference)型。因?yàn)橐呀?jīng)指出,所有成員變量在執(zhí)行函數(shù)體之前 已經(jīng)被構(gòu)造,即已經(jīng)擁有初始值。根據(jù)這個(gè)特點(diǎn),很容易推斷出"常量"型和"引用"型變量必須在初始化列表中正確初始化,而不能將其初始化放在構(gòu)造函數(shù)體 內(nèi)。因?yàn)檫@兩類變量一旦被賦值,其整個(gè)生命周期都不能修改其初始值。所以必須在第一次即"初始化"操作中被正確賦值。 (4)可以看到,即使初始化列表可能沒有完全列出其子成員或父類對象成員,或者順序與其在類中聲明的順序不符,這些成員仍然保證會(huì)被"全部"且"嚴(yán) 格地按照順序"被構(gòu)建。這意味著在程序進(jìn)入構(gòu)造函數(shù)體之前,類的父類對象和所有子成員變量對象都已經(jīng)被生成和構(gòu)造。如果在構(gòu)造函數(shù)體內(nèi)為其執(zhí)行賦初值操 作,顯然屬于浪費(fèi)。如果在構(gòu)造函數(shù)時(shí)已經(jīng)知道如何為類的子成員變量初始化,那么應(yīng)該將這些初始化信息通過構(gòu)造函數(shù)的初始化列表賦予子成員變量,而不是在構(gòu) 造函數(shù)體中進(jìn)行這些初始化。因?yàn)檫M(jìn)入構(gòu)造函數(shù)體時(shí),這些子成員變量已經(jīng)初始化一次。 下面這個(gè)例子演示了構(gòu)造函數(shù)的這些重要特性:
在這段代碼中,類D繼承自類B,類B繼承自類A。然后類D中含有3個(gè)成員變量對象c1、c2和c3,分別為類型C1、C2和C3。 此段程序的輸出為:
可以看到,①處調(diào)用D::D(double,int)構(gòu)造函數(shù)構(gòu)造對象d,此構(gòu)造函數(shù)從②處開始引起了一連串的遞歸構(gòu)造。從輸出可以驗(yàn)證遞歸操作的如下規(guī)律。 (1)遞歸從父類對象開始,D的構(gòu)造函數(shù)首先通過"初始化"操作構(gòu)造其直接父類B的構(gòu)造函數(shù)。然后B的構(gòu)造函數(shù)先執(zhí)行"初始化"部分,該"初始化" 操作構(gòu)造B的直接父類A,類A沒有自己的成員需要初始化,所以其"初始化"不執(zhí)行任何操作。初始化后,開始執(zhí)行類A的構(gòu)造函數(shù),即③的輸出。 (2)構(gòu)造類A的對象后,B的"初始化"操作執(zhí)行初始化類表中的j(0)對j進(jìn)行初始化。然后進(jìn)入B的構(gòu)造函數(shù)的函數(shù)體,即④處輸出的來源。至此類 B的對象構(gòu)造完畢,注意這里看到初始化列表中并沒有"顯式"地列出其父類的構(gòu)造函數(shù)。但是子類在構(gòu)造時(shí)總是在其構(gòu)造函數(shù)的"初始化"操作的最開始構(gòu)造其父 類對象,而忽略其父類構(gòu)造函數(shù)是否顯式地列在初始化列表中。 (3)構(gòu)造類B的對象后,類D的"初始化"操作接著初始化其成員變量對象,這里是c1,c2和c3。因?yàn)樗鼈冊陬怐中的聲明順序就是c1 -> c2 -> c3,所以看到它們也是按照這個(gè)順序構(gòu)造的,如⑤,⑥,⑦ 3處輸出所示。注意這里故意在初始化列表中將c2的順序放在了c1的前面,c3甚至都沒有列在初始化列表中。但是輸出顯示了成員變量的初始化嚴(yán)格按照它們 在類中的聲明順序進(jìn)行,而忽略其是否顯式地列在初始化列表中,或者顯示在初始化列表中的順序如何。應(yīng)該盡量將成員變量初始化列表中出現(xiàn)的順序與其在類中聲 明的順序保持一致,因?yàn)槿绻褂靡粋€(gè)變量的值來初始化另外一個(gè)變量時(shí),程序的行為可能不是開發(fā)人員預(yù)想的那樣,比如:
這段程序的本意應(yīng)該是首先將v2初始化為5,然后用v2的值來初始化v1,從而v1=15。然而通過驗(yàn)證,初始化后的v2確實(shí)為5,但v1則是一個(gè) 非常奇怪的值(在筆者的電腦上輸出是12737697)。這是因?yàn)閷?shí)際初始化時(shí)首先初始化v1,這時(shí)v2還尚未正確初始化,根據(jù)v2計(jì)算出來的v1也就不 是一個(gè)合理的值了。當(dāng)然除了將成員變量在初始化列表中的順序與其在類中聲明的順序保持一致之外,最好還是避免在初始化列表中用某個(gè)成員變量的值初始化另外 一個(gè)成員變量的值。 (4)隨著c1、c2和c3這3個(gè)成員變量對象構(gòu)造完畢,類D的構(gòu)造函數(shù)的"初始化"操作部分結(jié)束,程序開始進(jìn)入其構(gòu)造函數(shù)的第2部分。即執(zhí)行構(gòu)造函數(shù)的函數(shù)體,這就是⑧處輸出的來源。 析構(gòu)函數(shù)的調(diào)用與構(gòu)造函數(shù)的調(diào)用一樣,也是類似的遞歸操作。但有兩點(diǎn)不同,一是析構(gòu)函數(shù)沒有與構(gòu)造函數(shù)相對應(yīng)的初始化操作部分,這樣析構(gòu)函數(shù)的主要 工作就是執(zhí)行析構(gòu)函數(shù)的函數(shù)體;二是析構(gòu)函數(shù)執(zhí)行的遞歸與構(gòu)造函數(shù)剛好相反,而且在每一層的遞歸中,成員變量對象的析構(gòu)順序也與構(gòu)造時(shí)剛好相反。 正是因?yàn)樵趫?zhí)行析構(gòu)函數(shù)時(shí),沒有與構(gòu)造函數(shù)的初始化列表相對應(yīng)的列表,所以析構(gòu)函數(shù)只能選擇成員變量在類中聲明的順序作為析構(gòu)的順序參考。因?yàn)闃?gòu)造 函數(shù)選擇了自然的正序,而析構(gòu)函數(shù)的工作又剛好與其相反,所以析構(gòu)函數(shù)選擇逆序。因?yàn)槲鰳?gòu)函數(shù)只能用成員變量在類中的聲明順序作為析構(gòu)順序(要么正序,要 么逆序),這樣使得構(gòu)造函數(shù)也只能選擇將這個(gè)順序作為構(gòu)造的順序依據(jù),而不能采用初始化列表中的作為順序依據(jù)。 與構(gòu)造函數(shù)類似,如果操作的對象屬于一個(gè)復(fù)雜繼承體系中的末端節(jié)點(diǎn),那么其析構(gòu)函數(shù)也是十分耗時(shí)的操作。 因?yàn)闃?gòu)造函數(shù)/析構(gòu)函數(shù)的這些特性,所以在考慮或者調(diào)整程序的性能時(shí),也必須考慮構(gòu)造函數(shù)/析構(gòu)函數(shù)的成本,在那些會(huì)大量構(gòu)造擁有復(fù)雜繼承體系對象的大型程序中尤其如此。下面兩點(diǎn)是構(gòu)造函數(shù)/析構(gòu)函數(shù)相關(guān)的性能考慮。 (5)在C++程序中,創(chuàng)建/銷毀對象是影響性能的一個(gè)非常突出的操作。首先,如果是從全局堆中生成對象,則需要首先進(jìn)行動(dòng)態(tài)內(nèi)存分配操作。眾所周 知,動(dòng)態(tài)內(nèi)存分配/回收在C/C++程序中一直都是非常費(fèi)時(shí)的。因?yàn)闋可娴綄ふ移ヅ浯笮〉膬?nèi)存塊,找到后可能還需要截?cái)嗵幚恚缓筮€需要修改維護(hù)全局堆內(nèi) 存使用情況信息的鏈表等。因?yàn)橐庾R到頻繁的內(nèi)存操作會(huì)嚴(yán)重影響性能的下降,所以已經(jīng)發(fā)展出很多技術(shù)用來緩解和降低這種影響,比如后續(xù)章節(jié)中將說明的內(nèi)存池 技術(shù)。其中一個(gè)主要目標(biāo)就是為了減少從動(dòng)態(tài)堆中申請內(nèi)存的次數(shù),從而提高程序的總體性能。當(dāng)取得內(nèi)存后,如果需要生成的目標(biāo)對象屬于一個(gè)復(fù)雜繼承體系中末 端的類,那么該構(gòu)造函數(shù)的調(diào)用就會(huì)引起一長串的遞歸構(gòu)造操作。在大型復(fù)雜系統(tǒng)中,大量此類對象的創(chuàng)建很快就會(huì)成為消耗CPU操作的主要部分。因?yàn)樽⒁夂鸵?識到對象的創(chuàng)建/銷毀會(huì)降低程序的性能,所以開發(fā)人員往往對那些會(huì)創(chuàng)建對象的代碼非常敏感。在盡量減少自己所寫代碼生成的對象同時(shí),開發(fā)人員也開始留意編 譯器在編譯時(shí)"悄悄"生成的一些臨時(shí)對象。開發(fā)人員有責(zé)任盡量避免編譯器為其程序生成臨時(shí)對象,下面會(huì)有一節(jié)專門討論這個(gè)問題。語義保持完全一致。 (6)已經(jīng)看到,如果在實(shí)現(xiàn)構(gòu)造函數(shù)時(shí),沒有注意到執(zhí)行構(gòu)造函數(shù)體前的初始化操作已經(jīng)將所有父類對象和成員變量對象構(gòu)造完畢。而在構(gòu)造函數(shù)體中進(jìn)行 第2次的賦值操作,那么也會(huì)浪費(fèi)很多的寶貴CPU時(shí)間用來重復(fù)計(jì)算。這雖然是小疏忽,但在大型復(fù)雜系統(tǒng)中積少成多,也會(huì)造成程序性能的顯著下降。 減少對象創(chuàng)建/銷毀的一個(gè)很簡單且常見的方法就是在函數(shù)聲明中將所有的值傳遞改為常量引用傳遞,比如下面的函數(shù)聲明:
應(yīng)該相應(yīng)改為:
因?yàn)镃/C++語言的函數(shù)調(diào)用都是"值傳遞",因此當(dāng)通過下面方式調(diào)用foo函數(shù)時(shí):
②處函數(shù)foo內(nèi)部引用的變量a雖然名字與①中創(chuàng)建的a相同,但并不是相同的對象,兩個(gè)對象"相同"的含義指其生命周期的每個(gè)時(shí)間點(diǎn)所指的是內(nèi)存中 相同的一塊區(qū)域。這里①處的a和②處的a并不是相同的對象,當(dāng)程序執(zhí)行到②句時(shí),編譯器會(huì)生成一個(gè)局部對象。這個(gè)局部對象利用①處的a拷貝構(gòu)造,然后執(zhí)行 foo函數(shù)。在函數(shù)體內(nèi)部,通過名字a引用的都是通過①處a拷貝構(gòu)造的復(fù)制品。函數(shù)體內(nèi)所有對a的修改,實(shí)質(zhì)上也只是對此復(fù)制品的修改,而不會(huì)影響到①處 的原變量。當(dāng)foo函數(shù)體執(zhí)行完畢退出函數(shù)時(shí),此復(fù)制品會(huì)被銷毀,這也意味著對此復(fù)制品的修改在函數(shù)結(jié)束后都被丟失。 通過下面這段程序來驗(yàn)證值傳遞的行為特征:
輸出為:
可以看到,④處的輸出為①處對象a的構(gòu)造,而⑤處的輸出則是②處foo(a)。調(diào)用開始時(shí)通過構(gòu)造函數(shù)生成對象a的復(fù)制品,緊跟著在函數(shù)體內(nèi)檢查復(fù) 制品的值。輸出與外部原對象的值相同(因?yàn)槭峭ㄟ^拷貝構(gòu)造函數(shù)),然后復(fù)制品調(diào)用inc()函數(shù)將值加1。再次打印出⑦處的輸出,復(fù)制品的值已經(jīng)變成了 2。foo函數(shù)執(zhí)行后需要銷毀復(fù)制品a,即⑧處的輸出。foo函數(shù)執(zhí)行后程序又回到main函數(shù)中繼續(xù)執(zhí)行,重新打印原對象a的值,發(fā)現(xiàn)其值保持不變(⑨ 處的輸出)。 重新審視foo函數(shù)的設(shè)計(jì),既然它在函數(shù)體內(nèi)修改了a。其原意應(yīng)該是想修改main函數(shù)的對象a,而非復(fù)制品。因?yàn)閷?fù)制品的修改在函數(shù)執(zhí)行后被" 丟失",那么這時(shí)不應(yīng)該傳入Object a,而是傳入Object& a。這樣函數(shù)體內(nèi)對a的修改,就是對原對象的修改。foo函數(shù)執(zhí)行后其修改仍然保持而不會(huì)丟失,這應(yīng)該是設(shè)計(jì)者的初衷。 如果相反,在foo函數(shù)體內(nèi)并沒有修改a。即只對a執(zhí)行"讀"操作,這時(shí)傳入const Object& a是完全勝任的。而且還不會(huì)生成復(fù)制品對象,也就不會(huì)調(diào)用構(gòu)造函數(shù)/析構(gòu)函數(shù)。 綜上所述,當(dāng)函數(shù)需要修改傳入?yún)?shù)時(shí),如果函數(shù)聲明中傳入?yún)?shù)為對象,那么這種設(shè)計(jì)達(dá)不到預(yù)期目的。即是錯(cuò)誤的,這時(shí)應(yīng)該用應(yīng)用傳入?yún)?shù)。當(dāng)函數(shù)不 會(huì)修改傳入?yún)?shù)時(shí),如果函數(shù)聲明中傳入?yún)?shù)為對象,則這種設(shè)計(jì)能夠達(dá)到程序的目的。但是因?yàn)闀?huì)生成不必要的復(fù)制品對象,從而引入了不必要的構(gòu)造/析構(gòu)操 作。這種設(shè)計(jì)是不合理和低效的,應(yīng)該用常量引用傳入?yún)?shù)。 下面這個(gè)簡單的小程序用來驗(yàn)證在構(gòu)造函數(shù)中重復(fù)賦值對性能的影響,為了放大絕對值的差距,將循環(huán)次數(shù)設(shè)置為100 000:
類Object中包含一個(gè)成員變量,即類Val的對象。類Val中含一個(gè)double數(shù)組,數(shù)組長度為1 000。Object在調(diào)用構(gòu)造函數(shù)時(shí)就知道應(yīng)為v賦的值,但有兩種方式,一種方式是如①處那樣通過初始化列表對v成員進(jìn)行初始化;另一種方式是如②處那 樣在構(gòu)造函數(shù)體內(nèi)為v賦值。兩種方式的性能差別到底有多大呢?測試機(jī)器(VC6 release版本,Windows XP sp2,CPU為Intel 1.6 GHz內(nèi)存為1GB)中測試結(jié)果是前者(①)耗時(shí)406毫秒,而后者(②)卻耗時(shí)735毫秒,如圖2-1所示。即如果改為前者,可以將性能提高 44.76%。 圖2-1 兩種方式的性能對比 ![]() 從圖中可以直觀地感受到將變量在初始化列表中正確初始化,而不是放置在構(gòu)造函數(shù)的函數(shù)體內(nèi)。從而對性能的影響相當(dāng)大,因此在寫構(gòu)造函數(shù)時(shí)應(yīng)該引起足夠的警覺和關(guān)注。
虛擬函數(shù)是C++語言引入的一個(gè)很重要的特性,它提供了"動(dòng)態(tài)綁定"機(jī)制,正是這一機(jī)制使得繼承的語義變得相對明晰。 (1)基類抽象了通用的數(shù)據(jù)及操作,就數(shù)據(jù)而言,如果該數(shù)據(jù)成員在各派生類中都需要用到,那么就需要將其聲明在基類中;就操作而言,如果該操作對各派生類都有意義,無論其語義是否會(huì)被修改或擴(kuò)展,那么就需要將其聲明在基類中。 (2)有些操作,如果對于各個(gè)派生類而言,語義保持完全一致,而無需修改或擴(kuò)展,那么這些操作聲明為基類的非虛擬成員函數(shù)。各派生類在聲明為基類的 派生類時(shí),默認(rèn)繼承了這些非虛擬成員函數(shù)的聲明/實(shí)現(xiàn),如同默認(rèn)繼承基類的數(shù)據(jù)成員一樣,而不必另外做任何聲明,這就是繼承帶來的代碼重用的優(yōu)點(diǎn)。 (3)另外還有一些操作,雖然對于各派生類而言都有意義,但是其語義并不相同。這時(shí),這些操作應(yīng)該聲明為基類的虛擬成員函數(shù)。各派生類雖然也默認(rèn)繼 承了這些虛擬成員函數(shù)的聲明/實(shí)現(xiàn),但是語義上它們應(yīng)該對這些虛擬成員函數(shù)的實(shí)現(xiàn)進(jìn)行修改或者擴(kuò)展。另外在實(shí)現(xiàn)這些修改或擴(kuò)展過程中,需要用到額外的該派 生類獨(dú)有的數(shù)據(jù)時(shí),將這些數(shù)據(jù)聲明為此派生類自己的數(shù)據(jù)成員。 再考慮更大背景下的繼承體系,當(dāng)更高層次的程序框架(繼承體系的使用者)使用此繼承體系時(shí),它處理的是一個(gè)抽象層次的對象集合(即基類)。雖然這個(gè) 對象集合的成員實(shí)質(zhì)上可能是各種派生類對象,但在處理這個(gè)對象集合中的對象時(shí),它用的是抽象層次的操作。并不區(qū)分在這些操作中,哪些操作對各派生類來說是 保持不變的,而哪些操作對各派生類來說有所不同。這是因?yàn)?,?dāng)運(yùn)行時(shí)實(shí)際執(zhí)行到各操作時(shí),運(yùn)行時(shí)系統(tǒng)能夠識別哪些操作需要用到"動(dòng)態(tài)綁定",從而找到對應(yīng) 此派生類的修改或擴(kuò)展的該操作版本。 也就是說,對繼承體系的使用者而言,此繼承體系內(nèi)部的多樣性是"透明的"。它不必關(guān)心其繼承細(xì)節(jié),處理的就是一組對它而言整體行為一致的"對象"。 即只需關(guān)心它自己問題域的業(yè)務(wù)邏輯,只要保證正確,其任務(wù)就算完成了。即使繼承體系內(nèi)部增加了某種派生類,或者刪除了某種派生類,或者某某派生類的某個(gè)虛 擬函數(shù)的實(shí)現(xiàn)發(fā)生了改變,它的代碼不必任何修改。這也意味著,程序的模塊化程度得到了極大的提高。而模塊化的提高也就意味著可擴(kuò)展性、可維護(hù)性,以及代碼 的可讀性的提高,這也是"面向?qū)ο?編程的一個(gè)很大的優(yōu)點(diǎn)。 下面通過一個(gè)簡單的實(shí)例來展示這一優(yōu)點(diǎn)。 假設(shè)有一個(gè)繪圖程序允許用戶在一個(gè)畫布上繪制各種圖形,如三角形、矩形和圓等,很自然地抽象圖形的繼承體系,如圖2-2所示。 圖2-2 圖形的繼承體系 ![]() 這個(gè)圖形繼承體系的設(shè)計(jì)大致如下:
為簡單起見,讓每個(gè)Shape對象都支持"繪制"和"旋轉(zhuǎn)"操作,每個(gè)Shape的派生類對這兩個(gè)操作都有自己的實(shí)現(xiàn):
再來考慮這個(gè)圖形繼承體系的使用,這里很自然的一個(gè)使用者是畫布,設(shè)計(jì)其類名為"Canvas":
Canvas類中維護(hù)一個(gè)包含所有圖形的shapes,Canvas類在處理自己的業(yè)務(wù)邏輯時(shí)并不關(guān)心shapes實(shí)際上都是哪些具體的圖形;相 反,如①處和②處所示,它只將這些圖形作為一個(gè)抽象,即Shape。在處理每個(gè)Shape時(shí),調(diào)用每個(gè)Shape的某個(gè)操作即可。 這樣做的一個(gè)好處是當(dāng)圖形繼承體系發(fā)生變化時(shí),作為圖形繼承體系的使用者Canvas而言,它的改變幾乎沒有,或者很小。 比如說,在程序的演變過程中發(fā)現(xiàn)需要支持多邊型(Polygon)和貝塞爾曲線(Bezier)類型,只需要在圖形繼承體系中增加這兩個(gè)新類型即可:
而不必修改Canvas的任何代碼,程序即可像以前那樣正常運(yùn)行。同理,如果以后發(fā)現(xiàn)不再支持某種類型,也只需要將其從圖形繼承體系中刪除,而不必 修改Canvas的任何代碼??梢钥吹?,從對象繼承體系的使用者(Canvas)的角度來看,它只看到Shape對象,而不必關(guān)心到底是哪一種特定的 Shape,這是面向?qū)ο笤O(shè)計(jì)的一個(gè)重要特點(diǎn)和優(yōu)點(diǎn)。 虛擬函數(shù)的"動(dòng)態(tài)綁定"特性雖然很好,但也有其內(nèi)在的空間以及時(shí)間開銷,每個(gè)支持虛擬函數(shù)的類(基類或派生類)都會(huì)有一個(gè)包含其所有支持的虛擬函數(shù) 指針的"虛擬函數(shù)表"(virtual table)。另外每個(gè)該類生成的對象都會(huì)隱含一個(gè)"虛擬函數(shù)指針"(virtual pointer),此指針指向其所屬類的"虛擬函數(shù)表"。當(dāng)通過基類的指針或者引用調(diào)用某個(gè)虛擬函數(shù)時(shí),系統(tǒng)需要首先定位這個(gè)指針或引用真正對應(yīng)的"對 象"所隱含的虛擬函數(shù)指針。"虛擬函數(shù)指針",然后根據(jù)這個(gè)虛擬函數(shù)的名稱,對這個(gè)虛擬函數(shù)指針?biāo)赶虻奶摂M函數(shù)表進(jìn)行一個(gè)偏移定位,再調(diào)用這個(gè)偏移定位 處的函數(shù)指針對應(yīng)的虛擬函數(shù),這就是"動(dòng)態(tài)綁定"的解析過程(當(dāng)然C++規(guī)范只需要編譯器能夠保證動(dòng)態(tài)綁定的語義即可,但是目前絕大多數(shù)的C++編譯器都 是用這種方式實(shí)現(xiàn)虛擬函數(shù)的),通過分析,不難發(fā)現(xiàn)虛擬函數(shù)的開銷:
內(nèi)聯(lián)函數(shù):因?yàn)閮?nèi)聯(lián)函數(shù)常??梢蕴岣叽a執(zhí)行的速度,因此很多普通函數(shù)會(huì)根據(jù)情況進(jìn)行內(nèi)聯(lián)化,但是虛擬函數(shù)無法利用內(nèi)聯(lián)化的優(yōu)勢,這是因?yàn)閮?nèi)聯(lián)函數(shù) 是在"編譯期"編譯器將調(diào)用內(nèi)聯(lián)函數(shù)的地方用內(nèi)聯(lián)函數(shù)體的代碼代替(內(nèi)聯(lián)展開),但是虛擬函數(shù)本質(zhì)上是"運(yùn)行期"行為,本質(zhì)上在"編譯期"編譯器無法知道 某處的虛擬函數(shù)調(diào)用在真正執(zhí)行的時(shí)候會(huì)調(diào)用到那個(gè)具體的實(shí)現(xiàn)(即在"編譯期"無法確定其綁定),因此在"編譯期"編譯器不會(huì)對通過指針或者引用調(diào)用的虛擬 函數(shù)進(jìn)行內(nèi)聯(lián)化。也就是說,如果想利用虛擬函數(shù)的"動(dòng)態(tài)綁定"帶來的設(shè)計(jì)優(yōu)勢,那么必須放棄"內(nèi)聯(lián)函數(shù)"帶來的速度優(yōu)勢。 根據(jù)上面的分析,似乎在采用虛擬函數(shù)時(shí)帶來和很多的負(fù)面影響,但是這些負(fù)面影響是否一定是虛擬函數(shù)所必須帶來的?或者說,如果不采用虛擬函數(shù),是否一定能避免這些缺陷? 還是分析以上圖形繼承體系的例子,假設(shè)不采用虛擬函數(shù),但同時(shí)還要實(shí)現(xiàn)與上面一樣的功能(維持程序的設(shè)計(jì)語義不變),那么對于基類Shape必須增加一個(gè)類型標(biāo)識成員變量用來在運(yùn)行時(shí)識別到底是哪一個(gè)具體的派生類對象:
如①處和②處所示,增加type用來標(biāo)識派生類對象的具體類型。另外注意這時(shí)③處和④處此時(shí)已經(jīng)不再使用virtual聲明。 其各派生類在構(gòu)造時(shí),必須設(shè)置具體類型,以Circle派生類為例:
對圖形繼承體系的使用者(這里是Canvas)而言,其Paint和RotateSelected也需要修改:
因?yàn)橐獙?shí)現(xiàn)相同的程序功能(語義),已經(jīng)看到,每個(gè)對象雖然沒有編譯器生成的虛擬函數(shù)指針(析構(gòu)函數(shù)往往被設(shè)計(jì)為virtual,如果如此,仍然免 不了會(huì)隱含增加一個(gè)虛擬函數(shù)指針,這里假設(shè)不是這樣),但是還是需要另外增加一個(gè)type變量用來標(biāo)識派生類的類型。構(gòu)造對象時(shí),雖然不必初始化虛擬函數(shù) 指針,但是仍然需要初始化type。另外,圖形繼承體系的使用者調(diào)用函數(shù)時(shí)雖然不再需要一次間接的根據(jù)虛擬函數(shù)表找尋虛擬函數(shù)指針的操作,但是再調(diào)用之 前,仍然需要一個(gè)switch語句對其類型進(jìn)行識別。 綜上所述,這里列舉的5條虛擬函數(shù)帶來的缺陷只剩下兩條,即虛擬函數(shù)表的空間開銷及無法利用"內(nèi)聯(lián)函數(shù)"的速度優(yōu)勢。再考慮虛擬函數(shù)表,每一個(gè)含有虛擬函數(shù)的類在整個(gè)程序中只會(huì)有一個(gè)虛擬函數(shù)表??梢韵胂竦教摂M函數(shù)表引起的空間開銷實(shí)際上是非常小的,幾乎可以忽略不計(jì)。 這樣可以得出結(jié)論,即虛擬函數(shù)引入的性能缺陷只是無法利用內(nèi)聯(lián)函數(shù)。 可以進(jìn)一步設(shè)想,非虛擬函數(shù)的常規(guī)設(shè)計(jì)假如需要增加一種新的圖形類型,或者刪除一種不再支持的圖形類型,都必須修改該圖形系統(tǒng)所有使用者的所有與類 型相關(guān)的函數(shù)調(diào)用的代碼。這里使用者只有Canvas一個(gè),與類型相關(guān)的函數(shù)調(diào)用代碼也只有Paint和RotateSelected兩處。但是在一個(gè)復(fù) 雜的程序中,其使用者很多。并且類型相關(guān)的函數(shù)調(diào)用很多時(shí),每次對圖形系統(tǒng)的修改都會(huì)波及到這些使用者??梢钥闯霾皇褂锰摂M函數(shù)的常規(guī)設(shè)計(jì)增加了代碼的耦 合度,模塊化不強(qiáng),因此帶來的可擴(kuò)展性、可維護(hù)性,以及代碼的可讀性方面都極大降低。面向?qū)ο缶幊痰囊粋€(gè)重要目的就是增加程序的可擴(kuò)展性和可維護(hù)性,即當(dāng) 程序的業(yè)務(wù)邏輯發(fā)生變化時(shí),對原有程序的修改非常方便。而不至于對原有代碼大動(dòng)干戈,從而降低因?yàn)闃I(yè)務(wù)邏輯的改變而增加出錯(cuò)的可能性。根據(jù)這點(diǎn)分析,虛擬 函數(shù)可以大大提升程序的可擴(kuò)展性及可維護(hù)性。 因此在性能和其他方面特性的選擇方面,需要開發(fā)人員根據(jù)實(shí)際情況進(jìn)行權(quán)衡和取舍。當(dāng)然在權(quán)衡之前,需要通過性能檢測確認(rèn)性能的瓶頸是由于虛擬函數(shù)沒有利用到內(nèi)聯(lián)函數(shù)的優(yōu)勢這一缺陷引起;否則可以不必考慮虛擬函數(shù)的影響。
從2.1節(jié)"構(gòu)造函數(shù)和析構(gòu)函數(shù)"中已經(jīng)知道,對象的創(chuàng)建與銷毀對程序的性能影響很大。尤其當(dāng)該對象的類處于一個(gè)復(fù)雜繼承體系的末端,或者該對象包 含很多成員變量對象(包括其所有父類對象,即直接或者間接父類的所有成員變量對象)時(shí),對程序性能影響尤其顯著。因此作為一個(gè)對性能敏感的開發(fā)人員,應(yīng)該 盡量避免創(chuàng)建不必要的對象,以及隨后的銷毀。這里"避免創(chuàng)建不必要的對象",不僅僅意味著在編程時(shí),主要減少顯式出現(xiàn)在源碼中的對象創(chuàng)建。還有在編譯過程 中,編譯器在某些特殊情況下生成的開發(fā)人員看不見的隱式的對象。這些對象的創(chuàng)建并不出現(xiàn)在源碼級別,而是由編譯器在編譯過程中"悄悄"創(chuàng)建(往往為了某些 特殊操作),并在適當(dāng)時(shí)銷毀,這些就是所謂的"臨時(shí)對象"。需要注意的是,臨時(shí)對象與通常意義上的臨時(shí)變量是完全不同的兩個(gè)概念,比如下面的代碼:
習(xí)慣稱①句中的temp為臨時(shí)變量,其目的是為了暫時(shí)存放指針px指向的int型值。但是它并不是這里要考察的"臨時(shí)對象",不僅僅是因?yàn)橐话汩_發(fā) 人員不習(xí)慣稱一個(gè)內(nèi)建類型的變量為"對象"(所以不算臨時(shí)"對象")。而且因?yàn)閠emp出現(xiàn)在了源碼中,這里考察的臨時(shí)對象并不會(huì)出現(xiàn)在源碼中。 到底什么才是臨時(shí)對象?它們在什么時(shí)候產(chǎn)生?其生命周期有什么特征?在回答這些問題之前,首先來看下面這段代碼:
分析代碼,③處生成3個(gè)Matrix對象a,b,c,調(diào)用3次Matrix構(gòu)造函數(shù)。④處調(diào)用operator+(const Matrix&, const Matrix&)執(zhí)行到①處時(shí)生成臨時(shí)變量(注意此處的sum并不是"臨時(shí)對象"),調(diào)用一次Matrix構(gòu)造函數(shù)。④處c = a + b最后將a + b的結(jié)果賦值給c,調(diào)用的是賦值操作,而不會(huì)生成新的Matrix對象,因此從源碼分析,此段代碼共生成4個(gè)Matrix對象。 但是輸出結(jié)果:
①、②、③3處輸出分別對應(yīng)對象a、b和c的構(gòu)造,④處輸出對應(yīng)的是operator+(const Matrix&, const Matrix&)中sum的構(gòu)造,⑥處輸出對應(yīng)的是c = a + b句中最后用a + b的結(jié)果向c賦值,那么⑤處輸出對應(yīng)哪個(gè)對象? 答案是在這段代碼中,編譯器生成了一個(gè)"臨時(shí)對象"。 a + b實(shí)際上是執(zhí)行operator+(const Matrix& arg1, const Matrix& arg2),重載的操作符本質(zhì)上是一個(gè)函數(shù),這里a和b就是此函數(shù)的兩個(gè)變量。此函數(shù)返回一個(gè)Matrix變量,然后進(jìn)一步將此變量通過 Matrix::operator=(const Matrix& mt)對c進(jìn)行賦值。因?yàn)閍 + b返回時(shí),其中的sum已經(jīng)結(jié)束了其生命周期。即在operator+(const Matrix& arg1, const Matrix& arg2)結(jié)束時(shí)被銷毀,那么其返回的Matrix對象需要在調(diào)用a + b函數(shù)(這里是main()函數(shù))的棧中開辟空間用來存放此返回值。這個(gè)臨時(shí)的Matrix對象是在a + b返回時(shí)通過Matrix拷貝構(gòu)造函數(shù)構(gòu)造,即⑤處的輸出。 既然如上所述,創(chuàng)建和銷毀對象經(jīng)常會(huì)成為一個(gè)程序的性能瓶頸所在,那么有必要對臨時(shí)對象產(chǎn)生的原因進(jìn)行深入探究,并在不損害程序功能的前提下盡可能地規(guī)避它。 臨時(shí)對象在C++語言中的特征是未出現(xiàn)在源代碼中,從堆棧中產(chǎn)生的未命名對象。這里需要特別注意的是,臨時(shí)對象并不出現(xiàn)在源代碼中。即開發(fā)人員并沒有聲明要使用它們,沒有為其聲明變量。它們由編譯器根據(jù)情況產(chǎn)生,而且開發(fā)人員往往都不會(huì)意識到它們的產(chǎn)生。 產(chǎn)生臨時(shí)對象一般來說有如下兩種場合。 (1)當(dāng)實(shí)際調(diào)用函數(shù)時(shí)傳入的參數(shù)與函數(shù)定義中聲明的變量類型不匹配。 (2)當(dāng)函數(shù)返回一個(gè)對象時(shí)(這種情形下也有例外,下面會(huì)講到)。 另外,也有很多開發(fā)人員認(rèn)為當(dāng)函數(shù)傳入?yún)?shù)為對象,并且實(shí)際調(diào)用時(shí)因?yàn)楹瘮?shù)體內(nèi)的該對象實(shí)際上并不是傳入的對象,而是該傳入對象的一份拷貝,所以認(rèn) 為這時(shí)函數(shù)體內(nèi)的那個(gè)拷貝的對象也應(yīng)該是一個(gè)臨時(shí)對象。但是嚴(yán)格說來,這個(gè)拷貝對象并不符合"未出現(xiàn)在源代碼中"這一特征。當(dāng)然只要能知道并意識到對象參 數(shù)的工作原理及背后隱含的性能特征,并能在編寫代碼時(shí)盡量規(guī)避之,那么也就沒有必要在字面上較真了,畢竟最終目的是寫出正確和高效的程序。 因?yàn)轭愋筒黄ヅ涠膳R時(shí)對象的情況,可以通過下面這段程序來認(rèn)識:
當(dāng)執(zhí)行②處代碼時(shí),因?yàn)镽ational類并沒有重載operator=(int i),所以此處編譯器會(huì)合成一個(gè)operator=(const Rational& r)。并且執(zhí)行逐位拷貝(bitwise copy)形式的賦值操作,但是右邊的一個(gè)整型常量100并不是一個(gè)Rational對象,初看此處無法通過編譯。但是,需要注意的一點(diǎn)是C++編譯器在 判定這種語句不能成功編譯前,總是盡可能地查找合適的轉(zhuǎn)換路徑,以滿足編譯的需要。這里,編譯器發(fā)現(xiàn)Rational類有一個(gè)如①處所示的 Rational(int a=0, int b=1)型的構(gòu)造函數(shù)。因?yàn)榇藰?gòu)造函數(shù)可以接受0、1或2個(gè)整數(shù)作為參數(shù),這時(shí)編譯器會(huì)"貼心"地首先將②式右邊的100通過調(diào)用 Rational::Rational(100, 1)生成一個(gè)臨時(shí)對象,然后用編譯器合成的逐位拷貝形式的賦值符對r對象進(jìn)行賦值。②處語句執(zhí)行后,r對象內(nèi)部的m為100,n為1。 從上面例子中,可以看到C++編譯器為了成功編譯某些語句,往往會(huì)在私底下"悄悄"地生成很多從源代碼中不易察覺的輔助函數(shù),甚至對象。比如上段代碼中,編譯器生成的賦值操作符、類型轉(zhuǎn)換,以及類型轉(zhuǎn)換的中間結(jié)果,即一個(gè)臨時(shí)對象。 很多時(shí)候,這種編譯器提供的自動(dòng)類型轉(zhuǎn)換確實(shí)提高了程序的可讀性,也在一定程度上簡化了程序的編寫,從而提高了開發(fā)速度。但是類型轉(zhuǎn)換意味著臨時(shí)對 象的產(chǎn)生,對象的創(chuàng)建和銷毀意味著性能的下降,類型轉(zhuǎn)換還意味著編譯器還需要生成額外的代碼等。因此在設(shè)計(jì)階段,預(yù)計(jì)到不需要編譯器提供這種自動(dòng)類型轉(zhuǎn)換 的便利時(shí),可以明確阻止這種自動(dòng)類型轉(zhuǎn)換的發(fā)生,即阻止因此而引起臨時(shí)對象的產(chǎn)生。這種明確阻止就是通過對類的構(gòu)造函數(shù)增加"explicit"聲明,如 上例中的代碼,可以通過如下聲明來阻止:
此段代碼編譯時(shí)在②處報(bào)一個(gè)錯(cuò)誤,即"binary ‘=‘ : no operator defined which takes a right-hand operand of type ‘const int‘ (or there is no acceptable conversion)",這個(gè)錯(cuò)誤說明編譯器無法將100轉(zhuǎn)換為一個(gè)Rational對象。編譯器合成的賦值運(yùn)算符只接受Rational對象,而不能 接受整型。編譯器要想能成功編譯②處語句,要么提供一個(gè)重載的"="運(yùn)算符,該運(yùn)算符接受整型作為參數(shù);要么能夠?qū)⒄娃D(zhuǎn)換為一個(gè)Rational對象, 然后進(jìn)一步利用編譯器合成的賦值運(yùn)算符。要想將整型轉(zhuǎn)換為一個(gè)Rational對象,一個(gè)辦法就是提供能只傳遞一個(gè)整型作為參數(shù)的Rational構(gòu)造函 數(shù)(不一定非要求該構(gòu)造函數(shù)只有一個(gè)整型參數(shù),因?yàn)榭紤]到默認(rèn)值的原因。如上面的例子,Rational的構(gòu)造函數(shù)接受兩個(gè)整型參數(shù)。但是因?yàn)槎加心J(rèn) 值,因此調(diào)用該構(gòu)造函數(shù)可以有3種方式,即無參、一個(gè)參數(shù)和兩個(gè)參數(shù)),這樣編譯器就可以用該整型數(shù)作為參數(shù)調(diào)用該構(gòu)造函數(shù)生成一個(gè)Rational對象 (臨時(shí)對象)。 但是上面沒有重載以整型為參數(shù)的"="操作符,雖然提供了一個(gè)能只傳入一個(gè)整型作為參數(shù)的構(gòu)造函數(shù),但是用"explicit"限制了此構(gòu)造函數(shù)。 因?yàn)閑xplicit的含義是開發(fā)人員只能顯式地根據(jù)這個(gè)構(gòu)造函數(shù)的定義調(diào)用,而不允許編譯器利用其來進(jìn)行隱式的類型轉(zhuǎn)換。這樣編譯器無辦法利用它來將 100轉(zhuǎn)換為一個(gè)臨時(shí)的Rational對象,②處語句也無法編譯。 上面提到,可以通過重載以整型為參數(shù)的"="操作符使②處成功編譯的目的,看這種方法:
如③處所示,重載了"="操作符。這樣當(dāng)編譯②處時(shí),編譯器發(fā)現(xiàn)右邊是一個(gè)整型數(shù),它首先尋找是否有與之匹配的重載的"="操作符。找到③處的聲明,及定義。這樣它利用③處來調(diào)用展開②處為r.Rational::operator=(100),順利通過編譯。 需要指出的是,重載"="操作符后達(dá)到了程序想要的效果,即程序的可讀性及代碼編寫的方便性。同時(shí)還有一個(gè)更重要的效果(對性能敏感的程序而言), 即成功避免了一個(gè)臨時(shí)對象的產(chǎn)生。因?yàn)?="操作符的實(shí)現(xiàn),僅僅是修改了被調(diào)用對象的內(nèi)部成員對象,整個(gè)過程中都不需要產(chǎn)生臨時(shí)對象。但是重載"="操作 符也增加了設(shè)計(jì)類Rational的成本,如果一個(gè)類可能會(huì)支持多種其他類型對它的轉(zhuǎn)換,則需要進(jìn)行多次重載,這無疑會(huì)使得這個(gè)類變得十分臃腫。同樣,如 果一個(gè)大型程序有很多這樣的類,那么因?yàn)榇a臃腫引起的維護(hù)難度也相應(yīng)會(huì)增加。 因此在設(shè)計(jì)階段,在兼顧程序的可讀性、代碼編寫時(shí)的方便性、性能,以及程序大小和可維護(hù)性時(shí),需要仔細(xì)分析和斟酌。尤其要對每個(gè)類在該應(yīng)用程序?qū)嶋H運(yùn)行時(shí)的調(diào)用次數(shù)及是否在性能關(guān)鍵路徑上等情況進(jìn)行預(yù)估和試驗(yàn),然后做到合理的折衷和權(quán)衡。 如前所述,還有一種情形往往導(dǎo)致臨時(shí)對象的產(chǎn)生,即當(dāng)一個(gè)函數(shù)返回的是某個(gè)非內(nèi)建類型的對象時(shí)。這時(shí)因?yàn)榉祷亟Y(jié)果(一個(gè)對象)必須要有一個(gè)地方存 放。所以編譯器會(huì)從調(diào)用該函數(shù)的函數(shù)棧楨中開辟空間,并用返回值作為參數(shù)調(diào)用該對象所屬類型的拷貝構(gòu)造函數(shù)在此空間中生成該對象。在被調(diào)用函數(shù)結(jié)束并返回 后,可以繼續(xù)利用此對象(返回值),如:
執(zhí)行①的處語句時(shí),相當(dāng)于在main函數(shù)中調(diào)用operator+(const Rational& a, const Rational& b)函數(shù)。在main函數(shù)棧中會(huì)開辟一塊Rational對象大小的空間。在operator+(const Rational& a, const Rational& b)函數(shù)的②處,函數(shù)返回被銷毀的temp對象為參數(shù)調(diào)用拷貝構(gòu)造函數(shù)在main函數(shù)棧中開辟的空間中生成一個(gè)Rational對象,然后在r=a+b 的"="部分執(zhí)行賦值運(yùn)算符操作,輸出如下:
但r在之前的默認(rèn)構(gòu)造后并沒有用到,此時(shí)可以將其生成延遲,如下所示:
這時(shí)輸出為:
已經(jīng)發(fā)現(xiàn),經(jīng)過簡單改寫,這段程序竟然減少了一次構(gòu)造函數(shù)和一次賦值操作。為什么?原來改寫后,在執(zhí)行①處時(shí)的行為發(fā)生了很大的變化。編譯器 對"="的解釋不再是賦值運(yùn)算符,而是對象r的初始化。在取得a+b的結(jié)果值時(shí),也不再需要在main函數(shù)棧楨中另外開辟空間。而是直接使用為r對象預(yù)留 的空間,即編譯器在執(zhí)行②處時(shí)直接使用temp作為參數(shù)調(diào)用了Rational的拷貝構(gòu)造函數(shù)對r對象進(jìn)行初始化。這樣,也消除了臨時(shí)對象的生成,以及原 本發(fā)生在①處的賦值運(yùn)算。 通過這個(gè)簡單的優(yōu)化,已經(jīng)消除了一個(gè)臨時(shí)對象的生成,也減少了一次函數(shù)調(diào)用(賦值操作符本質(zhì)上也是一個(gè)函數(shù))。這里已經(jīng)得到一個(gè)啟示,即對非內(nèi)建類型的對象,盡量將對象延遲到已經(jīng)確切知道其有效狀態(tài)時(shí)。這樣可以減少臨時(shí)對象的生成,如上面所示,應(yīng)寫為:
而不是:
當(dāng)然這里有一個(gè)前提,即在r = a + b調(diào)用之前未用到r,因此不必生成。再進(jìn)一步,已經(jīng)看到在operator+(const Rational& a, const Rational& b)實(shí)現(xiàn)中用到了一個(gè)局部對象temp,改寫如下:
這時(shí)輸出如下:
如上,確實(shí)消除了temp。這時(shí)編譯器在進(jìn)入operator+(const Rational& a, const Rational& b)時(shí)看到①處是一個(gè)初始化,而不是賦值。所以編譯器傳入?yún)?shù)時(shí),也傳入了在main函數(shù)棧楨中為對象r預(yù)留的空間地址。當(dāng)執(zhí)行到②處時(shí),實(shí)際上這個(gè)構(gòu)造 函數(shù)就是在r對象所處的空間內(nèi)進(jìn)行的,即構(gòu)造了r對象,這樣省去了用來臨時(shí)計(jì)算和存放結(jié)果的temp對象。 需要注意的是,這個(gè)做法需要與前一個(gè)優(yōu)化配合才有效。即a+b的結(jié)果用來初始化一個(gè)對象,而不是對一個(gè)已經(jīng)存在的對象進(jìn)行賦值操作,如果①處是:
那么operator+(const Rational& a, const Rational& b)的實(shí)現(xiàn)中雖然沒有用到temp對象,但是仍然會(huì)在調(diào)用函數(shù)(這里是main函數(shù))的棧楨中生成一個(gè)臨時(shí)對象用來存放計(jì)算結(jié)果,然后利用這個(gè)臨時(shí)對象對 r對象進(jìn)行賦值操作。 對于operator+(const Rational& a, const Rational& b)函數(shù),常??吹接腥缦抡{(diào)用習(xí)慣:
這種寫法也經(jīng)常會(huì)用下面這種寫法代替:
這兩種寫法除了個(gè)人習(xí)慣之外,在性能方面有無區(qū)別?回答是有區(qū)別。而且有時(shí)還會(huì)很大,視對象大小而定。因此設(shè)計(jì)某類時(shí),如果需要重載 operator+,最好也重載operator+=,并且考慮到維護(hù)性,operator+用operator+=來實(shí)現(xiàn)。這樣如果這個(gè)操作符的語義有 所改變需要修改時(shí),只需要修改一處即可。 對Rational類來說,一般operator+=的實(shí)現(xiàn)如下:
這里可以看到,與operator+不同,operator+=并沒有產(chǎn)生臨時(shí)變量,operator+則只有在返回值被用來初始化一個(gè)對象,而不 是對一個(gè)已經(jīng)生成的對象進(jìn)行賦值時(shí)才不產(chǎn)生臨時(shí)對象。而且往往返回值被用來賦值的情況并不少見,甚至比初始化的情況還要多。因此使用operator+= 不產(chǎn)生臨時(shí)對象,性能會(huì)比operator+要好,為此盡量使用語句:
而避免使用:
相應(yīng)地,也應(yīng)考慮到程序的代碼可維護(hù)性(易于修改,因?yàn)椴恍⌒牡男薷臅?huì)導(dǎo)致不一致等)。即盡量利用operator+=來實(shí)現(xiàn)operator+,如下:
同理,這個(gè)規(guī)律可以擴(kuò)展到-=、*=和/=等。 操作符中還有兩個(gè)比較特殊的,即++和--。它們都可以前置或者后置,比如i++和++i。二者的語義是有區(qū)別的,前者先將其值返回,然后其值增1;后者則是先將值增1,再返回其值。但當(dāng)不需要用到其值,即單獨(dú)使用時(shí),比如:
二者的語義則是一樣的,都是將原值增1。但是對于一個(gè)非內(nèi)建類型,在重載這兩個(gè)操作符后,單獨(dú)使用在性能方面是否有差別?來考察它們的實(shí)現(xiàn)。仍以 Rational類作為例子,假設(shè)++的語義為對分子(即m)增1,分母不變(暫且不考慮這種語義是否符合實(shí)際情況),那么兩個(gè)實(shí)現(xiàn)如下:
可以看到,因?yàn)榭紤]到后置++的語義,所以在實(shí)現(xiàn)中必須首先保留其原來的值。為此需要一個(gè)局部變量,如①處所示。然后值增1后,將保存其原值的局部 變量作為返回值返回。相比較而言,前置++的實(shí)現(xiàn)不會(huì)需要這樣一個(gè)局部變量。而且不僅如此,前置的++只需要將自身返回即可,因此只需返回一個(gè)引用;后 置++需要返回一個(gè)對象。已經(jīng)知道,函數(shù)返回值為一個(gè)對象時(shí),往往意味著需要生成一個(gè)臨時(shí)對象用來存放返回值。因此如果調(diào)用后置++,意味著需要多生成兩 個(gè)對象,分別是函數(shù)內(nèi)部的局部變量和存放返回值的臨時(shí)變量。 有鑒于此,對于非內(nèi)建類型,在保證程序語義正確的前提下應(yīng)該多用:
而避免使用:
同樣的規(guī)律也適用于前置--和后置--(與=/+=相同的理由,考慮到維護(hù)性,盡量用前置++來實(shí)現(xiàn)后置++)。 至此,已經(jīng)考察了臨時(shí)對象的含義、產(chǎn)生臨時(shí)對象的各種場合,以及一些避免臨時(shí)對象產(chǎn)生的方法。最后來查看臨時(shí)對象的生命周期。在C++規(guī)范中定義一個(gè)臨時(shí)對象的生命周期為從創(chuàng)建時(shí)開始,到包含創(chuàng)建它的最長語句執(zhí)行完畢,比如:
在①處,首先創(chuàng)建一個(gè)臨時(shí)對象存放a+b的值。然后從這個(gè)臨時(shí)string對象中通過c_str()函數(shù)得到其字符串內(nèi)容,賦給str。如果str的長度大于5,就會(huì)進(jìn)入if內(nèi)部,執(zhí)行②處語句。問題是,這時(shí)的str還合法否? 答案是否定的,因?yàn)榇娣臿+b值的臨時(shí)對象的生命在包含其創(chuàng)建的最長語句結(jié)束后也相應(yīng)結(jié)束了,這里是①處語句。當(dāng)執(zhí)行到②處時(shí),該臨時(shí)對象已經(jīng)不存在,指向它內(nèi)部字符串內(nèi)容的str指向的是一段已經(jīng)被回收的內(nèi)存。這時(shí)的結(jié)果是無法預(yù)測的,但肯定不是所期望的。 但這條規(guī)范也有一個(gè)特例,當(dāng)用一個(gè)臨時(shí)對象來初始化一個(gè)常量引用時(shí),該臨時(shí)對象的生命會(huì)持續(xù)到與綁定到其上的常量引用銷毀時(shí),如:
這時(shí)c這個(gè)常量string引用在①處綁定在存放a+b結(jié)果的臨時(shí)對象后,可以繼續(xù)在其使用域(scope)內(nèi)正常使用,如在②處語句中那樣。這是因?yàn)閏是一個(gè)常量引用,因?yàn)楸凰壎?。所以存放a+b的臨時(shí)對象并不會(huì)在①處語句執(zhí)行后銷毀,而是保持與c一樣的生命周期。
在C++語言的設(shè)計(jì)中,內(nèi)聯(lián)函數(shù)的引入可以說完全是為了性能的考慮。因此在編寫對性能要求比較高的C++程序時(shí),非常有必要仔細(xì)考量內(nèi)聯(lián)函數(shù)的使 用。所謂"內(nèi)聯(lián)",即將被調(diào)用函數(shù)的函數(shù)體代碼直接地整個(gè)插入到該函數(shù)被調(diào)用處,而不是通過call語句進(jìn)行。當(dāng)然,編譯器在真正進(jìn)行"內(nèi)聯(lián)"時(shí),因?yàn)榭?慮到被內(nèi)聯(lián)函數(shù)的傳入?yún)?shù)、自己的局部變量,以及返回值的因素,不僅僅只是進(jìn)行簡單的代碼拷貝,還需要做很多細(xì)致的工作,但大致思路如此。 開發(fā)人員可以有兩種方式告訴編譯器需要內(nèi)聯(lián)哪些類成員函數(shù),一種是在類的定義體外;一種是在類的定義體內(nèi)。 (1)當(dāng)在類的定義體外時(shí),需要在該成員函數(shù)的定義前面加"inline"關(guān)鍵字,顯式地告訴編譯器該函數(shù)在調(diào)用時(shí)需要"內(nèi)聯(lián)"處理,如:
(2)當(dāng)在類的定義體內(nèi)且聲明該成員函數(shù)時(shí),同時(shí)提供該成員函數(shù)的實(shí)現(xiàn)體。此時(shí),"inline"關(guān)鍵字并不是必需的,如:
當(dāng)普通函數(shù)(非類成員函數(shù))需要被內(nèi)聯(lián)時(shí),則只需要在函數(shù)的定義時(shí)前面加上"inline"關(guān)鍵字,如:
因?yàn)镃++是以"編譯單元"為單位編譯的,而一個(gè)編譯單元往往大致等于一個(gè)".cpp"文件。在實(shí)際編譯前,預(yù)處理器會(huì)將"#include"的各 頭文件的內(nèi)容(可能會(huì)有遞歸頭文件展開)完整地拷貝到cpp文件對應(yīng)位置處(另外還會(huì)進(jìn)行宏展開等操作)。預(yù)處理器處理后,編譯真正開始。一旦C++編譯 器開始編譯,它不會(huì)意識到其他cpp文件的存在。因此并不會(huì)參考其他cpp文件的內(nèi)容信息。聯(lián)想到內(nèi)聯(lián)的工作是由編譯器完成的,且內(nèi)聯(lián)的意思是將被調(diào)用內(nèi) 聯(lián)函數(shù)的函數(shù)體代碼直接代替對該內(nèi)聯(lián)函數(shù)的調(diào)用。這也就意味著,在編譯某個(gè)編譯單元時(shí),如果該編譯單元會(huì)調(diào)用到某個(gè)內(nèi)聯(lián)函數(shù),那么該內(nèi)聯(lián)函數(shù)的函數(shù)定義 (即函數(shù)體)必須也包含在該編譯單元內(nèi)。因?yàn)榫幾g器使用內(nèi)聯(lián)函數(shù)體代碼替代內(nèi)聯(lián)函數(shù)調(diào)用時(shí),必須知道該內(nèi)聯(lián)函數(shù)的函數(shù)體代碼,而且不能通過參考其他編譯單 元信息來獲得這一信息。 如果有多個(gè)編譯單元會(huì)調(diào)用到某同一個(gè)內(nèi)聯(lián)函數(shù),C++規(guī)范要求在這多個(gè)編譯單元中該內(nèi)聯(lián)函數(shù)的定義必須是完全一致的,這就是"ODR"(one- definition rule)原則??紤]到代碼的可維護(hù)性,最好將內(nèi)聯(lián)函數(shù)的定義放在一個(gè)頭文件中,用到該內(nèi)聯(lián)函數(shù)的各個(gè)編譯單元只需#include該頭文件即可。進(jìn)一步 考慮,如果該內(nèi)聯(lián)函數(shù)是一個(gè)類的成員函數(shù),這個(gè)頭文件正好可以是該成員函數(shù)所屬類的聲明所在的頭文件。這樣看來,類成員內(nèi)聯(lián)函數(shù)的兩種聲明可以看成是幾乎 一樣的,雖然一個(gè)是在類外,一個(gè)在類內(nèi)。但是兩個(gè)都在同一個(gè)頭文件中,編譯器都能在#include該頭文件后直接取得內(nèi)聯(lián)函數(shù)的函數(shù)體代碼。討論完如何 聲明一個(gè)內(nèi)聯(lián)函數(shù),來查看編譯器如何內(nèi)聯(lián)的。繼續(xù)上面的例子,假設(shè)有個(gè)foo函數(shù):
foo函數(shù)進(jìn)入foo函數(shù)時(shí),從其棧幀中開辟了放置abc對象的空間。進(jìn)入函數(shù)體后,首先對該處空間執(zhí)行Student的默認(rèn)構(gòu)造函數(shù)構(gòu)造abc對 象。然后將常數(shù)12壓棧,調(diào)用abc的SetAge函數(shù)(開辟SetAge函數(shù)自己的棧幀,返回時(shí)回退銷毀此棧幀)。緊跟著執(zhí)行abc的GetAge函 數(shù),并將返回值壓棧。最后調(diào)用cout的<<操作符操作壓棧的結(jié)果,即輸出。 內(nèi)聯(lián)后大致如下:
這時(shí),函數(shù)調(diào)用時(shí)的參數(shù)壓棧、棧幀開辟與銷毀等操作不再需要,而且在結(jié)合這些代碼后,編譯器能進(jìn)一步優(yōu)化為如下結(jié)果:
這顯然是最好的優(yōu)化結(jié)果;相反,考慮原始版本。如果SetAge/GetAge沒有被內(nèi)聯(lián),因?yàn)榉莾?nèi)聯(lián)函數(shù)一般不會(huì)在頭文件中定義,這兩個(gè)函數(shù)可能 在這個(gè)編譯單元之外的其他編譯單元中定義。即foo函數(shù)所在編譯單元看不到SetAge/GetAge,不知道函數(shù)體代碼信息,那么編譯器傳入12給 SetAge,然后用GetAge輸出。在這一過程中,編譯器不能確信最后GetAge的輸出。因?yàn)榫幾g這個(gè)編譯單元時(shí),不知道這兩個(gè)函數(shù)的函數(shù)體代碼, 因而也就不能做出最終版本的優(yōu)化。 從上述分析中,可以看到使用內(nèi)聯(lián)函數(shù)至少有如下兩個(gè)優(yōu)點(diǎn)。 (1)減少因?yàn)楹瘮?shù)調(diào)用引起開銷,主要是參數(shù)壓棧、棧幀開辟與回收,以及寄存器保存與恢復(fù)等。 (2)內(nèi)聯(lián)后編譯器在處理調(diào)用內(nèi)聯(lián)函數(shù)的函數(shù)(如上例中的foo()函數(shù))時(shí),因?yàn)榭晒┓治龅拇a更多,因此它能做的優(yōu)化更深入徹底。前一條優(yōu)點(diǎn)對于開發(fā)人員來說往往更顯而易見一些,但往往這條優(yōu)點(diǎn)對最終代碼的優(yōu)化可能貢獻(xiàn)更大。 這時(shí),有必要簡單介紹函數(shù)調(diào)用時(shí)都需要執(zhí)行哪些操作,這樣可以幫助分析一些函數(shù)調(diào)用相關(guān)的問題。假設(shè)下面代碼:
調(diào)用者(這里是foo)在調(diào)用前需要執(zhí)行如下操作。 (1)參數(shù)壓棧:這里是a、b和c。壓棧時(shí)一般都是按照逆序,因此是c->b->c。如果a、b和c有對象,則需要先進(jìn)行拷貝構(gòu)造(前面章節(jié)已經(jīng)討論)。 (2)保存返回地址:即函數(shù)調(diào)用結(jié)束返回后接著執(zhí)行的語句的地址,這里是②處語句的地址。 (3)保存維護(hù)foo函數(shù)棧幀信息的寄存器內(nèi)容:如SP(堆棧指針)和FP(棧幀指針)等。到底保存哪些寄存器與平臺相關(guān),但是每個(gè)平臺肯定都會(huì)有對應(yīng)的寄存器。 (4)保存一些通用寄存器的內(nèi)容:因?yàn)橛行┩ㄓ眉拇嫫鲿?huì)被所有函數(shù)用到,所以在foo調(diào)用func之前,這些寄存器可能已經(jīng)放置了對foo有用的信 息。這些寄存器在進(jìn)入func函數(shù)體內(nèi)執(zhí)行時(shí)可能會(huì)被func用到,從而被覆寫。因此foo在調(diào)用func前保存一份這些通用寄存器的內(nèi)容,這樣在 func返回后可以恢復(fù)它們。 接著調(diào)用func函數(shù),它首先通過移動(dòng)棧指針來分配所有在其內(nèi)部聲明的局部變量所需的空間,然后執(zhí)行其函數(shù)體內(nèi)的代碼等。 最后當(dāng)func執(zhí)行完畢,函數(shù)返回時(shí),foo函數(shù)還需要執(zhí)行如下善后處理。 (1)恢復(fù)通用寄存器的值。 (2)恢復(fù)保存foo函數(shù)棧幀信息的那些寄存器的值。 (3)通過移動(dòng)棧指針,銷毀func函數(shù)的棧幀, (4)將保存的返回地址出棧,并賦給IP寄存器。 (5)通過移動(dòng)棧指針,回收傳給func函數(shù)的參數(shù)所占用的空間。 在前面章節(jié)中已經(jīng)討論,如果傳入?yún)?shù)和返回值為對象時(shí),還會(huì)涉及對象的構(gòu)造與析構(gòu),函數(shù)調(diào)用的開銷就會(huì)更大。尤其是當(dāng)傳入對象和返回對象是復(fù)雜的大對象時(shí),更是如此。 因?yàn)楹瘮?shù)調(diào)用的準(zhǔn)備與善后工作最終都是由機(jī)器指令完成的,假設(shè)一個(gè)函數(shù)之前的準(zhǔn)備工作與之后的善后工作的指令所需的空間為SS,執(zhí)行這些代碼所需的時(shí)間為TS,現(xiàn)在可以更細(xì)致地從空間與時(shí)間兩個(gè)方面來分析內(nèi)聯(lián)的效果。 (1)在空間上,一般印象是不采用內(nèi)聯(lián),被調(diào)用函數(shù)的代碼只有一份,調(diào)用它的地方使用call語句引用即可。而采用內(nèi)聯(lián)后,該函數(shù)的代碼在所有調(diào)用 其處都有一份拷貝,因此最后總的代碼大小比采用內(nèi)聯(lián)前要大。但事實(shí)不總是這樣的,如果一個(gè)函數(shù)a的體代碼大小為AS,假設(shè)a函數(shù)在整個(gè)程序中被調(diào)用了n 次,不采用內(nèi)聯(lián)時(shí),對a的調(diào)用只有準(zhǔn)備工作與善后工作兩處會(huì)增加最后的代碼量開銷,即a函數(shù)相關(guān)的代碼大小為:n * SS + AS。采用內(nèi)聯(lián)后,在各處調(diào)用點(diǎn)都需要將其函數(shù)體代碼展開,即a函數(shù)相關(guān)的代碼大小為n * AS。這樣比較二者的大小,即比較(n * SS + AS)與(n*AS)的大小??紤]到n一般次數(shù)很多時(shí),可以簡化成比較SS與AS的大小。這樣可以得出大致結(jié)論,如果被內(nèi)聯(lián)函數(shù)自己的函數(shù)體代碼量比因?yàn)?函數(shù)調(diào)用的準(zhǔn)備與善后工作引入的代碼量大,內(nèi)聯(lián)后程序的代碼量會(huì)變大;相反,當(dāng)被內(nèi)聯(lián)函數(shù)的函數(shù)體代碼量比因?yàn)楹瘮?shù)調(diào)用的準(zhǔn)備與善后工作引入的代碼量小, 內(nèi)聯(lián)后程序的代碼量會(huì)變小。這里還沒有考慮內(nèi)聯(lián)的后續(xù)情況,即編譯器可能因?yàn)楂@得的信息更多,從而對調(diào)用函數(shù)的優(yōu)化做得更深入和徹底,致使最終的代碼量變 得更小。 (2)在時(shí)間上,一般而言,每處調(diào)用都不再需要做函數(shù)調(diào)用的準(zhǔn)備與善后工作。另外內(nèi)聯(lián)后,編譯器在做優(yōu)化時(shí),看到的是調(diào)用函數(shù)與被調(diào)用函數(shù)連成的一 大塊代碼。即獲得的代碼信息更多,此時(shí)它對調(diào)用函數(shù)的優(yōu)化可以做得更好。最后還有一個(gè)很重要的因素,即內(nèi)聯(lián)后調(diào)用函數(shù)體內(nèi)需要執(zhí)行的代碼是相鄰的,其執(zhí)行 的代碼都在同一個(gè)頁面或連續(xù)的頁面中。如果沒有內(nèi)聯(lián),執(zhí)行到被調(diào)用函數(shù)時(shí),需要跳到包含被調(diào)用函數(shù)的內(nèi)存頁面中執(zhí)行,而被調(diào)用函數(shù)所屬的頁面極有可能當(dāng)時(shí) 不在物理內(nèi)存中。這意味著,內(nèi)聯(lián)后可以降低"缺頁"的幾率,知道減少"缺頁"次數(shù)的效果遠(yuǎn)比減少一些代碼量執(zhí)行的效果。另外即使被調(diào)用函數(shù)所在頁面可能也 在內(nèi)存中,但是因?yàn)榕c調(diào)用函數(shù)在空間上相隔甚遠(yuǎn),所以可能會(huì)引起"cache miss",從而降低執(zhí)行速度。因此總的來說,內(nèi)聯(lián)后程序的執(zhí)行時(shí)間會(huì)比沒有內(nèi)聯(lián)要少。即程序的速度更快,這也是因?yàn)閮?nèi)聯(lián)后代碼的空 間"locality"特性提高了。但正如上面分析空間影響時(shí)提到的,當(dāng)AS遠(yuǎn)大于SS,且n非常大時(shí),最終程序的大小會(huì)比沒有內(nèi)聯(lián)時(shí)要大很多。代碼量大 意味著用來存放代碼的內(nèi)存頁也會(huì)更多,這樣因?yàn)閳?zhí)行代碼而引起的"缺頁"也會(huì)相應(yīng)增多。如果這樣,最終程序的執(zhí)行時(shí)間可能會(huì)因?yàn)榇罅康?缺頁"而變得更 多,即程序的速度變慢。這也是為什么很多編譯器對于函數(shù)體代碼很多的函數(shù),會(huì)拒絕對其進(jìn)行內(nèi)聯(lián)的請求。即忽略"inline"關(guān)鍵字,而對如同普通函數(shù)那 樣編譯。 綜合上面的分析,在采用內(nèi)聯(lián)時(shí)需要內(nèi)聯(lián)函數(shù)的特征。比如該函數(shù)自己的函數(shù)體代碼量,以及程序執(zhí)行時(shí)可能被調(diào)用的次數(shù)等。當(dāng)然,判斷內(nèi)聯(lián)效果的最終和最有效的方法還是對程序的大小和執(zhí)行時(shí)間進(jìn)行實(shí)際測量,然后根據(jù)測量結(jié)果來決定是否應(yīng)該采用內(nèi)聯(lián),以及對哪些函數(shù)進(jìn)行內(nèi)聯(lián)。 如下根據(jù)內(nèi)聯(lián)的本質(zhì)來討論與其相關(guān)的一些其他特點(diǎn)。 如前所述,因?yàn)檎{(diào)用內(nèi)聯(lián)函數(shù)的編譯單元必須有內(nèi)聯(lián)函數(shù)的函數(shù)體代碼信息。又因?yàn)镺DR規(guī)則和考慮到代碼的可維護(hù)性,所以一般將內(nèi)聯(lián)函數(shù)的定義放在一 個(gè)頭文件中,然后在每個(gè)調(diào)用該內(nèi)聯(lián)函數(shù)的編譯單元中#include該頭文件。現(xiàn)在考慮這種情況,即在一個(gè)大型程序中,某個(gè)內(nèi)聯(lián)函數(shù)因?yàn)榉浅Mㄓ茫淮?多數(shù)編譯單元用到對該內(nèi)聯(lián)函數(shù)的一個(gè)修改,就會(huì)引起所有用到它的編譯單元的重新編譯。對于一個(gè)真正的大型程序,重新編譯大部分編譯單元往往意味著大量的編 譯時(shí)間。因此內(nèi)聯(lián)最好在開發(fā)的后期引入,以避免可能不必要的大量編譯時(shí)間的浪費(fèi)。 再考慮這種情況,如果某開發(fā)小組在開發(fā)中用到了第三方提供的程序庫,而這些程序庫中包含一些內(nèi)聯(lián)函數(shù)。因?yàn)樵撻_發(fā)小組的代碼中在用到第三方提供的內(nèi) 聯(lián)函數(shù)處,都是將該內(nèi)聯(lián)函數(shù)的函數(shù)體代碼拷貝到調(diào)用處,即該開發(fā)小組的代碼中包含了第三方提供代碼的"實(shí)現(xiàn)"。假設(shè)這個(gè)第三方單位在下一個(gè)版本中修改了某 些內(nèi)聯(lián)函數(shù)的定義,那么雖然這個(gè)第三方單位并沒有修改任何函數(shù)的對外接口,而只是修改了實(shí)現(xiàn),該開發(fā)小組要想利用這個(gè)新的版本,仍然需要重新編譯。考慮到 可能該開發(fā)小組的程序已經(jīng)發(fā)布,那么這種重新編譯的成本會(huì)相當(dāng)高;相反,如果沒有內(nèi)聯(lián),并且仍然只是修改實(shí)現(xiàn),那么該開發(fā)小組不必重新編譯即可利用新的版 本。 因?yàn)閮?nèi)聯(lián)的本質(zhì)就是用函數(shù)體代碼代替對該函數(shù)的調(diào)用,所以考慮遞歸函數(shù),如:
如果編譯器編譯某個(gè)調(diào)用此函數(shù)的編譯單元,如:
考慮如下兩種情況。 (1)如果在編譯該編譯單元且調(diào)用foo時(shí),提供的參數(shù)n不能知道其實(shí)際值,則編譯器無法知道對foo函數(shù)體進(jìn)行多少次代替。在這種情況下,編譯器會(huì)拒絕對foo函數(shù)進(jìn)行內(nèi)聯(lián)。 (2)如果在編譯該編譯單元且調(diào)用foo時(shí),提供的參數(shù)n能夠知道其實(shí)際值,則編譯器可能會(huì)視n值的大小來決定是否對foo函數(shù)進(jìn)行內(nèi)聯(lián)。因?yàn)槿绻鹡很大,內(nèi)聯(lián)展開可能會(huì)使最終程序的大小變得很大。 如前所述,因?yàn)閮?nèi)聯(lián)函數(shù)是編譯期行為,而虛擬函數(shù)是執(zhí)行期行為,因此編譯器一般會(huì)拒絕對虛擬函數(shù)進(jìn)行內(nèi)聯(lián)的請求。但是事情總有例外,內(nèi)聯(lián)函數(shù)的本質(zhì) 是編譯器編譯調(diào)用某函數(shù)時(shí),將其函數(shù)體代碼代替call調(diào)用,即內(nèi)聯(lián)的條件是編譯器能夠知道該處函數(shù)調(diào)用的函數(shù)體。而虛擬函數(shù)不能夠被內(nèi)聯(lián),也是因?yàn)樵诰?譯時(shí)一般來說編譯器無法知道該虛擬函數(shù)到底是哪一個(gè)版本,即無法確定其函數(shù)體。但是在兩種情況下,編譯器是能夠知道虛擬函數(shù)調(diào)用的真實(shí)版本的,因此虛擬函 數(shù)可以被內(nèi)聯(lián)。 其一是通過對象,而不是指向?qū)ο蟮闹羔樆蛘邔ο蟮囊谜{(diào)用虛擬函數(shù),這時(shí)編譯器在編譯期就已經(jīng)知道對象的確切類型。因此會(huì)直接調(diào)用確定的某虛擬函數(shù)實(shí)現(xiàn)版本,而不會(huì)產(chǎn)生"動(dòng)態(tài)綁定"行為的代碼。 其二是雖然是通過對象指針或者對象引用調(diào)用虛擬函數(shù),但是編譯時(shí)編譯器能知道該指針或引用對應(yīng)到的對象的確切類型。比如在產(chǎn)生的新對象時(shí)做的指針賦 值或引用初始化,發(fā)生在于通過該指針或引用調(diào)用虛擬函數(shù)同一個(gè)編譯單元并且二者之間該指針沒有被改變賦值使其指向到其他不能確切知道類型的對象(因?yàn)橐?不能修改綁定,因此無此之虞)。此時(shí)編譯器也不會(huì)產(chǎn)生動(dòng)態(tài)綁定的代碼,而是直接調(diào)用該確定類型的虛擬函數(shù)實(shí)現(xiàn)版本。 在這兩種情況下,編譯器能夠?qū)⒋颂摂M函數(shù)內(nèi)聯(lián)化,如:
當(dāng)然在實(shí)際開發(fā)中,通過這兩種方式調(diào)用虛擬函數(shù)時(shí)應(yīng)該非常少,因?yàn)樘摂M函數(shù)的語義是"通過基類指針或引用調(diào)用,到真正運(yùn)行時(shí)才決定調(diào)用哪個(gè)版本"。 從上面的分析中已經(jīng)看到,編譯器并不總是尊重"inline"關(guān)鍵字。即使某個(gè)函數(shù)用"inline"關(guān)鍵字修飾,并不能夠保證該函數(shù)在編譯時(shí)真正 被內(nèi)聯(lián)處理。因此與register關(guān)鍵字性質(zhì)類似,inline僅僅是給編譯器的一個(gè)"建議",編譯器完全可以視實(shí)際情況而忽略之。 另外從內(nèi)聯(lián),即用函數(shù)體代碼替代對該函數(shù)的調(diào)用這一本質(zhì)看,它與C語言中的函數(shù)宏(macro)極其相似,但是它們之間也有本質(zhì)的區(qū)別。即內(nèi)聯(lián)是編 譯期行為,宏是預(yù)處理期行為,其替代展開由預(yù)處理器來做。也就是說編譯器看不到宏,更不可能處理宏。另外宏的參數(shù)在其宏體內(nèi)出現(xiàn)兩次或兩次以上時(shí)經(jīng)常會(huì)產(chǎn) 生副作用,尤其是當(dāng)在宏體內(nèi)對參數(shù)進(jìn)行++或 操作時(shí),而內(nèi)聯(lián)不會(huì)。還有,預(yù)處理器不會(huì)也不能對宏的參數(shù)進(jìn)行類型檢查。而內(nèi)聯(lián)因?yàn)槭蔷幾g器處理的,因此會(huì)對內(nèi)聯(lián)函數(shù)的參數(shù)進(jìn)行類型檢查,這對于寫出正確 且魯棒的程序,是一個(gè)很大的優(yōu)勢。最后,宏肯定會(huì)被展開,而用inline關(guān)鍵字修飾的函數(shù)不一定會(huì)被內(nèi)聯(lián)展開。 最后順帶提及,一個(gè)程序的惟一入口main()函數(shù)肯定不會(huì)被內(nèi)聯(lián)化。另外,編譯器合成的默認(rèn)構(gòu)造函數(shù)、拷貝構(gòu)造函數(shù)、析構(gòu)函數(shù),以及賦值運(yùn)算符一般都會(huì)被內(nèi)聯(lián)化。
相對C語言而言,C++語言確實(shí)引入了很多新的語言特性。而很多開發(fā)人員在遇到用C++語言編寫的應(yīng)用程序性能問題時(shí),也往往會(huì)傾向于將性能問題歸 咎于這些新的語言特性,但實(shí)際情形往往并不是這樣的。對待性能問題,我們應(yīng)該采取一個(gè)客觀的態(tài)度。在遇到性能問題并做出真正的性能測量之前,不要輕易假定 瓶頸所在。往往很多時(shí)候,應(yīng)用程序的性能是因?yàn)樵摮绦虻墓δ芎蛷?fù)雜度引起的,而非語言特性本身。如果實(shí)際的性能測量證明瓶頸確實(shí)是因?yàn)槟承┱Z言特性引起 的,這時(shí)需要對該語言特性的使用場合進(jìn)行仔細(xì)分析,然后在不損害其帶來的設(shè)計(jì)任務(wù)的前提下進(jìn)行性能改善。本章著重分析了幾個(gè)可能會(huì)對性能引起下降的語言特 性,包括構(gòu)造函數(shù)/析構(gòu)函數(shù)、繼承與虛擬、臨時(shí)對象,以及內(nèi)聯(lián)函數(shù),對它們的深刻理解常常能夠在編碼階段避免很多性能問題。
|
原文地址http://www.ibm.com/developerworks/cn/linux/l-cn-ppp/index2.html