前言
故事的起因源自于一項(xiàng)“翻譯”工作,工作內(nèi)容是將門(mén)戶(hù)Java版自動(dòng)切換客戶(hù)端改寫(xiě)成C++版。然而起始階段“翻譯”過(guò)程并不順暢,原因是雖然兩種語(yǔ)言語(yǔ)法類(lèi)似,但仍有一些本質(zhì)上的區(qū)別很難“直譯”。就如同我們?cè)诜g英文文章的時(shí)候總會(huì)發(fā)現(xiàn)有些單詞很難直譯成中文對(duì)應(yīng)物,于是要么生造一個(gè)詞、要么就得繞個(gè)圈子才能解釋清楚。除此之外,我,一個(gè)用了很長(zhǎng)時(shí)間Java后來(lái)又轉(zhuǎn)為C++開(kāi)發(fā)的人來(lái)說(shuō),始終割舍不下Java那優(yōu)雅的線程模型、所有變量(除了基本數(shù)值變量)都是引用的編程理念、只管new不需要delete的傻瓜式內(nèi)存管理、實(shí)用的靜態(tài)初始化區(qū)塊……,我一直想不明白為何C++不內(nèi)置線程支持、垃圾回收這些現(xiàn)代編程語(yǔ)言特征,類(lèi)似google的GO語(yǔ)言那樣。所以我在開(kāi)發(fā)過(guò)程中一直在C++世界尋找可以讓我在寫(xiě)代碼時(shí)更貼近Java習(xí)慣的替代品。經(jīng)過(guò)一段時(shí)間的摸索實(shí)踐,我總結(jié)了一些經(jīng)驗(yàn)和范例,使我可以在享受Java語(yǔ)法功能方面簡(jiǎn)潔優(yōu)雅的同時(shí)不失C++的強(qiáng)大控制力和高效性。
1 線程模型與鎖
1.1 線程模型Java的線程模型是在語(yǔ)言層面就支持的。通常我們可以通過(guò)兩種方法來(lái)定義一個(gè)線程:直接繼承Thread類(lèi)并覆寫(xiě)run()方法或?qū)崿F(xiàn)Runnable接口并實(shí)現(xiàn)run()方法。run()里面存放的是邏輯相關(guān)代碼,調(diào)用start()即可使線程啟動(dòng)。而C++在語(yǔ)言層面并未支持線程模型,而需要使用額外的庫(kù)來(lái)實(shí)現(xiàn)線程功能(比如pthread)。于是你就會(huì)很煩躁地看到pthread_create()、pthread_exit(),、pthread_join()等一堆函數(shù)和一些更令人煩躁的屬性設(shè)置(pthread_attr_t)。Oh My God!還我簡(jiǎn)潔的線程模型!!
上帝和罵街解決不了問(wèn)題,我們自己動(dòng)手實(shí)現(xiàn)一套類(lèi)java的線程模型。首先定義接口IRunnable,純虛函數(shù)run()用于實(shí)現(xiàn)類(lèi)填充邏輯。
第二步是定義線程基類(lèi),里面含有我們熟悉的run()和start()。我們自定義的線程類(lèi)只需要繼承自AbstractThread,實(shí)現(xiàn)run()方法就可以了。于是煩躁去無(wú)蹤,簡(jiǎn)潔清爽的感覺(jué)又回來(lái)了。不過(guò)這樣一來(lái),我們自定義線程類(lèi)還想繼承其他類(lèi)的話(huà)就得使用多重繼承,這是C++中很容易誤用和導(dǎo)致錯(cuò)誤的部分,后續(xù)的代碼需要特別注意。這里還有一個(gè)需要注意的點(diǎn)是代碼最后的那個(gè)互斥鎖,每個(gè)線程內(nèi)部都應(yīng)該有一把鎖作為線程內(nèi)部的同步控制之用,java的synchronized關(guān)鍵字實(shí)際上是使用了基類(lèi)Object中的互斥鎖實(shí)現(xiàn)的,我們需要額外寫(xiě)一個(gè)。
1.2 鎖Java語(yǔ)言在jdk1.5以前用的是一種簡(jiǎn)單的同步模型,即整個(gè)Java世界的基類(lèi)Object內(nèi)定義了一把互斥鎖,于是所有Java類(lèi)就都可以用這把鎖進(jìn)行同步控制。具體操作上是用synchronized關(guān)鍵字修飾的類(lèi)的成員函數(shù)、{}括起來(lái)的代碼塊,于是被修飾的函數(shù)和代碼塊就可以隱式利用Object的互斥鎖進(jìn)行線程同步。劃時(shí)代的jdk1.5引入了單獨(dú)的線程包java.util.concurrent,里面包含了多種新的線程模型、線程安全容器、鎖等等工具,極大地提高了java線程工具的可用性。
同線程模型一樣,C++語(yǔ)言本身并不包含鎖的功能,我們只能借助于外部庫(kù)(如pthread)來(lái)實(shí)現(xiàn)鎖的功能。仍然是一堆煩人的函數(shù),我們簡(jiǎn)單的包裝一下,希望把它變得更好用一些。
1.2.1 讀寫(xiě)鎖
簡(jiǎn)單包裝了一下,避開(kāi)了pthread_XXX系列函數(shù)并適時(shí)一些異常,登時(shí)感覺(jué)好用了很多(或者是心理作用……)。
1.2.2 互斥鎖
類(lèi)似的封裝思路,看起來(lái)很上流,不過(guò)總覺(jué)得還有點(diǎn)什么事讓我們放不下心來(lái),我們下一節(jié)討論。
1.2.3 鎖的安全釋放
java里面有一個(gè)很好用的異常處理范式:try…catch…finally,其中java保證無(wú)論try塊中是否拋出異常,都會(huì)執(zhí)行finally塊中代碼,這就給了我們一個(gè)機(jī)會(huì)在異常出現(xiàn)之后進(jìn)行一些常規(guī)的清理動(dòng)作,如關(guān)閉數(shù)據(jù)庫(kù)連接、釋放鎖等等。然而C++沒(méi)有finally塊,所以如果我們?cè)诤瘮?shù)中加了鎖,一旦發(fā)生異常,我們必須花很大力氣+把代碼搞的面目全非才能保證把鎖安全的釋放掉。如何才能更優(yōu)雅的完成這項(xiàng)艱巨的任務(wù)?
首先我們復(fù)習(xí)一下C++異常處理部分的一個(gè)特性:C++保證,在函數(shù)拋出異常的時(shí)候,異常拋出點(diǎn)之前聲明的所有臨時(shí)變量都將被析構(gòu)。利用這一性質(zhì),我們只要把要同步的代碼用{}包裹起來(lái),在代碼塊的起始部分聲明一個(gè)鎖的Wrapper對(duì)象(我們定義為L(zhǎng)ockWrapper),在程序執(zhí)行完這段代碼或拋出異常的時(shí)候,LockWrapper的析構(gòu)函數(shù)將被調(diào)用,這時(shí)我們有機(jī)會(huì)在析構(gòu)函數(shù)中把鎖釋放掉。
于是,我們?cè)趫?zhí)行某一互斥操作的時(shí)候就可以像下面這樣寫(xiě)。簡(jiǎn)潔,優(yōu)雅,安全……
1.3 原子計(jì)數(shù)器很多時(shí)候(如生成協(xié)議的sequence)我們都需要一個(gè)線程安全的計(jì)數(shù)器,但如果為了線程安全在一個(gè)不斷++的int變量前加個(gè)互斥鎖就感覺(jué)有些太重了,但是不加有時(shí)候會(huì)引起很多煩惱。java.util.concurrent包里包含了各種類(lèi)型線程安全的計(jì)數(shù)器(AtomicInteger)和數(shù)組(AtomicArray),著實(shí)令人眼饞哪。C++就沒(méi)那么幸運(yùn)了,曾經(jīng)有人說(shuō)++i在很多平臺(tái)上是原子操作,但實(shí)際測(cè)試了一下發(fā)現(xiàn)并非如此,所以我們沒(méi)法偷懶還得自己動(dòng)手豐衣足食。
基本思路是仿照l(shuí)inux內(nèi)核的同步方式,用嵌套匯編的方式來(lái)解決問(wèn)題。代碼很簡(jiǎn)單應(yīng)該無(wú)須解釋?zhuān)?+、--和清零操作都有了,作為一個(gè)線程安全的計(jì)數(shù)器足矣。這里需要注意的是,由于用了80x86的匯編,我們這段代碼無(wú)法移植到其他平臺(tái)上,但是鑒于公司的服務(wù)器從硬件到系統(tǒng)都是統(tǒng)一部署配置的,這個(gè)問(wèn)題應(yīng)該不用太過(guò)擔(dān)心。
2 引用與內(nèi)存管理
在Java的世界中,除了基本數(shù)值變量之外,所有的類(lèi)變量都是引用。Java的引用概念和C++的有本質(zhì)的不同,C++的引用是指變量的別名,而Java中的引用我們可以理解為指針的Wrapper,而且是線程安全的。在內(nèi)存管理方面,眾所周知Java的一個(gè)最大賣(mài)點(diǎn)就是垃圾回收機(jī)制,并且隨著虛擬機(jī)垃圾回收算法的不斷改進(jìn),垃圾回收對(duì)于系統(tǒng)性能的影響越來(lái)越小。沒(méi)用過(guò)Java的人也可以想像得到,只管new不管delete還是相當(dāng)爽的,同時(shí)我們不必?fù)?dān)心忘了delete某些資源而造成的內(nèi)存泄漏,也不會(huì)因?yàn)閷?duì)Raw指針的錯(cuò)誤操作而把內(nèi)存寫(xiě)壞。在C++里我們也可以擁有自動(dòng)化的內(nèi)存管理嗎?答案是boost::shared_ptr<T>。
2.1 shared_ptrshared_ptr實(shí)際上也是一個(gè)Raw指針的wrapper,它通過(guò)引用計(jì)數(shù)的方式來(lái)管理所指資源的生命周期。即每當(dāng)shared_ptr被copy的時(shí)候,所指資源對(duì)象的引用計(jì)數(shù)就加1;當(dāng)shared_ptr對(duì)象析構(gòu)的時(shí)候,所指資源對(duì)象的引用計(jì)數(shù)就減1,當(dāng)引用計(jì)數(shù)為0的時(shí)候,shared_ptr將調(diào)用刪除器(缺省直接delete指針)將所指資源對(duì)象刪除。于是我們就可以放心大膽地new出對(duì)象,塞入到shared_ptr里,然后讓shared_ptr幫我們管理資源對(duì)象的生命周期。我們從此再不用擔(dān)心因?yàn)橹活檔ew忘了delete或者拋異常沒(méi)來(lái)得及delete而造成的問(wèn)題,大大降低程序出現(xiàn)內(nèi)存泄漏的機(jī)會(huì)。這也正是《effective C++》的作者對(duì)boost庫(kù)中只能指針推崇備至的原因。正因?yàn)樗侨绱擞杏?,我們更有必要深入了解一下它的局限性、潛?guī)則,避免因?yàn)檎`用而導(dǎo)致的問(wèn)題。
2.1.1 循環(huán)引用
其實(shí)引用計(jì)數(shù)也是一種最簡(jiǎn)單的垃圾回收算法,但是它的一個(gè)很大的缺陷在于可能存在循環(huán)引用。而一旦形成循環(huán)引用,環(huán)上的所有資源對(duì)象的引用計(jì)數(shù)永遠(yuǎn)不會(huì)是0,shared_ptr也就沒(méi)法幫我們正確刪除那些已經(jīng)沒(méi)用了的資源對(duì)象,于是內(nèi)存泄漏再次發(fā)生。不過(guò)幸好我們可以用weak_ptr來(lái)幫助我們解決部分問(wèn)題,看下面的例子。
本例中shared_from_this()是獲取this指針的shared_ptr,我們后面再說(shuō)??紤]parent_,如果把weak_ptr改為shared_ptr的話(huà)會(huì)有什么問(wèn)題?對(duì),答案是循環(huán)引用。weak_ptr是shared_ptr的觀察者,在把shared_ptr賦給weak_ptr的時(shí)候引用計(jì)數(shù)是不會(huì)加1的。所以shared_ptr配合weak_ptr可以幫助我們解決循環(huán)引用的困擾。但必須強(qiáng)調(diào)的是,我們首先還是要能看出程序中有類(lèi)似上述例子中的問(wèn)題,才能對(duì)癥下藥用shared_ptr配合weak_ptr加以解決,但如果我們沒(méi)看出來(lái)呢?還是要自己小心些才行……
Java在應(yīng)付循環(huán)引用的一堆對(duì)象的時(shí)候就會(huì)比較智能,垃圾回收器會(huì)根據(jù)某種算法找到一些“跟對(duì)象”,然后根據(jù)這些跟對(duì)象順藤摸瓜找到所有正在被使用的對(duì)象。而剩下的那些“對(duì)象孤島”自然就是可以被回收掉的。這就避免了循環(huán)引用帶來(lái)的問(wèn)題。當(dāng)然這是題外話(huà),與shared_ptr無(wú)關(guān)。
2.1.2 混用Raw指針和shared_ptr帶來(lái)的問(wèn)題
第一個(gè)例子如下圖所示,先new了一個(gè)指針出來(lái),然后賦給一個(gè)在括號(hào)作用域內(nèi)的shared_ptr變量。在作用域結(jié)束之后shared_ptr析構(gòu),引用計(jì)數(shù)為0,p所指向的內(nèi)存被清空,于是在最后一行再使用p的時(shí)候?qū)?huì)core掉。
第二個(gè)例子稍微復(fù)雜一些,主要是p4在析構(gòu)時(shí)刪掉了資源。導(dǎo)致后面p1,p2析構(gòu)之后又再次刪除已經(jīng)析構(gòu)過(guò)的指針導(dǎo)致異常。
這里總結(jié)一點(diǎn)就是既然用了shared_ptr,那就信任它,把資源對(duì)象的生命周期管理完全交給它。我們不應(yīng)再對(duì)Raw指針進(jìn)行額外的操作,既不要把它取出來(lái)用也不要把它再賦給其他shared_ptr。如果是因?yàn)榇嗽驅(qū)е聠?wèn)題,那不是shared_ptr的錯(cuò),而是我們確實(shí)誤用了。
2.1.3 資源對(duì)象獲取this指針的shared_ptr
this指針是一個(gè)比較特殊的指針,由于shared_ptr是一種非侵入式(不知google之)的管理方案,資源對(duì)象本身對(duì)于自己的引用計(jì)數(shù)毫不知情,所以如果資源對(duì)象的方法中要獲取一個(gè)指向自己的shared_ptr,就需要做一些額外的處理。如下所示,CResouce就可以在成員函數(shù)中用enable_shared_from_this::shared_from_this()獲取指向自己的shared_ptr了。
2.1.4 用臨時(shí)的shared_ptr當(dāng)參數(shù)帶來(lái)的問(wèn)題
考慮下面的代碼有啥問(wèn)題:
void test()
{
foo(boost::shared_ptr<MyObj>(new MyObj()),g());
}
由于C++并不保證函數(shù)中參數(shù)表達(dá)式的執(zhí)行順序,所以如果例子中執(zhí)行順序是new MyObj、g()、構(gòu)造shared_ptr,則g()拋異常的時(shí)候MyObj就會(huì)泄漏。《effective C++》作者建議這樣寫(xiě):
void test()
{
boost::shared_ptr<implementation> sp (new MyObj());
foo(sp,g());
}
2.1.5 shared_ptr與多態(tài)
多態(tài)是面向?qū)ο蟮娜齻€(gè)基本概念之一,我們可以采用多態(tài)技術(shù)實(shí)現(xiàn)接口與實(shí)現(xiàn)的分離。但是shared_ptr并不直接支持多態(tài)。比如有類(lèi)Father和Child,Child繼承自Father。我們不能直接這樣寫(xiě):
shared_ptr<Father> p1(new Father);
shared_ptr<Child> p2 = p1;// 編譯錯(cuò)誤。
為了實(shí)現(xiàn)多態(tài)的目的,我們必須進(jìn)行一次顯示的類(lèi)型轉(zhuǎn)換:
shared_ptr<Father> p1(new Father);
shared_ptr<Child> p2 = static_pointer_cast<Child>(p1);// 編譯錯(cuò)誤。
這是一次有代價(jià)的轉(zhuǎn)換,但是換來(lái)了靈活性。
2.1.6 執(zhí)行效率
我們可以想象得到,既然用了資源對(duì)象外部的引用計(jì)數(shù),就不可避免地要進(jìn)行同步操作以保證引用計(jì)數(shù)的準(zhǔn)確性。雖然在新版本中采用了lock-free的原子整數(shù)操作一定程度上降低了線程同步開(kāi)銷(xiāo),但是有人壓測(cè)實(shí)際維護(hù)引用計(jì)數(shù)帶來(lái)的開(kāi)銷(xiāo)大約占了5%的CPU,并非全無(wú)代價(jià)。如果真的在乎這5%還是要另想辦法才行。
2.1.7 auto_ptr與shared_ptr
auto_ptr是stl里面自帶的只能指針,它沒(méi)有采用引用計(jì)數(shù)的方式,它管理對(duì)象的方式為:如果兩個(gè)auto_ptr進(jìn)行賦值操作,賦值一方將會(huì)把指針的控制權(quán)“轉(zhuǎn)移”給被賦值一方。從源碼上看是賦值一方把指針交給了被賦值一方,然后自己內(nèi)部賦一個(gè)NULL。如此一來(lái),auto_ptr就完全沒(méi)辦法放到stl容器中了。以vector<auto_ptr<T>>為例,如果把其中的一個(gè)值賦給外面的一個(gè)auto_ptr,則指針控制權(quán)隨之轉(zhuǎn)移,vector里面的值就變成了NULL,這是隨時(shí)可能導(dǎo)致程序崩潰的事情?!秂ffective C++》作者的說(shuō)法是:遇到這種情況如果編譯器能報(bào)錯(cuò)的話(huà)算你走運(yùn),如果沒(méi)報(bào)錯(cuò)的話(huà)你才更應(yīng)該小心。
2.2 NullPointer for shared_ptr在Java中,如果引用為空我們可以返回null,這和C++中的指針為空我們可以用NULL一樣。但如果shared_ptr為空我們應(yīng)該如何表達(dá)?這是很常見(jiàn)的需求,例如我們有個(gè)函數(shù)返回某一cache中資源的shared_ptr,有時(shí)需要給用戶(hù)返回一個(gè)空智能指針表示cache中不存在改資源。直接用默認(rèn)構(gòu)造函數(shù)生成的只能指針里面是一個(gè)NULL,不過(guò)為了讓看的人更明白,我選擇這樣做:
shared_ptr<MyObj> pNullInfo(static_cast<MyObj*>(0));
如果要判斷pNullInfo是否為空,只需要像下面這樣就行,和Raw指針用法是一樣的。
if( !pNullInfo)
……
else ……
3 靜態(tài)初始化代碼塊
Java類(lèi)里面可以定義一個(gè)static代碼塊來(lái)進(jìn)行一些必要的初始化動(dòng)作。像這樣:
class Foo{
static{
……//一些初始化動(dòng)作
}
…….//其他操作
}
static塊內(nèi)的代碼將在該類(lèi)的對(duì)象第一次被用到的時(shí)候執(zhí)行(lazy loading),很是方便。但是C++不支持這種這種寫(xiě)法,根據(jù)C++的初始化方法,于是我們很難對(duì)類(lèi)里定義的static成員進(jìn)行較為復(fù)雜一些的初始化動(dòng)作。簡(jiǎn)單賦個(gè)初值還行,復(fù)雜了就沒(méi)辦法。通常的解決方案是寫(xiě)一個(gè)static的init()方法,并要求類(lèi)的使用者在使用類(lèi)之前一定要實(shí)現(xiàn)調(diào)用一下這個(gè)init來(lái)進(jìn)行必要的初始化。這種方式既麻煩又不安全(多線程情況下還要加鎖)。
我想到一個(gè)解決方法是在類(lèi)里面生成寫(xiě)一個(gè)嵌套類(lèi),并聲明一個(gè)該類(lèi)的static成員,要進(jìn)行的初始化動(dòng)作可以放在這個(gè)嵌套類(lèi)的構(gòu)造函數(shù)里。于是,當(dāng)該類(lèi)被初始化的時(shí)候相應(yīng)的動(dòng)作也得到執(zhí)行。因?yàn)镃++保證:在類(lèi)被使用之前,所有的靜態(tài)成員都已經(jīng)被初始化完畢。于是我們就可以獲得java中static代碼塊一樣的效果,寫(xiě)個(gè)例子如下: