王 威,孫曉范,楊志敏
(1.山東外事翻譯職業(yè)學(xué)院,山東 威海 264504;2.山東大學(xué) 機電與信息工程學(xué)院,山東 威海 264209)
C++高層次抽象效率分析
王 威1,孫曉范1,楊志敏2
(1.山東外事翻譯職業(yè)學(xué)院,山東 威海 264504;2.山東大學(xué) 機電與信息工程學(xué)院,山東 威海 264209)
一開始C++是作為C語言的增強版出現(xiàn)的,從增加類開始,C語言不斷地增加新特性。在學(xué)習(xí)C++時可以將其作為一門獨立的語言,因為其并不依賴于C語言。《Thinking in C++》認(rèn)為在運行效率上往往有一個±5%的差異。有說法認(rèn)為高層次的抽象功能導(dǎo)致了C++的效率下降。為驗證這個問題,文章就一些有代表性的案例在封裝、繼承和多態(tài)的性能,并部分與C和Java語言進(jìn)行的比較,對引起效率下降的可能原因作出總結(jié),并給予相應(yīng)的解決方案。
C++;高層次抽象;面向?qū)ο螅痪幾g優(yōu)化
C++有3種編程范型:面向過程編程、面向?qū)ο缶幊毯头盒途幊蹋瑢?yīng)3個抽象級別:過程抽象、數(shù)據(jù)抽象和模板[1]。高層次抽象涉及的是后面兩項。一般來說,抽象水平越高,越有利于分析和處理實際問題。但同時可能會導(dǎo)致程序的性能下降。面向?qū)ο缶幊绦枰褂妙惖姆庋b、繼承和多態(tài)。封裝需要預(yù)先定義數(shù)據(jù)成員和類成員,使得程序變得累贅和臃腫。類的繼承則加劇了這種麻煩,因為子類都要從其基類繼承類的成員。這使得高層次抽象看起來比面向過程編程在編程空間上負(fù)擔(dān)更加沉重。另一方面,多態(tài)意味著我們必須花費額外的時間做動態(tài)數(shù)據(jù)類型綁定。C++在盡可能地確保運行效率的前提下提供高層次抽象。在這里我們通過測試應(yīng)用程序分析C++的抽象機制并不時與其他語言做比較,來驗證高層次抽象的表現(xiàn)。
C++支持各種不同的編程方法,這就是所謂的多范式編程,允許程序員針對不同的問題選擇適當(dāng)?shù)哪J健_@里我們只討論高層次抽象的部分,對應(yīng)數(shù)據(jù)抽象和模板。
面向?qū)ο缶幊虣C制包括封裝、繼承和多態(tài),還有友元和內(nèi)聯(lián)機制。
1.1.1 封裝和繼承
封裝主要靠類實現(xiàn)。類的實例被稱為對象,是面向?qū)ο缶幊痰幕A(chǔ)。一個類通常包含大量的數(shù)據(jù)成員和函數(shù)成員,使得其看起來像使用過多的內(nèi)存空間。事實上,C++只為對象的數(shù)據(jù)成員分配一個單獨的存儲空間。編譯器不將數(shù)據(jù)成員歸入對象。
編譯和運行如圖1所示中的代碼,可以得到結(jié)果是8。這里有兩個數(shù)據(jù)成員,一個函數(shù)成員和一個友元類,但只有數(shù)據(jù)成員消耗存儲空間。一個int類型的a占用4個字節(jié),一個bool類型的b占用1個字節(jié),根據(jù)C++中的對齊原則,系統(tǒng)分配另外4個字節(jié)以保持空間容易讀寫。我們可以看到函數(shù)成員和友元類的空間分配[2]。如果有一個從B派生出的子類,有自己的新數(shù)據(jù)成員和函數(shù)成員,同樣也會有從B中繼承的成員。但是我們可以驗證系統(tǒng)只為子類中新添加的數(shù)據(jù)成員和從B中派生的成員分配空間。因此其和C語言中具有同樣數(shù)據(jù)成員的structure占據(jù)同樣的內(nèi)存空間。

圖1 一個典型的類
C++中對函數(shù)成員的處理是做對象到函數(shù)的映射。這是在編譯時進(jìn)行的所以是靜態(tài)綁定。函數(shù)成員通過隱藏的this指針訪問數(shù)據(jù)成員,這也是唯一可能降低時間效率的因素。但是在C語言中只包含數(shù)據(jù)成員和一個獨立的函數(shù)來實現(xiàn)相同功能的structure同樣也需要一個指針來傳遞給構(gòu)造函數(shù)。因此在時間開銷上,使用類的C++程序和使用相同功能結(jié)構(gòu)的C語言程序沒有太大區(qū)別。
C++的性能與對象的邏輯存儲位置也有關(guān)。C++的對象可以被分配在兩個位置:堆和棧。同一對象在堆和棧中分布會有不同的表現(xiàn)。棧對象在程序進(jìn)入函數(shù)體的時候完成創(chuàng)建,而堆對象是在程序運行到new語句時開始創(chuàng)建,意味著它創(chuàng)建一個新的類實例。管理堆對象的時間開銷很大。如果我們在程序運行時做了動態(tài)內(nèi)存分配和回收,內(nèi)存空間中將會出現(xiàn)碎片[3]。如果存儲碎片太多,操作系統(tǒng)必須花費額外的時間做整理而導(dǎo)致堆對象的存儲管理時間開銷迅速增加。C++僅在程序剛開始的時候使用堆對象,這時候還沒有產(chǎn)生內(nèi)存碎片。相對于只是用堆對象的Java,C++使用棧使得其運營效率表現(xiàn)往往要比Java好。
從上面的分析可以看出C++的類機制不增加運行時開銷。堆棧的使用讓C++具有更好的存儲管理性能。
1.1.2 運行時多態(tài)
運行時多態(tài),又稱動態(tài)多態(tài)是面向?qū)ο缶幊痰暮诵奶卣鳌_@是通過延遲對象和操作的綁定至運行時實現(xiàn)的,因此稱之為動態(tài)多態(tài)。C++運行時多態(tài)是基于虛函數(shù)機制的。
要使一個函數(shù)顯示多態(tài)性,必須聲明其為一個虛函數(shù)。包含虛函數(shù)的類被稱為多態(tài)類,其中的對象存儲方式已經(jīng)改變,除了前面提到的數(shù)據(jù)成員,還加上了一個虛函數(shù)表指針。該指針指向一個虛函數(shù)表,其中每個項目對應(yīng)一個虛函數(shù)地址。這些虛函數(shù)包括他們自己的對象和從基類的虛函數(shù)繼承來的部分[4]。虛函數(shù)表為類中所有對象共享。
如圖2所示,與普通類相比,多態(tài)類的對象添加一個指向內(nèi)存的指針。要訪問虛函數(shù),必須要有個指針間的訪問操作,這會成為影響性能的一個重要因素。因此運行時多態(tài)確實增加了程序的時間和空間開銷。更重要的是,虛函數(shù)不能被內(nèi)聯(lián)。基于這些原因,為節(jié)約系統(tǒng)資源,虛函數(shù)的使用應(yīng)盡可能限制。

圖2 虛擬功能機制
1.1.3 編譯時多態(tài)
由于運行時多態(tài)降低程序性能,我們被迫采取其他的措施來實現(xiàn)多態(tài),這就是編譯時多態(tài)。C++中有兩種方法實現(xiàn)這一機制:函數(shù)重載和模板。
函數(shù)重載是指用相同的名稱來定義不同的參數(shù)(可以是不同類型或不同數(shù)目的參數(shù))。重載可以發(fā)生在同一類中或者繼承中。然而當(dāng)固定參數(shù)的函數(shù)需要表現(xiàn)出多態(tài)時,重載無能為力,因為如果子類重新定義的函數(shù)中具有與祖先類中的參數(shù)相同的名稱,這時并沒有發(fā)生重載,只是在子類函數(shù)中覆蓋基類。這意味著重載不能代替虛函數(shù)機制。
模板也成為參數(shù)化類型。當(dāng)編譯器遇到一個模板調(diào)用語句,它會產(chǎn)生函數(shù)定義或者類定義的參數(shù)類型,這叫作模板實例化。模板實例化的本質(zhì)就是模板與特定類型的綁定。綁定發(fā)生在編譯過程中,因此屬于靜態(tài)綁定。與動態(tài)綁定不同靜態(tài)綁定不會導(dǎo)致程序運行效率的下降。所以模板多態(tài)往往比運行時多態(tài)有更好的效率優(yōu)勢。
另一方面,模板類經(jīng)常使用嵌套類型來定義自己與外界之間的接口。一個典型的例子是在標(biāo)準(zhǔn)模板庫(Standard Template Library,STL)中廣泛使用的容器,常用嵌套定義自己的迭代器。這可能會導(dǎo)致代碼膨脹,增加出錯概率。
1.1.4 內(nèi)聯(lián)
在封裝原則的基礎(chǔ)上,類的數(shù)據(jù)成員是不開放的,這意味著如果外部函數(shù)想要訪問它們只能通過成員訪問函數(shù)(典型是一個set和get)。如果一個函數(shù)需要訪問很多數(shù)據(jù)成員,這個函數(shù)就會被頻繁調(diào)用,會導(dǎo)致程序性能的顯著下降。內(nèi)聯(lián)機制就是為了解決這一問題出現(xiàn)的。
從源代碼來看,內(nèi)聯(lián)函數(shù)有函數(shù)的結(jié)構(gòu)但是在編譯時沒有函數(shù)的性質(zhì)。在編譯過程中,它使用函數(shù)體代替函數(shù)名。為了讀寫private或protected成員,必須使用接口功能。如果我們定義這些函數(shù)為內(nèi)聯(lián)成員函數(shù),會得到更好的效率。
但是內(nèi)聯(lián)機制有一定的局限性。當(dāng)內(nèi)聯(lián)函數(shù)過小并且返回值是一個復(fù)雜的對象時,性能上的優(yōu)勢是最明顯的。但如果函數(shù)體本身很大,或者它內(nèi)部又調(diào)用了其他的內(nèi)聯(lián)函數(shù)(或switch語句的使用造成的遞歸),代碼不僅不能有更好的性能,反而會發(fā)生膨脹。一般情況下,我們所定義的函數(shù)體都很大,所以內(nèi)聯(lián)機制應(yīng)謹(jǐn)慎使用。
1.1.5 友元
在默認(rèn)配置下,編譯器可以決定是否把內(nèi)聯(lián)函數(shù)處理成普通的函數(shù)以避免可能出現(xiàn)的副作用,所以內(nèi)聯(lián)機制并非普遍適用。解決這一問題要用到友元機制。
友元是一個在類外定義的函數(shù)或者類,在類體前標(biāo)注關(guān)鍵字friend來和類中的成員函數(shù)做區(qū)別。友元不是一個成員函數(shù),但可以訪問類的私有成員[5]。雖然這種機制破壞了類的封裝,但它的確可以保證某些程序的運行效率。例如,矩陣的乘法運算函數(shù)通常會被聲明為一個矩陣類的友元。
在Java中,類的私有成員不能從外部訪問。然而,Java使用一個默認(rèn)的訪問機制,以限制同一個包中的數(shù)據(jù)成員,這在一定程度上減輕了函數(shù)經(jīng)常訪問數(shù)據(jù)成員所造成的性能下降。顯然,Java中的訪問控制機制不如C++中精細(xì)。
泛型編程最初提出的動機是想要發(fā)明一種語言機制來幫助實現(xiàn)一個通用標(biāo)準(zhǔn)容器庫。泛型表示對各種類似于模板的數(shù)據(jù)類型都具有可操作性。泛型編程包括類模板和函數(shù)模板,具體到C++中的STL,可以分為6個主要的組成部分:分配器、迭代器、容器、算法、函數(shù)對象和適配器[6]。
1.2.1 分配器
許多C++程序往往涉及大量的小對象創(chuàng)建和銷毀,例如STL中的樹節(jié)點和鏈表節(jié)點。這可能導(dǎo)致使用new/delete機制的程序時間效率下降。對象的創(chuàng)建和銷毀開銷過重,頻繁的內(nèi)存分配和大量小對象碎片的回收都是原因。因此有必要引入一個特殊的分配程序來提高時間效率,這就是分配器。
準(zhǔn)備兩個程序,每個分配和回收一百萬個小對象。一個使用常用的new/delete方式,另一個使用分配器。運行它們,看到比較的結(jié)果。我們可以看到使用分配器的程序具有更好的運行性能。這里是一個小測試,用一個典型的類person,數(shù)據(jù)成員有不同類型的gender,name和age還有一些get/set函數(shù)。本次測試的時間開銷如圖3所示。

圖3 通用方法和分配器機制創(chuàng)建和銷毀100萬個對象所消耗的平均時間
這僅僅是一個小測試。對于那些需要長期運行在服務(wù)器端的程序,內(nèi)存管理關(guān)系到程序的穩(wěn)定性。專用的存儲操作,有利于程序性能和操作系統(tǒng)管理內(nèi)存。在特定的情況下,使用分配器可以使模擬器、編譯器或其他類似的運行時間快一倍。
1.2.2 迭代器
迭代器是STL中一種可以用來遍歷容器的部分或全部元素的對象。每個容器的類型必須通過內(nèi)嵌套定義提供自己的迭代器。因此,迭代器具有相同的接口不同的類型。迭代器有很多不同的功能,其可以將抽象容器和通用算法有機地聯(lián)系起來。容器、算法、迭代器之間的關(guān)系如圖4所示。
迭代器實際上是一個模板模仿指針行為以允許用戶訪問容器中的元素。那么其和指針相比性能如何呢?這里我們設(shè)定3個評價標(biāo)準(zhǔn):迭代性能,取內(nèi)容性能和成員訪問性能。迭代是指迭代器從現(xiàn)在的位置移動到指定位置,取內(nèi)容是指移動對象內(nèi)容到迭代器,成員訪問是指訪問迭代器關(guān)聯(lián)的成員。迭代器和指針std::vector都有這3個功能,我們使用這個指針和迭代器做測試比較。測試結(jié)果如圖5所示.
從上面的測評我們可以看出,與指針相比迭代器的時間開銷是相當(dāng)大的。因此迭代操作應(yīng)避免出現(xiàn)在小規(guī)模的編程中。但是我們也不能因此否定其存在。由于迭代器的出現(xiàn),容易和算法獨立開來,這是指針?biāo)鶡o法做到的。

圖4 容器、算法、迭代器之間的關(guān)系
1.2.3 算法和函數(shù)對象
STL算法是一個處理容器和迭代器元素的模板類,和容器獨立。因為在容器中的元素類型可以改變,算法必須能夠處理不同類型的數(shù)據(jù)。所以其需要程序傳遞類型作為參數(shù)的操作到算法。STL使用函數(shù)對象來解決問題。函數(shù)對象是一個類,重載了operator(args,…)函數(shù),提供需要的操作。函數(shù)對象作為參數(shù)傳遞給算法,算法會得到需要的操作。

圖5 迭代器和指針之間的性能比較
Bjarne Stroutstrup做了一個特殊的測試,對 fl oat和string類型作快速排序,分別用C標(biāo)準(zhǔn)庫中的qsort函數(shù)和STL中的排序算法。結(jié)果如圖6所示。
從圖中可以看到C標(biāo)準(zhǔn)庫和C++的STL排序算法之間性能有著顯著的差異。由于排序操作在C/C++編程中高頻出現(xiàn),上述測試結(jié)果是很有代表性的。
從本文前面的分析和性能測試可以看到,高層次抽象編程本身是相輔相成的。面向?qū)ο髾C制,如大型的繼承和運行時多態(tài),往往會讓程序的性能下降。但是泛型機制,如模板和STL算法,總是趨向于彌補這一損失。因此,即使應(yīng)用程序的性能下降,高層次抽象也不應(yīng)該負(fù)主要責(zé)任。問題是如何使用這語言讓其表現(xiàn)最佳。
有精心設(shè)計的庫,程序員可以在一個比C或Fortran更高的水平抽象代碼。代碼將更容易閱讀,效率也可以得到保證。使用精心設(shè)計的特定應(yīng)用程序庫,可以防止很多常見的錯誤。具有相同函數(shù)的庫通常彼此有不同性能。所以選擇一個合適的庫也是提高程序性能的一種有效措施。一些庫之間有可能不同版本間存在差異,因此要注意選擇高效率的庫版本。
通常情況下,編譯優(yōu)化可以在很多層面上進(jìn)行,例如語句級、塊級和全局級別。優(yōu)化層次越高,程序性能提高越明顯。例如,在語句級優(yōu)化,編譯器可以很容易地保存臨時數(shù)據(jù)內(nèi)存,但在塊級別上,編譯器可以省略函數(shù)模板實例生成。抽象層次越高,越容易在更高的代碼級別形成一個清晰的結(jié)構(gòu),編譯器也更容易做更高層次的優(yōu)化。
應(yīng)根據(jù)應(yīng)用程序執(zhí)行環(huán)境進(jìn)行合理的編譯器優(yōu)化。通常來說,C++編譯器有很多不同的應(yīng)用程序參數(shù)來優(yōu)化配置選項。
一般來說,使用異常處理會降低系統(tǒng)的效率。在系統(tǒng)資源有限的情況下,我們應(yīng)該盡量不適用異常處理并禁止拋出異常。但是這樣對提高性能的作用有限,通常只有1%左右。更激進(jìn)的方法是禁用異常處理,但如果在這時拋出任何異常,系統(tǒng)都會立即崩潰。很多庫都依賴于異常處理,所以禁用異常處理是不安全的。
參數(shù)_declspec(novtable)是用于當(dāng)編譯器構(gòu)造特定對象時阻止初始化虛函數(shù)表的。因為抽象類中的實例無法獨立存在而是要依賴于具體的子類對象,不需要初始化虛函數(shù)列表,所以這個參數(shù)只對抽象類有用。由于虛函數(shù)的開銷是很大的,當(dāng)系統(tǒng)資源有限時應(yīng)該盡量避免使用虛函數(shù)。如果必須使用,應(yīng)該禁止初始化虛函數(shù)表。
默認(rèn)情況下,編譯器只對防止代碼發(fā)生膨脹的函數(shù)進(jìn)行內(nèi)聯(lián)。使用/OB1可以迫使其打開內(nèi)聯(lián)。但要優(yōu)化代碼執(zhí)行速度往往導(dǎo)致代碼膨脹,降低程序的時間和空間效率。因此建議不要強制打開內(nèi)聯(lián)。
在分析了高層次抽象的主要機制后,人們知道高層次抽象是一把雙刃劍,它可以顯著改善編程效率和程序穩(wěn)定性,另一方面,如果不正確使用就會導(dǎo)致性能下降。這些操作會降低效率:對繼承的過度依賴,濫用虛函數(shù)或迭代器,強制大函數(shù)內(nèi)聯(lián),初始化抽象類的虛函數(shù)表,不禁用所有的異常處理函數(shù)或程序。可以提高效率的方案有:優(yōu)先使用庫,選擇高效庫,使用模板代替繼承,限制對嵌套類型參數(shù)的依賴,內(nèi)聯(lián)小的函數(shù),使用STL算法和函數(shù)對象。

圖6 500萬個float和200萬個strings的平均時間成本比較
[1] STROUSTRUP B.Abstraction,libraries,and efficiency in C++[J].Dr. Dobb’s Journal China,2003(1):10.
[2] STROUSTRUP B.The design and evolution of C++[M].New Jersey:Addison-Wesley,2002.
[3] DAN T,WISNIEWSKI R W,BACON D F,et al.Minimizing dependencies within generic classes for faster and smaller programs[J].Acm Sigplan Conference on Object Oriented Programming Systems Languages amp; Applications,2009(10):425-444.
[4] ISENSEE P.C++ optimization strategies and techniques[EB/OL].(2016-10-15)[2017-11-09].http://www.tantalon.com/pete/cppopt/main.htm.
[5] HOU J.Analysis of STL source code[D].Wuhan:Huazhong University of Science and Technology,2002.
[6] STROUSTRUP B.Learning standard C++ as a new language[J].C/C++ Users Journal,1999(5):43-54.
Analysis on efficiency of C++ high-level abstraction
Wang Wei1, Sun Xiaofan1, Yang Zhimin2
(1.Shandong Vocational College of Foreign Affairs Translation, Weihai 264504, China;2.Mechanial, Electrical and Information Engineering School, Shandong University, Weihai 264209, China)
At first, C ++ appears as an enhanced version of C, starting with adding classes, and C continues to add new features. It can be used as a stand-alone language when learning C++ because it does not depend on C language. Thinking in C++ thinks there is often a ± 5% difference in operational efficiency. There is argument that the high-level abstraction caused C++ efficiency decline. In order to verify this problem, this article summarizes the possible causes of the decline in efficiency and gives corresponding solutions based on the performance of encapsulation, inheritance and polymorphism in some representative cases and some comparison with C and Java.
C++; high-level abstraction; object-oriented; compiler optimization
王威(1981— ),女,遼寧沈陽人,講師,學(xué)士;研究方向:信息技術(shù)應(yīng)用。