用C++寫Java Style程式

來源:互聯網
上載者:User

前言

故事的起因源自於一項“翻譯”工作,工作內容是將門戶Java版自動切換用戶端改寫成C++版。然而起始階段“翻譯”過程並不順暢,原因是雖然兩種語言文法類似,但仍有一些本質上的區別很難“直譯”。就如同我們在翻譯英文文章的時候總會發現有些單詞很難直譯成中文對應物,於是要麼生造一個詞、要麼就得繞個圈子才能解釋清楚。除此之外,我,一個用了很長時間Java後來又轉為C++開發的人來說,始終割捨不下Java那優雅的執行緒模式、所有變數(除了基本數值變數)都是引用的編程理念、只管new不需要delete的傻瓜式記憶體管理、實用的靜態初始化區塊……,我一直想不明白為何C++不內建線程支援、記憶體回收這些現代程式設計語言特徵,類似google的GO語言那樣。所以我在開發過程中一直在C++世界尋找可以讓我在寫代碼時更貼近Java習慣的替代品。經過一段時間的摸索實踐,我總結了一些經驗和範例,使我可以在享受Java文法功能方面簡潔優雅的同時不失C++的強大控制力和高效性。

1 執行緒模式與鎖
1.1 執行緒模式Java的執行緒模式是在語言層面就支援的。通常我們可以通過兩種方法來定義一個線程:直接繼承Thread類並覆寫run()方法或實現Runnable介面並實現run()方法。run()裡面存放的是邏輯相關代碼,調用start()即可使線程啟動。而C++在語言層面並未支援執行緒模式,而需要使用額外的庫來實現線程功能(比如pthread)。於是你就會很煩躁地看到pthread_create()、pthread_exit(),、pthread_join()等一堆函數和一些更令人煩躁的屬性設定(pthread_attr_t)。Oh My God!還我簡潔的執行緒模式!!

上帝和罵街解決不了問題,我們自己動手實現一套類java的執行緒模式。首先定義介面IRunnable,純虛函數run()用於實作類別填充邏輯。

 

第二步是定義線程基類,裡面含有我們熟悉的run()和start()。我們自訂的線程類只需要繼承自AbstractThread,實現run()方法就可以了。於是煩躁去無蹤,簡潔清爽的感覺又回來了。不過這樣一來,我們自訂線程類還想繼承其他類的話就得使用多重繼承,這是C++中很容易誤用和導致錯誤的部分,後續的代碼需要特別注意。這裡還有一個需要注意的點是代碼最後的那個互斥鎖,每個線程內部都應該有一把鎖作為線程內部的同步控制之用,java的synchronized關鍵字實際上是使用了基類Object中的互斥鎖實現的,我們需要額外寫一個。

 

1.2 鎖Java語言在jdk1.5以前用的是一種簡單的同步模型,即整個Java世界的基類Object內定義了一把互斥鎖,於是所有Java類就都可以用這把鎖進行同步控制。具體操作上是用synchronized關鍵字修飾的類的成員函數、{}括起來的代碼塊,於是被修飾的函數和代碼塊就可以隱式利用Object的互斥鎖進行線程同步。劃時代的jdk1.5引入了單獨的線程包java.util.concurrent,裡麵包含了多種新的執行緒模式、安全執行緒容器、鎖等等工具,極大地提高了java線程工具的可用性。

同執行緒模式一樣,C++語言本身並不包含鎖的功能,我們只能藉助於外部庫(如pthread)來實現鎖的功能。仍然是一堆煩人的函數,我們簡單的封裝一下,希望把它變得更好用一些。

1.2.1 讀寫鎖

簡單封裝了一下,避開了pthread_XXX系列函數並適時一些異常,登時感覺好用了很多(或者是心理作用……)。

1.2.2 互斥鎖

類似的封裝思路,看起來很上流,不過總覺得還有點什麼事讓我們放不下心來,我們下一節討論。

1.2.3 鎖的安全釋放
java裡面有一個很好用的異常處理範式:try…catch…finally,其中java保證無論try塊中是否拋出異常,都會執行finally塊中代碼,這就給了我們一個機會在異常出現之後進行一些常規的清理動作,如關閉資料庫連接、釋放鎖等等。然而C++沒有finally塊,所以如果我們在函數中加了鎖,一旦發生異常,我們必須花很大力氣+把代碼搞的面目全非才能保證把鎖安全的釋放掉。如何才能更優雅的完成這項艱巨的任務?

首先我們複習一下C++異常處理部分的一個特性:C++保證,在函數拋出異常的時候,異常拋出點之前聲明的所有臨時變數都將被析構。利用這一性質,我們只要把要同步的代碼用{}包裹起來,在代碼塊的起始部分聲明一個鎖的Wrapper對象(我們定義為LockWrapper),在程式執行完這段代碼或拋出異常的時候,LockWrapper的解構函式將被調用,這時我們有機會在解構函式中把鎖釋放掉。

 

於是,我們在執行某一互斥操作的時候就可以像下面這樣寫。簡潔,優雅,安全……

 

1.3 原子計數器很多時候(如產生協議的sequence)我們都需要一個安全執行緒的計數器,但如果為了安全執行緒在一個不斷++的int變數前加個互斥鎖就感覺有些太重了,但是不加有時候會引起很多煩惱。java.util.concurrent包裡包含了各種類型安全執行緒的計數器(AtomicInteger)和數組(AtomicArray),著實令人眼饞哪。C++就沒那麼幸運了,曾經有人說++i在很多平台上是原子操作,但實際測試了一下發現並非如此,所以我們沒法偷懶還得自己動手豐衣足食。

基本思路是仿照linux核心的同步方式,用嵌套彙編的方式來解決問題。代碼很簡單應該無須解釋,++、--和清零操作都有了,作為一個安全執行緒的計數器足矣。這裡需要注意的是,由於用了80x86的彙編,我們這段代碼無法移植到其他平台上,但是鑒於公司的伺服器從硬體到系統都是統一部署配置的,這個問題應該不用太過擔心。

 

2 引用與記憶體管理
在Java的世界中,除了基本數值變數之外,所有的類變數都是引用。Java的引用概念和C++的有本質的不同,C++的引用是指變數的別名,而Java中的引用我們可以理解為指標的Wrapper,而且是安全執行緒的。在記憶體管理方面,眾所周知Java的一個最大賣點就是記憶體回收機制,並且隨著虛擬機器記憶體回收演算法的不斷改進,記憶體回收對於系統效能的影響越來越小。沒用過Java的人也可以想像得到,只管new不管delete還是相當爽的,同時我們不必擔心忘了delete某些資源而造成的記憶體流失,也不會因為對Raw指標的錯誤操作而把記憶體寫壞。在C++裡我們也可以擁有自動化的記憶體管理嗎?答案是boost::shared_ptr<T>。

2.1 shared_ptrshared_ptr實際上也是一個Raw指標的wrapper,它通過引用計數的方式來管理所指資源的生命週期。即每當shared_ptr被copy的時候,所指資來源物件的引用計數就加1;當shared_ptr對象析構的時候,所指資來源物件的引用計數就減1,當引用計數為0的時候,shared_ptr將調用刪除器(預設直接delete指標)將所指資來源物件刪除。於是我們就可以放心大膽地new出對象,塞入到shared_ptr裡,然後讓shared_ptr幫我們管理資來源物件的生命週期。我們從此再不用擔心因為只顧new忘了delete或者拋異常沒來得及delete而造成的問題,大大降低程式出現記憶體流失的機會。這也正是《effective C++》的作者對boost庫中只能指標推崇備至的原因。正因為它是如此有用,我們更有必要深入瞭解一下它的局限性、潛規則,避免因為誤用而導致的問題。

2.1.1 循環參考
其實引用計數也是一種最簡單的記憶體回收演算法,但是它的一個很大的缺陷在於可能存在循環參考。而一旦形成循環參考,環上的所有資來源物件的引用計數永遠不會是0,shared_ptr也就沒法幫我們正確刪除那些已經沒用了的資來源物件,於是記憶體流失再次發生。不過幸好我們可以用weak_ptr來協助我們解決部分問題,看下面的例子。

 

本例中shared_from_this()是擷取this指標的shared_ptr,我們後面再說。考慮parent_,如果把weak_ptr改為shared_ptr的話會有什麼問題?對,答案是循環參考。weak_ptr是shared_ptr的觀察者,在把shared_ptr賦給weak_ptr的時候引用計數是不會加1的。所以shared_ptr配合weak_ptr可以協助我們解決循環參考的困擾。但必須強調的是,我們首先還是要能看出程式中有類似上述例子中的問題,才能對症下藥用shared_ptr配合weak_ptr加以解決,但如果我們沒看出來呢?還是要自己小心些才行……

Java在應付循環參考的一堆對象的時候就會比較智能,記憶體回收行程會根據某種演算法找到一些“跟對象”,然後根據這些跟對象順藤摸瓜找到所有正在被使用的對象。而剩下的那些“對象孤島”自然就是可以被回收掉的。這就避免了循環參考帶來的問題。當然這是題外話,與shared_ptr無關。

2.1.2 混用Raw指標和shared_ptr帶來的問題
第一個例子如所示,先new了一個指標出來,然後賦給一個在括弧範圍內的shared_ptr變數。在範圍結束之後shared_ptr析構,引用計數為0,p所指向的記憶體被清空,於是在最後一行再使用p的時候將會core掉。

 

第二個例子稍微複雜一些,主要是p4在析構時刪掉了資源。導致後面p1,p2析構之後又再次刪除已經析構過的指標導致異常。

 

這裡總結一點就是既然用了shared_ptr,那就信任它,把資來源物件的生命週期管理完全交給它。我們不應再對Raw指標進行額外的操作,既不要把它取出來用也不要把它再賦給其他shared_ptr。如果是因為此原因導致問題,那不是shared_ptr的錯,而是我們確實誤用了。

2.1.3 資來源物件擷取this指標的shared_ptr
this指標是一個比較特殊的指標,由於shared_ptr是一種非侵入式(不知google之)的管理方案,資來源物件本身對於自己的引用計數毫不知情,所以如果資來源物件的方法中要擷取一個指向自己的shared_ptr,就需要做一些額外的處理。如下所示,CResouce就可以在成員函數中用enable_shared_from_this::shared_from_this()擷取指向自己的shared_ptr了。

 

2.1.4 用臨時的shared_ptr當參數帶來的問題
考慮下面的代碼有啥問題:

void test()

{

foo(boost::shared_ptr<MyObj>(new MyObj()),g());

}

由於C++並不保證函數中參數運算式的執行順序,所以如果例子中執行順序是new MyObj、g()、構造shared_ptr,則g()拋異常的時候MyObj就會泄漏。《effective C++》作者建議這樣寫:

void test()

{

boost::shared_ptr<implementation> sp (new MyObj());

foo(sp,g());

}

2.1.5 shared_ptr與多態
多態是物件導向的三個基本概念之一,我們可以採用多態技術實現介面與實現的分離。但是shared_ptr並不直接支援多態。比如有類Father和Child,Child繼承自Father。我們不能直接這樣寫:

shared_ptr<Father> p1(new Father);

shared_ptr<Child> p2 = p1;// 編譯錯誤。

為了實現多態的目的,我們必須進行一次顯示的類型轉換:

shared_ptr<Father> p1(new Father);

shared_ptr<Child> p2 = static_pointer_cast<Child>(p1);// 編譯錯誤。

這是一次有代價的轉換,但是換來了靈活性。

2.1.6 執行效率
我們可以想象得到,既然用了資來源物件外部的引用計數,就不可避免地要進行同步操作以保證引用計數的準確性。雖然在新版本中採用了lock-free的原子整數操作一定程度上降低了線程同步開銷,但是有人壓測實際維護引用計數帶來的開銷大約佔了5%的CPU,並非全無代價。如果真的在乎這5%還是要另想辦法才行。

2.1.7 auto_ptr與shared_ptr
auto_ptr是stl裡面內建的只能指標,它沒有採用引用計數的方式,它管理對象的方式為:如果兩個auto_ptr進行賦值操作,賦值一方將會把指標的控制權“轉移”給被賦值一方。從源碼上看是賦值一方把指標交給了被賦值一方,然後自己內部賦一個NULL。如此一來,auto_ptr就完全沒辦法放到stl容器中了。以vector<auto_ptr<T>>為例,如果把其中的一個值賦給外面的一個auto_ptr,則指標控制權隨之轉移,vector裡面的值就變成了NULL,這是隨時可能導致程式崩潰的事情。《effective C++》作者的說法是:遇到這種情況如果編譯器能報錯的話算你走運,如果沒報錯的話你才更應該小心。

2.2 NullPointer for shared_ptr在Java中,如果引用為空白我們可以返回null,這和C++中的指標為空白我們可以用NULL一樣。但如果shared_ptr為空白我們應該如何表達?這是很常見的需求,例如我們有個函數返回某一cache中資源的shared_ptr,有時需要給使用者返回一個空智能指標表示cache中不存在改資源。直接用預設建構函式產生的只能指標裡面是一個NULL,不過為了讓看的人更明白,我選擇這樣做:

shared_ptr<MyObj> pNullInfo(static_cast<MyObj*>(0));

如果要判斷pNullInfo是否為空白,只需要像下面這樣就行,和Raw指標用法是一樣的。

if( !pNullInfo)

……

else ……

3 靜態初始化代碼塊
Java類裡面可以定義一個static代碼塊來進行一些必要的初始化動作。像這樣:

class Foo{

static{

……//一些初始化動作

}

…….//其他動作

}

static塊內的代碼將在該類的對象第一次被用到的時候執行(lazy loading),很是方便。但是C++不支援這種這種寫法,根據C++的初始化方法,於是我們很難對類裡定義的static成員進行較為複雜一些的初始化動作。簡單賦個初值還行,複雜了就沒辦法。通常的解決方案是寫一個static的init()方法,並要求類的使用者在使用類之前一定要實現調用一下這個init來進行必要的初始化。這種方式既麻煩又不安全(多線程情況下還要加鎖)。

我想到一個解決方案是在類裡面產生寫一個嵌套類,並聲明一個該類的static成員,要進行的初始化動作可以放在這個嵌套類的建構函式裡。於是,當該類被初始化的時候相應的動作也得到執行。因為C++保證:在類被使用之前,所有的靜態成員都已經被初始化完畢。於是我們就可以獲得java中static代碼塊一樣的效果,寫個例子如下:

 

4 參考資料:
1. shared_ptr四宗罪 http://blog.liancheng.info/?p=85

2. 關於boost::shared_ptr...高興得太早 http://www.cppblog.com/Charlib/archive/2010/02/22/76313.html

 

本文來自CSDN部落格,轉載請標明出處:http://blog.csdn.net/kabini/archive/2010/12/05/6056613.aspx

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.