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

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

過去的一年我在微軟亞洲研究院做輸入法,我們的產品叫“英庫拼音輸入法” (下載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裏面拋接異常,等等)。具體的可以參考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/


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

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

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

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

 



資源管理那裏. 既然你使用了STL,並且強調了使用C++ 的目的是爲了避免重複造輪子.那麼爲什麼不使用c++0x的智能指針來完成資源管理呢,至少你寫的例子,用shared_ptr是可以實現的,況且宏對於modern c++來說本身就是不推薦使用的.
lambda表達式確實簡單易用,配合std::tr1::function和decltype,可以簡化很多實現.
大牛



非常有借鑑意義。
資源管理實際上std::shared_ptr提供的構造函數有一個參數可以傳遞一個函數,不必自己再寫。
template<class _Ux,
class _Dx>
shared_ptr(_Ux *_Px, _Dx _Dt)



嗯,正在努力試用C++11.可惜,boost::range貌似沒有包含進去。
引用“zdarkalone”的評論:ScopeGuard用std::function碰到參數多會在堆上分配內存不好吧?這裏不需要type...

變參模板不如改一下傳參方式, 給參數棧加個類似反射的機制就方便多了
Re: zdarkalone 2012-08-30 10:53發表 [回覆]
回覆pl___:我不理解,模板知道所傳參數類型的吧?
只是c++11之前定義模板的時候參數數目固定而已。

變參主要方便std::tuple, boost::variant, boost::fusion這類庫的編寫,原來要把不同參數數目的模板全部用宏定義一遍
造成編譯速度巨慢,擴展性巨差,代碼巨亂,調試巨困難
有變參只要用遞歸的方法寫就行了,寫起來像寫haskell。

真·函數式編程。
Re: pl___ 2012-08-31 17:55發表 [回覆]
回覆zdarkalone:嗯 變參只能用遞歸這個很不好

還有個問題就是 模板是靜態的 不能封到動態鏈接庫裏



現在工作中用了一些C++11的特性,比如nullptr, unique_ptr,auto, boost庫類也用了不少。。。

好文有指導意義,原來一直不怎麼理解lambda表達式和function object除了在task之外還能怎麼用,看了資源管理章節後受益匪淺!
ScopeGuard用std::function碰到參數多會在堆上分配內存不好吧?這裏不需要type erasure,用模板實現更好。
異常部分有點意思。
c++11裏右值引用和變參模板兩個大頭沒講。
右值引用帶來的perfect forward解決了歷史遺留的大問題。
http://thbecker.net/articles/rvalue_references/section_01.html
變參模板更是直接把c++帶進了函數式編程的新紀元。
有了變參模板語言抽象力又上了一個層次。
intel編譯器已經支持變參模板了,可以集成到vs環境下使用,就是編譯慢了點。。。
ON_SCOPE_EXIT也可以通過使用boost庫在c++98裏解決


RAII好東西
異常有點不好取捨
int func(int) throw(xxx){ ... } 這種聲明用不好
1句話的調用加上try catch 至少變4句 把邏輯都衝散了 不知道怎麼解決

另外問一下沒有全自動的gc(像java那樣完全不管回收)對開發效率有多大影響.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章