1. 程式人生 > >膜拜性轉帖: C++11有關的(現在用的編譯器都是不太支援C++11的)

膜拜性轉帖: C++11有關的(現在用的編譯器都是不太支援C++11的)

過去的一年我在微軟亞洲研究院做輸入法,我們的產品叫“英庫拼音輸入法” (下載Beta版),如果你用過“英庫詞典”(現已更名為必應詞典),應該知道“英庫”這個名字(實際上我們的核心開發團隊也有很大一部分來源於英庫團隊的老成員)。整個專案是微軟亞洲研究院的自然語言處理組、網際網路搜尋與挖掘組和我們創新工程中心,以及微軟中國Office商務軟體部(MODC)多組合作的結果。至於我們的輸入法有哪些創新的feature,以及這些feature背後的種種有趣故事… 本文暫不討論。雖然整個過程中我也參與了很多feature的設想和設計,但90%的職責還是開發,所以作為client端的核心開發人員之一,我想跟大家分享這一年來在專案中全面使用

C++11以及現代C++風格Elements of Modern C++ Style)來做開發的種種經驗。

我們用的開發環境是VS2010 SP1,該版本已經支援了相當多的C++11的特性:lambda表示式,右值引用,auto型別推導,static_assert,decltype,nullptr,exception_ptr等等。C++曾經飽受“學院派”標籤的困擾,不過這個標籤著實被貼得挺冤,C++11的新feature沒有一個是從學院派角度出發來設計的,以上提到的所有這些feature都在我們的專案中得到了適得其所的運用,並且帶來了很大的收益。尤其是lambda表示式。

說起來我跟C++也算是有相當大的緣分,03年還在讀本科的時候,

第一篇發表在程式設計師上面的文章就是Boost庫的原始碼剖析,那個時候Boost庫在國內還真是相當的陽春白雪,至今已經快十年了,Boost庫如今已經是寫C++程式碼不可或缺的庫,被譽為“準標準庫”,C++的TR1基本就脫胎於Boost的一系列子庫,而TR2同樣也大量從Boost庫中取材。之後有好幾年,我在CSDN上的部落格幾乎純粹是C++的前沿技術文章,包括從06年就開始寫的“C++0x漫談”系列。(後來寫技術文章寫得少了,也就把部落格從CSDN部落格獨立了出來,便是現在的mindhacks.cn)。自從獨立部落格了之後我就沒有再寫過C++相關的文章(不過仍然一直對C++的發展保持了一定的關注),一方面我喜歡關注前沿的進展,寫完了Boost原始碼剖析系列和C++0x漫談系列之後我覺得這一波的前沿進展從大方面來說也都寫得差不多了,所以不想再費時間。另一方面的原因也是我雖然對C++關注較深,但實踐經驗卻始終絕大多數都是“替代經驗”,即從別人那兒看來的,並非自己第一手的。而過去一年來深度參與的英庫輸入法專案彌補了這個缺憾,所以我就決定重新開始寫一點C++11的實踐經驗。算是對努力一年的專案釋出第一版的一個小結。

09年入職微軟亞洲研究院之後,前兩年跟C++基本沒沾邊,第一個專案倒是用C++的,不過是工作在既有程式碼基上,時間也相對較短。第二個專案為Bing Image Search用javascript寫前端,第三個專案則給Visual Studio 2012寫Code Clone Detection,用C#和WPF。直到一年前英庫輸入法這個專案,是我在研究院的第四個專案了,也是最大的一個,一年來我很開心,因為又回到了C++。

這個專案我們從零開始,,而client端的核心開發人員也很緊湊,只有3個。這個專案有很多特殊之處,對高效的快速迭代開發提出了很大的挑戰(研究院所倡導的“以實踐為驅動的研究(Deployment-Driven-Research)”要求我們迅速對使用者的需求作出響應):

  1. 長期時間壓力:從零開始到釋出,只有一年時間,我們既要在主要feature上能和主流的輸入法相較,還需要實現我們自己獨特的創新feature,從而能夠和其他輸入法產品區分開來。
  2. 短期時間壓力:輸入法在中國是一個非常成熟的市場,誰也沒法保證悶著頭搞一年搞出來的東西就一炮而紅,所以我們從第一天起就進入demo驅動的準迭代式開發,整個過程中必須不斷有階段性輸出,擡頭看路好過悶頭走路。但工程師最頭疼的二難問題之一恐怕就是短期與長遠的矛盾:要持續不斷出短期的成果,就必須經常在某些地方趕工,趕工的結果則可能導致在設計和程式碼質量上面的折衷,這些折衷也被稱為Technical Debt(技術債)。沒有任何專案沒有技術債,只是多少,以及償還的方式的區別。我們的目的不是消除技術債,而是通過不斷持續改進程式碼質量,阻止技術債的滾雪球式積累。
  3. C++是一門不容易用好的語言:錯誤的使用方式會給程式碼基的質量帶來很大的損傷。而C++的誤用方式又特別多。
  4. 輸入法是個很特殊的應用程式,在Windows下面,輸入法是載入到目標程序空間當中的dll,所以,輸入法對質量的要求極高,別的軟體出了錯誤崩潰了大不了重啟一下,而輸入法如果崩潰就會造成整個目標程序崩潰,如果使用者的文件未儲存就可能會丟失寶貴的使用者資料,所以輸入法最容不得崩潰。可是隻要是人寫的程式碼怎麼可能沒有bug呢?所以關鍵在於如何減少bug及其產生的影響和如何能儘快響應並修復bug。所以我們的做法分為三步:1). 使用現代C++技術減少bug產生的機會。2). 即便bug產生了,也儘量減少對使用者產生的影響。3). 完善的bug彙報系統使開發人員能夠第一時間擁有足夠的資訊修復bug。

至於為什麼要用C++而不是C呢?對於我們來說理由很現實:時間緊任務重,用C的話需要發明的輪子太多了,C++的抽象層次高,程式碼量少,bug相對就會更少,現代C++的記憶體管理完全自動,以至於從頭到尾我根本不記得曾遇到過什麼記憶體管理相關的bug,現代C++的錯誤處理機制也非常適合快速開發的同時不用擔心bug亂飛,另外有了C++11的強大支援更是如虎添翼,當然,這一切都必須建立在核心團隊必須善用C++的大前提上,而這對於我們這個緊湊的小團隊來說這不是問題,因為大家都有較好的C++背景,沒有陡峭的學習曲線要爬。(至於C++在大規模團隊中各人對C++的掌握良莠不齊的情況下所帶來的一些包袱本文也不作討論,呵呵,語言之爭別找我。)

下面就說說我們在這個專案中是如何使用C++11和現代C++風格來開發的,什麼是現代C++風格以及它給我們開發帶來的好處。

資源管理

說到Native Languages就不得不說資源管理,因為資源管理向來都是Native Languages的一個大問題,其中記憶體管理又是資源當中的一個大問題,由於堆記憶體需要手動分配和釋放,所以必須確保記憶體得到釋放,對此一般原則是“誰分配誰負責釋放”,但即便如此仍然還是經常會導致記憶體洩漏、野指標等等問題。更不用說這種手動釋放給API設計帶來的問題(例如Win32 APIWideCharToMultiByte就是一個典型的例子,你需要提供一個緩衝區給它來接收編碼轉換的結果,但是你又不能確保你的緩衝區足夠大,所以就出現了一個兩次呼叫的pattern,第一次給個NULL緩衝區,於是API返回的是所需的緩衝區的大小,根據這個大小分配緩衝區之後再第二次呼叫它,別提多彆扭了)。

託管語言們為了解決這個問題引入了GC,其理念是“記憶體管理太重要了,不能交給程式設計師來做”。但GC對於Native開發也常常有它自己的問題。而且另一方面Native界也常常詬病GC,說“記憶體管理太重要了,不能交給機器來做”。

C++也許是第一個提供了完美折衷的語言(不過這個機制直到C++11的出現才真正達到了易用的程度),即:既不是完全交給機器來做,也不是完全交給程式設計師來做,而是程式設計師先在程式碼中指定怎麼做,至於什麼時候做,如何確保一定會得到執行,則交由編譯器來確定。

首先是C++98提供了語言機制:物件在超出作用域的時候其解構函式會被自動呼叫。接著,Bjarne Stroustrup在TC++PL裡面定義了RAII(Resource Acquisition is Initialization)正規化(即:物件構造的時候其所需的資源便應該在建構函式中初始化,而物件析構的時候則釋放這些資源)。RAII意味著我們應該用類來封裝和管理資源,對於記憶體管理而言,Boost第一個實現了工業強度的智慧指標,如今智慧指標(shared_ptr和unique_ptr)已經是C++11的一部分,簡單來說有了智慧指標意味著你的C++程式碼基中幾乎就不應該出現delete了。

不過,RAII正規化雖然很好,但還不足夠易用,很多時候我們並不想為了一個CloseHandle, ReleaseDC, GlobalUnlock等等而去大張旗鼓地另寫一個類出來,所以這些時候我們往往會因為怕麻煩而直接手動去調這些釋放函式,手動調的一個壞處是,如果在資源申請和釋放之間發生了異常,那麼釋放將不會發生,此外,手動釋放需要在函式的所有出口處都去調釋放函式,萬一某天有人修改了程式碼,加了一處return,而在return之前忘了調釋放函式,資源就洩露了。理想情況下我們希望語言能夠支援這樣的正規化:

void foo()
{
    HANDLE h = CreateFile(...);

    ON_SCOPE_EXIT { CloseHandle(h); }

    ... // use the file
}

ON_SCOPE_EXIT裡面的程式碼就像是在解構函式裡面的一樣:不管當前作用域以什麼方式退出,都必然會被執行。

實際上,早在2000年,Andrei Alexandrescu 就在DDJ雜誌上發表了一篇文章,提出了這個叫做ScopeGuard 的設施,不過當時C++還沒有太好的語言機制來支援這個設施,所以Andrei動用了你所能想到的各種奇技淫巧硬是造了一個出來,後來Boost也加入了ScopeExit庫,不過這些都是建立在C++98不完備的語言機制的情況下,所以其實現非常不必要的繁瑣和不完美,實在是戴著腳鐐跳舞(這也是C++98的通用庫被詬病的一個重要原因),再後來Andrei不能忍了就把這個設施內建到了D語言當中,成了D語言特性的一部分最出彩的部分之一)。

再後來就是C++11的釋出了,C++11釋出之後,很多人都開始重新實現這個對於異常安全來說極其重要的設施,不過絕大多數人的實現受到了2000年Andrei的原始文章的影響,多多少少還是有不必要的複雜性,而實際上,將C++11的Lambda Functiontr1::function結合起來,這個設施可以簡化到腦殘的地步:

class ScopeGuard
{
public:
    explicit ScopeGuard(std::function<void()> onExitScope)
        : onExitScope_(onExitScope), dismissed_(false)
    { }

    ~ScopeGuard()
    {
        if(!dismissed_)
        {
            onExitScope_();
        }
    }

    void Dismiss()
    {
        dismissed_ = true;
    }

private:
    std::function<void()> onExitScope_;
    bool dismissed_;

private: // noncopyable
    ScopeGuard(ScopeGuard const&);
    ScopeGuard& operator=(ScopeGuard const&);
};

這個類的使用很簡單,你交給它一個std::function,它負責在析構的時候執行,絕大多數時候這個function就是lambda,例如:

HANDLE h = CreateFile(...);
ScopeGuard onExit([&] { CloseHandle(h); });

onExit在析構的時候會忠實地執行CloseHandle。為了避免給這個物件起名的麻煩(如果有多個變數,起名就麻煩大了),可以定義一個巨集,把行號混入變數名當中,這樣每次定義的ScopeGuard物件都是唯一命名的。

#define SCOPEGUARD_LINENAME_CAT(name, line) name##line
#define SCOPEGUARD_LINENAME(name, line) SCOPEGUARD_LINENAME_CAT(name, line)

#define ON_SCOPE_EXIT(callback) ScopeGuard SCOPEGUARD_LINENAME(EXIT, __LINE__)(callback)
// Dismiss()函式也是Andrei的原始設計的一部分,其作用是為了支援rollback模式,例如:
ScopeGuard onFailureRollback([&] { /* rollback */ });
... // do something that could fail
onFailureRollback.Dismiss();


在上面的程式碼中,“do something”的過程中只要任何地方丟擲了異常,rollback邏輯都會被執行。如果“do something”成功了,onFailureRollback.Dismiss()會被呼叫,設定dismissed_為true,阻止rollback邏輯的執行。

ScopeGuard是資源自動釋放,以及在程式碼出錯的情況下rollback的不可或缺的設施,C++98由於沒有lambda和tr1::function的支援,ScopeGuard不但實現複雜,而且用起來非常麻煩,陷阱也很多,而C++11之後立即變得極其簡單,從而真正變成了每天要用到的設施了。C++的RAII正規化被認為是資源確定性釋放的最佳正規化(C#的using關鍵字在巢狀資源申請釋放的情況下會層層縮排,相當的不能scale),而有了ON_SCOPE_EXIT之後,在C++裡面申請釋放資源就變得非常方便

Acquire Resource1
ON_SCOPE_EXIT( [&] { /* Release Resource1 */ })

Acquire Resource2
ON_SCOPE_EXIT( [&] { /* Release Resource2 */ })
…

這樣做的好處不僅是程式碼不會出現無謂的縮排,而且資源申請和釋放的程式碼在視覺上緊鄰彼此,永遠不會忘記。更不用說只需要在一個地方寫釋放的程式碼,下文無論發生什麼錯誤,導致該作用域退出我們都不用擔心資源不會被釋放掉了。我相信這一正規化很快就會成為所有C++程式碼分配和釋放資源的標準方式,因為這是C++十年來的演化所積澱下來的真正好的部分之一。

錯誤處理

前面提到,輸入法是一個特殊的東西,某種程度上他就跟使用者態的driver一樣,對錯誤的寬容度極低,出了錯誤之後可能造成很嚴重的後果:使用者資料丟失。不像其他獨立跑的程式可以隨便崩潰大不了重啟(或者程式自動重啟),所以從一開始,錯誤處理就被非常嚴肅地對待。

這裡就出現了一個兩難問題:嚴謹的錯誤處理要求不要忽視和放過任何一個錯誤,要麼當即處理,要麼轉發給呼叫者,層層往上傳播。任何被忽視的錯誤,都遲早會在程式碼接下去的執行流當中引發其他錯誤,這種被原始錯誤引發的二階三階錯誤可能看上去跟root cause一點關係都沒有,造成bugfix的成本劇增,這是我們專案快速的開發步調下所承受不起的成本。

然而另一方面,要想不忽視錯誤,就意味著我們需要勤勤懇懇地檢查並轉發錯誤,一個大規模的程式中隨處都可能有錯誤發生,如果這種檢查和轉發的成本太高,例如錯誤處理的程式碼會導致程式碼增加,結構臃腫,那麼程式設計師就會偷懶不檢查。而一時的偷懶以後總是要還的。

所以細心檢查是短期不斷付出成本,疏忽檢查則是長期付出成本,看上去怎麼都是個成本。有沒有既不需要短期付出成本,又不會導致長期付出成本的辦法呢?答案是有的。我們的專案全面使用異常來作為錯誤處理的機制。異常相對於錯誤程式碼來說有很多優勢,我曾經在2007年寫過一篇部落格《錯誤處理:為何、何時、如何》進行了詳細的比較,但是異常對於C++而言也屬於不容易用好的特性:

首先,為了保證當異常丟擲的時候不會產生資源洩露,你必須用RAII正規化封裝所有資源。這在C++98中可以做到,但代價較大,一方面智慧指標還沒有進入標準庫,另一方面智慧指標也只能管記憶體,其他資源莫非還都得費勁去寫一堆wrapper類,這個不便很大程度上也限制了異常在C++98下的被廣泛使用。不過幸運的是,我們這個專案開始的時候VS2010 SP1已經具備了tr1和lambda function,所以寫完上文那個簡單的ScopeGuard之後,資源的自動釋放問題就非常簡便了。

其次,C++的異常不像C#的異常那樣附帶Callstack。例如你在某個地方通過.at(i)來取一個vector的某個元素,然後i越界了,你會收到vector內部丟擲來的一個異常,這個異常只是說下標越界了,然後什麼其他資訊都木有,連個行號都沒有。要是不拋異常直接讓程式崩潰掉好歹還可以抓到一個minidump呢,這個因素一定程度上也限制了C++異常的被廣泛使用。Callstack顯然對於我們迅速診斷程式的bug有至關重要的作用,由於我們是一個不大的團隊,所以我們對質量的測試很依賴於微軟內部的dogfood使用者,我們release給dogfood使用者的是release版,倘若我們不用異常,用assert的話,固然是可以在release版也開啟assert,但assert同樣也只能提供很有限的資訊(檔案和行號,以及assert的表示式),很多時候這些資訊是不足夠理解一個bug的(更不用說還得手動截圖拷貝黏貼傳送郵件才能彙報一個bug了),所以往往接下來還需要在開發人員自己的環境下試圖重現bug。這就不夠理想了。理想情況下,一個bug發生的時刻,程式應該自己具備收集一切必要的資訊的能力。那麼對於一個bug來說,有哪些資訊是至關重要的呢?

  1. Error Message本身,例如“您的下標越界啦!”少部分情況下,光是Error Message已經足夠診斷。不過這往往是對於在開發的早期出現的一些簡單bug,到中後期往往這類簡單bug都被清除掉了,剩下的較為隱蔽的bug的診斷則需要多得多的資訊。
  2. Callstack。C++的異常由於效能的考慮,並不支援callstack。所以必須另想辦法。
  3. 錯誤發生地點的上下文變數的值:例如越界訪問,那麼越界的下標的值是多少,而被越界的容器的大小又是多少,等等。例如解析一段xml失敗了,那麼這段xml是什麼,當前解析到哪兒,等等。例如呼叫Win32 API失敗了,那麼Win32 Error Message是什麼。
  4. 錯誤發生的環境:例如目標程序是什麼。
  5. 錯誤發生之前使用者做了什麼:對於輸入法來說,例如錯誤發生之前的若干個鍵敲擊。

如果程式能夠自動把這些資訊收集並打包起來,傳送給開發人員,那麼就能夠為診斷提供極大的幫助(當然,既便如此仍然還是會有難以診斷的bug)。而且這一切都要以不增加寫程式碼過程中的開銷的方式來進行,如果每次都要在程式碼裡面做一堆事情來收集這些資訊,那煩都得煩死人了,沒有人會願意用的。

那麼到底如何才能無代價地儘量收集充足的資訊為診斷bug提供幫助呢?

首先是callstack,有很多種方法可以給C++異常加上callstack,不過很多方法會帶來效能損失,而且用起來也不方便,例如在每個函式的入口處加上一小段程式碼把函式名/檔案/行號列印到某個地方,或者還有一些利用dbghelp.dll裡面的StackWalk功能。我們使用的是沒有效能損失的簡單方案:在拋C++異常之前先手動MiniDumpWriteDump,在異常捕獲端把minidump發回來,在開發人員收到minidump之後可以使用VS或windbg進行除錯(但前提是相應的release版本必須開啟pdb)。可能這裡你會擔心,minidump難道不是很耗時間的嘛?沒錯,但是既然程式已經發生了異常,稍微多花一點時間也就無所謂了。我們對於“附帶minidump的異常”的使用原則是,只在那些真正“異常”的情況下丟擲,換句話說,只在你認為應該使用的assert的地方用,這類錯誤屬於critical error。另外我們還有不帶minidump的異常,例如網路失敗,xml解析失敗等等“可以預見”的錯誤,這類錯誤發生的頻率較高,所以如果每次都minidump會拖慢程式,所以這種情況下我們只拋異常不做minidump。

然後是Error Message,如何才能像assert那樣,在Error Message裡面包含表示式和檔案行號?

最後,也是最重要的,如何能夠把上下文相關變數的值capture下來,因為一方面release版本的minidump在除錯的時候所看到的變數值未必正確,另一方面如果這個值在堆上(例如std::string的內部buffer就在堆上),那就更看不著了。

所有上面這些需求我們通過一個ENSURE巨集來實現,它的使用很簡單:

ENSURE(0 <= index && index < v.size())(index)(v.size());

ENSURE巨集在release版本中同樣生效,如果發現表示式求值失敗,就會丟擲一個C++異常,並會在異常的.what()裡面記錄類似如下的錯誤資訊:

Failed: 0 <= index && index < v.size()
File: xxx.cpp Line: 123
Context Variables:
    index = 12345
    v.size() = 100

(如果你為stream過載了接收vector的operator <<,你甚至可以把vector的元素也列印到error message裡頭)

由於ENSURE丟擲的是一個自定義異常型別ExceptionWithMinidump,這個異常有一個GetMinidumpPath()可以獲得丟擲異常的時候記錄下來的minidump檔案。

ENSURE巨集還有一個很方便的feature:在debug版本下,拋異常之前它會先assert,而assert的錯誤訊息正是上面這樣。Debug版本assert的好處是可以讓你有時間attach debugger,保證有完整的上下文。

利用ENSURE,所有對Win32 API的呼叫所發生的錯誤返回值就可以很方便地被轉化為異常丟擲來,例如:

ENSURE_WIN32(SHGetKnownFolderPath(rfid, 0, NULL, &p) == S_OK);

為了將LastError附在Error Message裡面,我們額外定義了一個ENSURE_WIN32:

#define ENSURE_WIN32(exp) ENSURE(exp)(GetLastErrorStr())

其中GetLastErrorStr()會返回Win32 Last Error的錯誤訊息文字。

而對於通過返回HRESULT來報錯的一些Win32函式,我們又定義了ENSURE_SUCCEEDED(hr):

#define ENSURE_SUCCEEDED(hr) \
    if(SUCCEEDED(hr)) \
else ENSURE(SUCCEEDED(hr))(Win32ErrorMessage(hr))

其中Win32ErrorMessage(hr)負責根據hr查到其錯誤訊息文字。

ENSURE巨集使得我們開發過程中對錯誤的處理變得極其簡單,任何地方你認為需要assert的,用ENSURE就行了,一行簡單的ENSURE,把bug相關的三大重要資訊全部記錄在案,而且由於ENSURE是基於異常的,所以沒有辦法被程式忽略,也就不會導致難以除錯的二階三階bug,此外異常不像錯誤程式碼需要手動去傳遞,也就不會帶來為了錯誤處理而造成的額外的開發成本(用錯誤程式碼來處理錯誤的最大的開銷就是錯誤程式碼的手工檢查和層層傳遞)。

ENSURE巨集的實現並不複雜,列印檔案行號和表示式文字的辦法和assert一樣,建立minidump的辦法(這裡只討論win32)是在__try中RaiseException(EXCEPTION_BREAKPOINT…),在__except中得到EXCEPTION_POINTERS之後呼叫MiniDumpWriteDump寫dump檔案。最tricky的部分是如何支援在後面capture任意多個區域性變數(ENSURE(expr)(var1)(var2)(var3)…),並且對每個被capture的區域性變數同時還得capture變數名(不僅是變數值)。而這個巨集無限展開的技術也在大概十年前就有了,還是Andrei Alexandrescu寫的一篇DDJ文章:Enhanced Assertions 。神奇的是,我的CSDN部落格當年第一篇文章就是翻譯的它,如今十年後又在自己的專案中用到,真是有穿越的感覺,而且穿越的還不止這一個,我們專案不用任何第三方庫,包括boost也不用,這其實也沒有帶來什麼不便,因為boost的大量有用的子庫已經進入了TR1,唯一的不便就是C++被廣為詬病的:沒有一個好的event實現,boost.signal這種非常強大的工業級實現當然是可以的,不過對於我們的專案來說boost.signal的許多feature根本用不上,屬於殺雞用牛刀了,因此我就自己寫了一個剛剛滿足我們專案的特定需求的event實現(使用tr1::function和lambda,這個signal的實現和使用都很簡潔,可惜variadic templates沒有,不然還會更簡潔一些)。我在03年寫boost原始碼剖析系列的時候曾經詳細剖析了boost.signal的實現技術,想不到十年前關注的技術十年後還會在專案中用到。

由於輸入法對錯誤的容忍度較低,所以我們在所有的出口處都設定了兩重柵欄,第一重catch所有的C++異常,如果是ExceptionWithMinidump型別,則傳送帶有dump的問題報告,如果是其他繼承自std::exception的異常型別,則僅傳送包含.what()訊息的問題報告,最後如果是catch(…)收到的那就沒辦法了,只能傳送“unknown exception occurred”這種訊息回來了。

inline void ReportCxxException(std::exception_ptr ex_ptr)
{
    try
    {
        std::rethrow_exception(ex_ptr);
    }
    catch(ExceptionWithMiniDump& ex)
    {
        LaunchProblemReporter(…, ex.GetMiniDumpFilePath());
    }
    catch(std::exception& ex)
    {
        LaunchProblemReporter(…, ex.what());
    }
    catch(...)
    {
        LaunchProblemReporter("Unknown C++ Exception"));
    }
}

C++異常外面還加了一層負責捕獲Win32異常的,捕獲到unhandled win32 exception也會寫minidump併發回。

考慮到輸入法應該“能不崩潰就不崩潰”,所以對於C++異常而言,除了彈出問題報告程式之外,我們並不會阻止程式繼續執行,這樣做有以下幾個原因:

  1. 很多時候C++異常並不會使得程式進入不可預測的狀態,只要合理使用智慧指標和ScopeGuard,該釋放的該回滾的操作都能被正確執行。
  2. 輸入法的引擎的每一個輸入session(從開始輸入到上詞)理論上是獨立的,如果session中間出現異常應該允許引擎被reset到一個可知的好的狀態。
  3. 輸入法核心中有核心模組也有非核心模組,引擎屬於核心模組,雲候選詞、換膚、還有我們的創新feature:Rich Candidates(目前被譯為多媒體輸入,但其實沒有準確表達出這個feature的含義,只不過第一批release的apps確實大多是輸入多媒體的,但我們接下來會陸續更新一系列的Rich Candidates Apps就不止是多媒體了)也屬於非核心模組,非核心模組即便出了錯誤也不應該影響核心的工作。因此對於這些模組而言我們都在其出口處設定了Error Boundary,捕獲一切異常以免影響整個核心的運作。

另一方面,對於Native Language而言,除了語言級別的異常,總還會有Platform Specific的“硬”異常,例如最常見的Access Violation,當然這種異常越少越好(我們的程式碼基中鼓勵使用ENSURE來檢查各種pre-condition和post-condition,因為一般來說Access Violation不會是第一手錯誤,它們幾乎總是由其他錯誤導致的,而這個“其他錯誤”往往可以用ENSURE來檢查,從而在它導致Access Violation之前就丟擲語言級別的異常。舉一個簡單的例子,還是vector的元素訪問,我們可以直接v[i],如果i越界,會Access Violation,那麼這個Access Violation便是由之前的第一手錯誤(i越界)所導致的二階異常了。而如果我們在v[i]之前先ENSURE(0 <= i && i < v.size())的話,就可以阻止“硬”異常的發生,轉而成為彙報一個語言級別的異常,語言級別的異常跟平臺相關的“硬”異常相比的好處在於:

  1. 語言級別異常的資訊更豐富,你可以capture相關的變數的值放在異常的錯誤訊息裡面。
  2. 語言級別的異常是“同步”的,一個寫的規範的程式可以保證在語言級別異常發生的情況下始終處於可知的狀態。C++的Stack Unwind機制可以確保一切善後工作得到執行。相比之下當平臺相關的“硬”異常發生的時候你既不會有機會清理資源回滾操作,也不能確保程式仍然處於可知的狀態。所以語言級別的異常允許你在模組邊界上設定Error Boundary並且在非核心模組失敗的時候仍然保持程式執行,語言級別的異常也允許你在核心模組,例如引擎的出口設定Error Boundary,並且在出錯的情況下reset引擎到一個乾淨的初始狀態。簡言之,語言級別的異常讓程式更健壯。

理想情況下,我們應該、並且能夠通過ENSURE來避免幾乎所有“硬”異常的發生。但程式設計師也是人,只要是程式碼就會有疏忽,萬一真的發生了“硬”異常怎麼辦?對於輸入法而言,即便出現了這種很遺憾的情況我們仍然不希望你的宿主程式崩潰,但另一方面,由於“硬”異常使得程式已經處於不可知的狀態,我們無法對程式以後的執行作出任何的保障,所以當我們的錯誤邊界處捕獲這類異常的時候,我們會設定一個全域性的flag,disable整個的輸入法核心,從使用者的角度來看就是輸入法不工作了,但一來宿主程式沒有崩潰,二來你的所有鍵敲擊都會被直接被宿主程式響應,就像沒有開啟輸入法的時候一樣。這樣一來即便在最壞的情況之下,宿主程式仍然有機會去儲存資料並體面退出。

所以,綜上所述,通過基於C++異常的ENSURE巨集,我們實現了以下幾個目的:

  1. 極其廉價的錯誤檢查和彙報(和assert一樣廉價,卻沒有assert的諸多缺陷):尤其是對於快速開發來說,既不可忽視錯誤,又不想在錯誤彙報和處理這種(非正事)上消耗太多的時間,這種時候ENSURE是完美的方案。
  2. 豐富的錯誤資訊。
  3. 不可忽視的錯誤:編譯器會忠實負責stack unwind,不會讓一個錯誤被藏著掖著,最後以二階三階錯誤的方式表現出來,給診斷造成麻煩。
  4. 健壯性:看上去到處拋異常會讓人感覺程式不夠健壯,而實際上恰恰相反,如果程式真的有bug,那麼一定會浮現出來,即便你不用異常,也並沒有消除錯誤本身,遲早錯誤會以其他形式表現出來,在程式的世界裡,有錯誤是永遠藏不住的。而異常作為語言級別支援的錯誤彙報和處理機制,擁有同步和自動清理的特點,支援模組邊界的錯誤屏障,支援在錯誤發生的時候重置程式到乾淨的狀態,從而最大限度保證程式的正常執行。如果不用異常而用error code,只要疏忽檢查一點,遲早會導致“硬”異常,而一旦後者發生,基本剩下的也別指望程式還能正常工作了,能做得最負責任的事情就是別導致宿主崩潰。

另一方面,如果使用error code而不用異常來彙報和處理錯誤,當然也是可以達到上這些目的,但會給開發帶來高昂的代價,設想你需要把每個函式的返回值騰出來用作HRESULT,然後在每個函式返回的時候必須check其返回錯誤,並且如果自己不處理必須勤勤懇懇地轉發給上層。所以對於error code來說,要想快就必須犧牲周密的檢查,要想周密的檢查就必須犧牲編碼時間來做“不相干”的事情(對於需要周密檢查的錯誤敏感的應用來說,最後會搞到程式碼裡面一眼望過去盡是各種if-else的返回值錯誤檢查,而真正幹活的程式碼卻縮在不起眼的角落,看過win32程式碼的同學應該都會有這個體會)。而只有使用異常和ENSURE,才真正實現了既幾乎不花任何額外時間、又不至於漏過任何一個第一手錯誤的目的。

最後簡單提一下異常的效能問題,現代編譯器對於異常處理的實現已經做到了在happy path上幾乎沒有開銷,對於絕大多數應用層的程式來說,根本無需考慮異常所帶來的可忽視的開銷。在我們的對速度要求很敏感的輸入法程式中,做performance profiling的時候根本看不到異常帶來任何可見影響(除非你亂用異常,例如拿異常來取代正常的bool返回值,或者在loop裡面拋接異常,等等)。具體的可以參考[email protected]上的The Importance of Being Native的1小時06分處。

C++11的其他特性的運用

資源管理和錯誤處理是現代C++風格最醒目的標誌,接下來再說一說C++11的其他特性在我們專案中的使用。

首先還是lambda,lambda除了配合ON_SCOPE_EXIT使用威力無窮之外,還有一個巨大的好處,就是建立on-the-fly的tasks,交給另一個執行緒去執行,或者建立一個delegate交給另一個類去呼叫(像C#的event那樣)。(當然,lambda使得STL變得比原來易用十倍這個事情就不說了,相信大家都知道了),例如我們有一個BackgroundWorker類,這個類的物件在內部維護一個執行緒,這個執行緒在內部有一個message loop,不斷以Thread Message的形式接收別人委託它執行的一段程式碼,如果是委託的同步執行的任務,那麼委託(呼叫)方便等在那裡,直到任務被執行完,如果執行過程中出現任何錯誤,會首先被BackgroundWorker捕獲,然後在呼叫方執行緒上重新丟擲(利用C++11的std::exception_ptrstd::current_exception()以及std::rethrow_exception())。BackgroundWorker的使用方式很簡單:

bgWorker.Send([&]
{
.. /* do something */
});

有了lambda,不僅Send的使用方式像上面這樣直觀,Send本身的實現也變得很優雅:

bool Send(std::function<void()> action)
{
    HANDLE done = CreateEvent(NULL, TRUE, FALSE, NULL);

    std::exception_ptr  pCxxException;
    unsigned int        win32ExceptionCode = 0;
    EXCEPTION_POINTERS* win32ExceptionPointers = nullptr;

    std::function<void()> synchronousAction = [&]
    {
        ON_SCOPE_EXIT([&] {
            SetEvent(done);
        });

        AllExceptionsBoundary(
            action,
            [&](std::exception_ptr e)
                { pCxxException = e; },
            [&](unsigned int code, EXCEPTION_POINTERS* ep)
                { win32ExceptionCode = code;
                  win32ExceptionPointers = ep; });
    };

    bool r = Post(synchronousAction);

    if(r)
    {
        WaitForSingleObject(done, INFINITE);
        CloseHandle(done);

        // propagate error (if any) to the calling thread
        if(!(pCxxException == nullptr))
        {
            std::rethrow_exception(pCxxException);
        }

        if(win32ExceptionPointers)
        {
            RaiseException(win32ExceptionCode, ..);
        }
    }
    return r;
}


這裡我們先把外面傳進來的function wrap成一個新的lambda function,後者除了負責呼叫前者之外,還負責在呼叫完了之後flag一個event從而實現同步等待的目的,另外它還負責捕獲任務執行中可能發生的錯誤並儲存下來,留待後面在呼叫方執行緒上重新raise這個錯誤。

另外一個使用lambda的例子是:由於我們專案中需要解析XML的地方用的是MSXML,而MSXML很不幸是個COM元件,COM元件要求生存在特定的Apartment裡面,而輸入法由於是被動載入的dll,其主執行緒不是輸入法本身建立的,所以主執行緒到底屬於什麼Apartment不由輸入法來控制,為了確保萬無一失,我們便將MSXML host在上文提到的一個專屬的BackgroundWorker物件裡面,由於BackgroundWorker內部會維護一個執行緒,這個執行緒的apartment是由我們全權控制的。為此我們給MSXML建立了一個wrapper類,這個類封裝了這些實現細節,只提供一個簡便的使用介面:

XMLDom dom;
dom.LoadXMLFile(xmlFilePath);

dom.Visit([&](std::wstring const& elemName, IXMLDOMNode* elem)
{
    if(elemHandlers.find(elemName) != elemHandlers.end())
    {
        elemHandlers[elemName](elem);
    }
});

基於上文提到的BackgroundWorker的輔助,這個wrapper類的實現也變得非常簡單:

void Visit(TNodeVisitor const& visitor)
{
    bgWorker_.Send([&] {
        ENSURE(pXMLDom_ != NULL);

        IXMLDOMElement* root;
        ENSURE(pXMLDom_->get_documentElement(&root) == S_OK);

        InternalVisit(root, visitor);
    });
}

所有對MSXML物件的操作都會被Send到host執行緒上去執行。

另一個很有用的feature就是static_assert,例如我們在ENSURE巨集的定義裡面就有一行:

static_assert(std::is_same<decltype(expr), bool>::value, "ENSURE(expr) can only be used on bool expression");

避免調ENSURE(expr)的時候expr不是bool型別,確給隱式轉換成了bool型別,從而出現很隱蔽的bug。

至於C++11的Move Semantics給程式碼帶來的變化則是潤物細無聲的:你可以不用擔心返回vector, string等STL容易的效能問題了,程式碼的可讀性會得到提升。

最後,由於VS2010 SP1並沒有實現全部的C++11語言特性,所以我們也並沒有用上全部的特性,不過話說回來,已經被實現的特性已經相當有用了。

程式碼質量

在各種長期和短期壓力之下寫程式碼,當然程式碼質量是重中之重,尤其是對於C++程式碼,否則各種積累的技術債會越壓越重。對於創新專案而言,程式碼基處於不停的演化當中,一開始的時候什麼都不是,就是一個最簡單的骨架,然後逐漸出現一點prototype的樣子,隨著不斷的加進新的feature,再不斷重構,抽取公共模組,形成concept和abstraction,isolate介面,拆分模組,最終prototype演變成product。關於程式碼質量的書很多,有一些寫得很好,例如《The Art of Readable Code》,《Clean Code》或者《Implementation Patterns》。這裡沒有必要去重複這些書已經講得非常好的技術,只說說我認為最重要的一些高層的指導性原則:

  1. 持續重構:避免程式碼質量無限滑坡的辦法就是持續重構。持續重構是The Boy Scout Rule的一個推論。離開一段程式碼的時候永遠保持它比上次看到的時候更乾淨。關於重構的書夠多的了,細節的這裡就不說了,值得注意的是,雖然重構有一些通用的手法,但具體怎麼重構很多時候是一個領域相關的問題,取決於你在寫什麼應用,有些時候,重構就是重設計。例如我們的程式碼基當中曾經有一個tricky的設計,因為相當tricky,導致在後來的一次程式碼改動中產生了一個很隱蔽的regression,這使得我們重新思考這個設計的實現,並最終決定換成另一個(很遺憾仍然還是tricky的)實現,後者雖然仍然tricky(總會有不得已必須tricky的地方),但是卻有一個好處:即便以後程式碼改動的過程中又涉及到了這塊程式碼並且又導致了regression,那麼至少所導致的regression將不再會是隱蔽的,而是會很明顯。
  2. KISS:KISS是個被說爛了的原則,不過由於”Simple”這個詞的定義很主觀,所以KISS並不是一個很具有實踐指導意義的原則。我認為下面兩個原則要遠遠有用得多: 1) YAGNI:You Ain’t Gonna Need It。不做不必要的實現,例如不做不必要的泛化,你的目的是寫應用,不是寫通用庫。尤其是在C++裡面,要想寫通用庫往往會觸及到這門語言最黑暗的部分,是個時間黑洞,而且由於語言的不完善往往會導致不完備的實現,出現使用上的陷阱。2) 程式碼不應該是沒有明顯的bug,而應該是明顯沒有bug:這是一條很具有指導意義的原則,你的程式碼是否一眼看上去就明白什麼意思,就確定沒有bug?例如Haskell著名的quicksort就屬於明顯沒有bug。為了達到這個目的,你的程式碼需要滿足很多要求:良好的命名(傳達意圖),良好的抽象,良好的結構,簡單的實現,等等。最後,KISS原則不僅適用於實現層面,在設計上KISS則更加重要,因為設計是決策的第一環,一個設計可能需要三四百行程式碼,而另一個設計可能只需要三四十行程式碼,我們就曾遇到過這樣的情況。一個糟糕的設計不僅製造大量的程式碼和bug(程式碼當然是越少越好,程式碼越少bug就越少),成為後期維護的負擔,侵入式的設計還會增加模組間的粘合度,導致被這個設計拖累的程式碼像滾雪球一樣越來越多,所以code review之前更重要的還是要做design review,前面決策做錯了後面會越錯越離譜。
  3. 解耦原則:這個就不多說了,都說爛了。不過具體怎麼解耦很多時候還是個領域相關的問題。雖然有些通用正規化可循。
  4. Best Practice Principle:對於C++開發來說尤其重要,因為在C++裡面,同一件事情往往有很多不同的(但同樣都有缺陷的)實現,而實現的成本往往還不低,所以C++社群多年以來一直在積澱所謂的Best Practices,其中的一個子集就是Idioms(慣用法),由於C++的學習曲線較為陡峭,悶頭寫一堆(有缺陷)的實現的成本很高,所以在一頭扎進去之前先大概瞭解有哪些Idioms以及各自適用的場景就變得很有必要。站在別人的肩膀上好過自己掉坑裡。

image

[我們在招人] 由於我們之前的star intern祁航同學離職去國外讀書了,所以再次尋找實習生一枚,參與英庫拼音輸入法client端的開發,要求如下:

  1. 紮實的win32系統底層知識。
  2. 紮實的C++功底,對現代C++風格有一定的認識(瞭解C++11更好)。
  3. 理解編寫乾淨、可讀、高效的程式碼的重要性。(最好讀過clean code或implementation patterns)
  4. 對新技術有熱忱,有很強的學習能力;善於溝通,喜歡討論。

有興趣的請發簡歷至[email protected]。此外,為了節省我們雙方的時間,我希望你在發簡歷的同時回答以下兩個問題:

  1. 簡要介紹一下你在大學裡面學習技術的歷程,例如看過那些書,經常上那些地方查資料,(如果有)參加過哪些開源專案,(如果有)寫過哪些技術文章,等等。
  2. 有針對性地對於上面的要求中提到的幾點做簡要的介紹:例如對win32有哪些瞭解,C++方面的技術儲備,以及對高質量程式碼的認識,等等。

相關推薦

膜拜: C++11有關的(現在編譯器支援C++11的)

過去的一年我在微軟亞洲研究院做輸入法,我們的產品叫“英庫拼音輸入法” (下載Beta版),如果你用過“英庫詞典”(現已更名為必應詞典),應該知道“英庫”這個名字(實際上我們的核心開發團隊也有很大一部分來源於英庫團隊的老成員)。整個專案是微軟亞洲研究院的自然語言處理組、網際網路搜尋與挖掘組和我們創新工程中心,

c# 異常檔案中的類能進行設計,因此未能為該檔案顯示設計器。設計器檢查出檔案中有以下類: FormMain --- 未能載入基類

出現該問題的原因:FormMain從FormMainBase繼承之後,一旦修改FormMainBase就會出現這個問題解決方案:(1-4是搜尋網友的)   1: 關閉VS所有視窗,後重啟.即可返回正常. 2: 第一種方案不成功,關閉VS所有視窗,點選解決方案->清理解決

[]Linux關閉Tomcat為什麼要kill, 而是shutdown.sh

解釋一: 執行tomcat/bin/shutdown.sh,tomcat停止, 但它的java程序還在, 不過狀態為S(sleep), 不是執行時的R(Runnable), 如果不kill的話, 這種程序越來越多。 解釋二: 通過shutdown.sh指令碼關閉t

[]USB-C和Thunderbolt 3連線線你搞懂了嗎?---沒搞明白.

USB-C和Thunderbolt 3連線線你搞懂了嗎? 2018年11月25日 07:30  6318 次閱讀 稿源:威鋒網 3 條評論 按照計算行業的風潮,USB Type-C 將會是下一代主流的介面。不過,

linux下安裝或升級GCC4.8.2,以支援C++11標準[]

在編譯kenlm的時候需要安裝gcc, 然後還需要安裝g++。 g++安裝命令:sudo apt-get install g++ ----------------------以下為網上轉載內容,加上自己修改------------------ 本文主要介紹在Linux系統下,如何升級GCC以支援C+

C++11有關執行緒同步的使用

互斥量和條件變數是控制執行緒同步的常用手段,用來保護多執行緒同時訪問的共享資料。 c++11提供了這些操作,同時還提供了原子變數和一次呼叫的操作,用起來非常的方便。 我們在這裡只介紹如何在C++中使用這些同步機制,有關概念的介紹我們就不在這裡多說了。

Base64 Encoder / Decoder 【C++版】-

這個版本是純C++版。 就兩個函式,一個是base64::encode()和base64::decode(),一個用來加密一個用來解密。 下面是具體的實現。 /* base64.hpp - base64 encoder/decoder implementing section

C++中有關volatile關鍵字的作用--阻止編譯器將其變數優化快取到暫存器(和執行緒相關)(自百度)

       就象大家更熟悉的const一樣,volatile是一個型別修飾符(type specifier)。        它是被設計用來修飾被不同執行緒訪問和修改的變數 。        如果沒有volatile,基本上會導致這樣的結果:要麼無法編寫多執行緒

[] 在Tornado下的C++開發

5.2 在Tornado下的C++開發 基本的C++支援被捆綁在Tornado開發環境裡。VxWorks提供了包含對所有程式的C++安全宣告的標頭檔案和必須的run-time support.標準的Tornado互動式開發工具如偵錯程式(debugger),shell,和新增的載入器(loader)都包含

()Visual C#常用函式和方法集彙總

1、DateTime 數字型 System.DateTime currentTime=new System.DateTime();   1.1 取當前年月日時分秒 currentTime=System.DateTime.Now;   1.2

Eclipse安裝svn插件的幾種方式 ....

如果 version name feature help sin 鏈接 exe 文件 Eclipse安裝svn插件的幾種方式 1.在線安裝: (1).點擊 Help --> Install New Software... (2).在彈出的窗口中點擊add按鈕,輸

三種數據庫日期字符串對照sql server、oracle、mysql(V4.11

to_date 擴展 article zha ret lar span timestamp tracking 三種數據庫日期轉換對照: http://blog.csdn.net/zljjava/article/details/17552741 SQL類

centos6.8下安裝matlab2009(圖片

.so 完成 流程 bsp ror not libraries .com pen 前言 如何優雅的在centos6.8上安裝matlab2009. 流程 不過我個人安裝過程完後啟動matlab的時候又出現了新問題: error while loading shared

C#方法有關內容的總結--C#基礎

programe height tasks adk 實例方法 text 三角形面積 string math.sqrt 1、靜態方法與實例方法 using System;using System.Collections.Generic;using System.Linq;u

C#認證考試試題匯編: 第二單元:1,11

bool namespace this cti tro wow static private ring 1、 using System;using System.Collections.Generic;using System.Linq;using System.Text

網站架構

設備 老男孩 第一次 緩存 rbd chm nbsp 性能 sql http://oldboy.blog.51cto.com/2561410/736710 高並發訪問的核心原則其實就一句話“把所有的用戶訪問請求都盡量往前推”。 如果把來訪用戶比作來犯的"敵人",我們一

11.18 Apache戶認證 - 11.19/11.20 域名跳 - 11.21 Apache訪問日誌

11.18 apache用戶認證 - 11.19/11.20 域名跳轉 - 11.21 apache訪問日誌- 11.18 Apache用戶認證 - 11.19/11.20 域名跳轉 - 11.21 Apache訪問日誌 - 擴展 - apache虛擬主機開啟php的短標簽 http://ask.apele

[收集] Java註解

cto 這一 字段 declare rri 鼓勵 指定 包含成員 容易 1、Annotation 它的作用是修飾編程元素。什麽是編程元素呢?例如:包、類、構造方法、方法、成員變量等。Annotation(註解)就是Java提供了一種元程序中的元素關聯任何信息

11.18 Apache戶認證11.19 11.20 域名跳11.21 Apache訪問日誌

十周三次課(3月2日)11.18 Apache用戶認證更改虛擬主機內容vim /usr/local/apache2.4/conf/extra/httpd-vhosts.conf增加用戶名與密碼? /usr/local/apache2.4/bin/htpasswd -c -m /data/.htpasswd a

11.18 Apache戶認證 11.19/11.20 域名跳 11.21 Apache訪問日誌

11.18 Apache用戶認證 11.11.18 Apache用戶認證 11.19/11.20 域名跳轉 httpd.conf,刪除rewrite_module (shared) 前面的#curl -x127.0.0.1:80 -I 123.com //狀態碼為301 11.21 Apache訪問日誌