C++11(及現代C++風格)和快速迭代式開發

過去的一年我在微軟亞洲研究院作輸入法,咱們的產品叫「英庫拼音輸入法」 (下載Beta版),若是你用過「英庫詞典」(現已改名爲必應詞典),應該知道「英庫」這個名字(實際上咱們的核心開發團隊也有很大一部分來源於英庫團隊的老成員)。整個項目是微軟亞洲研究院的天然語言處理組、互聯網搜索與挖掘組和咱們創新工程中心,以及微軟中國Office商務軟件部(MODC)多組合做的結果。至於咱們的輸入法有哪些創新的feature,以及這些feature背後的種種有趣故事… 本文暫不討論。雖然整個過程當中我也參與了不少feature的設想和設計,但90%的職責仍是開發,因此做爲client端的核心開發人員之一,我想跟你們分享這一年來在項目中全面使用C++11以及現代C++風格Elements of Modern C++ Style)來作開發的種種經驗。javascript

咱們用的開發環境是VS2010 SP1,該版本已經支持了至關多的C++11的特性:lambda表達式,右值引用,auto類型推導,static_assert,decltype,nullptr,exception_ptr等等。C++曾經飽受「學院派」標籤的困擾,不過這個標籤着實被貼得挺冤,C++11的新feature沒有一個是從學院派角度出發來設計的,以上提到的全部這些feature都在咱們的項目中獲得了適得其所的運用,而且帶來了很大的收益。尤爲是lambda表達式。php

提及來我跟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的實踐經驗。算是對努力一年的項目發佈初版的一個小結。html

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

這個項目咱們從零開始,,而client端的核心開發人員也很緊湊,只有3個。這個項目有不少特殊之處,對高效的快速迭代開發提出了很大的挑戰(研究院所倡導的「以實踐爲驅動的研究(Deployment-Driven-Research)」要求咱們迅速對用戶的需求做出響應):java

  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++風格以及它給咱們開發帶來的好處。express

資源管理windows

說到Native Languages就不得不說資源管理,由於資源管理向來都是Native Languages的一個大問題,其中內存管理又是資源當中的一個大問題,因爲堆內存須要手動分配和釋放,因此必須確保內存獲得釋放,對此通常原則是「誰分配誰負責釋放」,但即使如此仍然仍是常常會致使內存泄漏、野指針等等問題。更不用說這種手動釋放給API設計帶來的問題(例如Win32 API WideCharToMultiByte就是一個典型的例子,你須要提供一個緩衝區給它來接收編碼轉換的結果,可是你又不能確保你的緩衝區足夠大,因此就出現了一個兩次調用的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裏面拋接異常,等等)。具體的能夠參考GoingNative2012@Channel9上的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以及各自適用的場景就變得頗有必要。站在別人的肩膀上好過本身掉坑裏。

對了,這篇文章從頭至尾是用英庫拼音輸入法寫的。最後貼個圖:(http://pinyin.engkoo.com/

image


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

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

有興趣的請發簡歷至liuweipeng@outlook.com。此外,爲了節省咱們雙方的時間,我但願你在發簡歷的同時回答如下兩個問題:

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

http://mindhacks.cn/2012/08/27/modern-cpp-practices/

相關文章
相關標籤/搜索