C++11(及现代C++风格)和快速迭代式开发
過去的一年我在微軟亞洲研究院做輸入法,我們的產(chǎn)品叫“英庫(kù)拼音輸入法” (下載Beta版),如果你用過“英庫(kù)詞典”(現(xiàn)已更名為必應(yīng)詞典),應(yīng)該知道“英庫(kù)”這個(gè)名字(實(shí)際上我們的核心開發(fā)團(tuán)隊(duì)也有很大一部分來(lái)源于英庫(kù)團(tuán)隊(duì)的老成員)。整個(gè)項(xiàng)目是微軟亞洲研究院的自然語(yǔ)言處理組、互聯(lián)網(wǎng)搜索與挖掘組和我們創(chuàng)新工程中心,以及微軟中國(guó)Office商務(wù)軟件部(MODC)多組合作的結(jié)果。至于我們的輸入法有哪些創(chuàng)新的feature,以及這些feature背后的種種有趣故事… 本文暫不討論。雖然整個(gè)過程中我也參與了很多feature的設(shè)想和設(shè)計(jì),但90%的職責(zé)還是開發(fā),所以作為client端的核心開發(fā)人員之一,我想跟大家分享這一年來(lái)在項(xiàng)目中全面使用C++11以及現(xiàn)代C++風(fēng)格(Elements of Modern C++ Style)來(lái)做開發(fā)的種種經(jīng)驗(yàn)。
我們用的開發(fā)環(huán)境是VS2010 SP1,該版本已經(jīng)支持了相當(dāng)多的C++11的特性:lambda表達(dá)式,右值引用,auto類型推導(dǎo),static_assert,decltype,nullptr,exception_ptr等等。C++曾經(jīng)飽受“學(xué)院派”標(biāo)簽的困擾,不過這個(gè)標(biāo)簽著實(shí)被貼得挺冤,C++11的新feature沒有一個(gè)是從學(xué)院派角度出發(fā)來(lái)設(shè)計(jì)的,以上提到的所有這些feature都在我們的項(xiàng)目中得到了適得其所的運(yùn)用,并且?guī)?lái)了很大的收益。尤其是lambda表達(dá)式。
說起來(lái)我跟C++也算是有相當(dāng)大的緣分,03年還在讀本科的時(shí)候,第一篇發(fā)表在程序員上面的文章就是Boost庫(kù)的源碼剖析,那個(gè)時(shí)候Boost庫(kù)在國(guó)內(nèi)還真是相當(dāng)?shù)年?yáng)春白雪,至今已經(jīng)快十年了,Boost庫(kù)如今已經(jīng)是寫C++代碼不可或缺的庫(kù),被譽(yù)為“準(zhǔn)標(biāo)準(zhǔn)庫(kù)”,C++的TR1基本就脫胎于Boost的一系列子庫(kù),而TR2同樣也大量從Boost庫(kù)中取材。之后有好幾年,我在CSDN上的博客幾乎純粹是C++的前沿技術(shù)文章,包括從06年就開始寫的“C++0x漫談”系列。(后來(lái)寫技術(shù)文章寫得少了,也就把博客從CSDN博客獨(dú)立了出來(lái),便是現(xiàn)在的mindhacks.cn)。自從獨(dú)立博客了之后我就沒有再寫過C++相關(guān)的文章(不過仍然一直對(duì)C++的發(fā)展保持了一定的關(guān)注),一方面我喜歡關(guān)注前沿的進(jìn)展,寫完了Boost源碼剖析系列和C++0x漫談系列之后我覺得這一波的前沿進(jìn)展從大方面來(lái)說也都寫得差不多了,所以不想再費(fèi)時(shí)間。另一方面的原因也是我雖然對(duì)C++關(guān)注較深,但實(shí)踐經(jīng)驗(yàn)卻始終絕大多數(shù)都是“替代經(jīng)驗(yàn)”,即從別人那兒看來(lái)的,并非自己第一手的。而過去一年來(lái)深度參與的英庫(kù)輸入法項(xiàng)目彌補(bǔ)了這個(gè)缺憾,所以我就決定重新開始寫一點(diǎn)C++11的實(shí)踐經(jīng)驗(yàn)。算是對(duì)努力一年的項(xiàng)目發(fā)布第一版的一個(gè)小結(jié)。
09年入職微軟亞洲研究院之后,前兩年跟C++基本沒沾邊,第一個(gè)項(xiàng)目倒是用C++的,不過是工作在既有代碼基上,時(shí)間也相對(duì)較短。第二個(gè)項(xiàng)目為Bing Image Search用javascript寫前端,第三個(gè)項(xiàng)目則給Visual Studio 2012寫Code Clone Detection,用C#和WPF。直到一年前英庫(kù)輸入法這個(gè)項(xiàng)目,是我在研究院的第四個(gè)項(xiàng)目了,也是最大的一個(gè),一年來(lái)我很開心,因?yàn)橛只氐搅薈++。
這個(gè)項(xiàng)目我們從零開始,,而client端的核心開發(fā)人員也很緊湊,只有3個(gè)。這個(gè)項(xiàng)目有很多特殊之處,對(duì)高效的快速迭代開發(fā)提出了很大的挑戰(zhàn)(研究院所倡導(dǎo)的“以實(shí)踐為驅(qū)動(dòng)的研究(Deployment-Driven-Research)”要求我們迅速對(duì)用戶的需求作出響應(yīng)):
至于為什么要用C++而不是C呢?對(duì)于我們來(lái)說理由很現(xiàn)實(shí):時(shí)間緊任務(wù)重,用C的話需要發(fā)明的輪子太多了,C++的抽象層次高,代碼量少,bug相對(duì)就會(huì)更少,現(xiàn)代C++的內(nèi)存管理完全自動(dòng),以至于從頭到尾我根本不記得曾遇到過什么內(nèi)存管理相關(guān)的bug,現(xiàn)代C++的錯(cuò)誤處理機(jī)制也非常適合快速開發(fā)的同時(shí)不用擔(dān)心bug亂飛,另外有了C++11的強(qiáng)大支持更是如虎添翼,當(dāng)然,這一切都必須建立在核心團(tuán)隊(duì)必須善用C++的大前提上,而這對(duì)于我們這個(gè)緊湊的小團(tuán)隊(duì)來(lái)說這不是問題,因?yàn)榇蠹叶加休^好的C++背景,沒有陡峭的學(xué)習(xí)曲線要爬。(至于C++在大規(guī)模團(tuán)隊(duì)中各人對(duì)C++的掌握良莠不齊的情況下所帶來(lái)的一些包袱本文也不作討論,呵呵,語(yǔ)言之爭(zhēng)別找我。)
下面就說說我們?cè)谶@個(gè)項(xiàng)目中是如何使用C++11和現(xiàn)代C++風(fēng)格來(lái)開發(fā)的,什么是現(xiàn)代C++風(fēng)格以及它給我們開發(fā)帶來(lái)的好處。
資源管理
說到Native Languages就不得不說資源管理,因?yàn)橘Y源管理向來(lái)都是Native Languages的一個(gè)大問題,其中內(nèi)存管理又是資源當(dāng)中的一個(gè)大問題,由于堆內(nèi)存需要手動(dòng)分配和釋放,所以必須確保內(nèi)存得到釋放,對(duì)此一般原則是“誰(shuí)分配誰(shuí)負(fù)責(zé)釋放”,但即便如此仍然還是經(jīng)常會(huì)導(dǎo)致內(nèi)存泄漏、野指針等等問題。更不用說這種手動(dòng)釋放給API設(shè)計(jì)帶來(lái)的問題(例如Win32 API?WideCharToMultiByte就是一個(gè)典型的例子,你需要提供一個(gè)緩沖區(qū)給它來(lái)接收編碼轉(zhuǎn)換的結(jié)果,但是你又不能確保你的緩沖區(qū)足夠大,所以就出現(xiàn)了一個(gè)兩次調(diào)用的pattern,第一次給個(gè)NULL緩沖區(qū),于是API返回的是所需的緩沖區(qū)的大小,根據(jù)這個(gè)大小分配緩沖區(qū)之后再第二次調(diào)用它,別提多別扭了)。
托管語(yǔ)言們?yōu)榱私鉀Q這個(gè)問題引入了GC,其理念是“內(nèi)存管理太重要了,不能交給程序員來(lái)做”。但GC對(duì)于Native開發(fā)也常常有它自己的問題。而且另一方面Native界也常常詬病GC,說“內(nèi)存管理太重要了,不能交給機(jī)器來(lái)做”。
C++也許是第一個(gè)提供了完美折衷的語(yǔ)言(不過這個(gè)機(jī)制直到C++11的出現(xiàn)才真正達(dá)到了易用的程度),即:既不是完全交給機(jī)器來(lái)做,也不是完全交給程序員來(lái)做,而是程序員先在代碼中指定怎么做,至于什么時(shí)候做,如何確保一定會(huì)得到執(zhí)行,則交由編譯器來(lái)確定。
首先是C++98提供了語(yǔ)言機(jī)制:對(duì)象在超出作用域的時(shí)候其析構(gòu)函數(shù)會(huì)被自動(dòng)調(diào)用。接著,Bjarne Stroustrup在TC++PL里面定義了RAII(Resource Acquisition is Initialization)范式(即:對(duì)象構(gòu)造的時(shí)候其所需的資源便應(yīng)該在構(gòu)造函數(shù)中初始化,而對(duì)象析構(gòu)的時(shí)候則釋放這些資源)。RAII意味著我們應(yīng)該用類來(lái)封裝和管理資源,對(duì)于內(nèi)存管理而言,Boost第一個(gè)實(shí)現(xiàn)了工業(yè)強(qiáng)度的智能指針,如今智能指針(shared_ptr和unique_ptr)已經(jīng)是C++11的一部分,簡(jiǎn)單來(lái)說有了智能指針意味著你的C++代碼基中幾乎就不應(yīng)該出現(xiàn)delete了。
不過,RAII范式雖然很好,但還不足夠易用,很多時(shí)候我們并不想為了一個(gè)CloseHandle, ReleaseDC, GlobalUnlock等等而去大張旗鼓地另寫一個(gè)類出來(lái),所以這些時(shí)候我們往往會(huì)因?yàn)榕侣闊┒苯邮謩?dòng)去調(diào)這些釋放函數(shù),手動(dòng)調(diào)的一個(gè)壞處是,如果在資源申請(qǐng)和釋放之間發(fā)生了異常,那么釋放將不會(huì)發(fā)生,此外,手動(dòng)釋放需要在函數(shù)的所有出口處都去調(diào)釋放函數(shù),萬(wàn)一某天有人修改了代碼,加了一處return,而在return之前忘了調(diào)釋放函數(shù),資源就泄露了。理想情況下我們希望語(yǔ)言能夠支持這樣的范式:
void foo() {HANDLE h = CreateFile(...);ON_SCOPE_EXIT { CloseHandle(h); }... // use the file }ON_SCOPE_EXIT里面的代碼就像是在析構(gòu)函數(shù)里面的一樣:不管當(dāng)前作用域以什么方式退出,都必然會(huì)被執(zhí)行。
實(shí)際上,早在2000年,Andrei Alexandrescu?就在DDJ雜志上發(fā)表了一篇文章,提出了這個(gè)叫做ScopeGuard 的設(shè)施,不過當(dāng)時(shí)C++還沒有太好的語(yǔ)言機(jī)制來(lái)支持這個(gè)設(shè)施,所以Andrei動(dòng)用了你所能想到的各種奇技淫巧硬是造了一個(gè)出來(lái),后來(lái)Boost也加入了ScopeExit庫(kù),不過這些都是建立在C++98不完備的語(yǔ)言機(jī)制的情況下,所以其實(shí)現(xiàn)非常不必要的繁瑣和不完美,實(shí)在是戴著腳鐐跳舞(這也是C++98的通用庫(kù)被詬病的一個(gè)重要原因),再后來(lái)Andrei不能忍了就把這個(gè)設(shè)施內(nèi)置到了D語(yǔ)言當(dāng)中,成了D語(yǔ)言特性的一部分(最出彩的部分之一)。
再后來(lái)就是C++11的發(fā)布了,C++11發(fā)布之后,很多人都開始重新實(shí)現(xiàn)這個(gè)對(duì)于異常安全來(lái)說極其重要的設(shè)施,不過絕大多數(shù)人的實(shí)現(xiàn)受到了2000年Andrei的原始文章的影響,多多少少還是有不必要的復(fù)雜性,而實(shí)際上,將C++11的Lambda Function和tr1::function結(jié)合起來(lái),這個(gè)設(shè)施可以簡(jiǎn)化到腦殘的地步:
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: // noncopyableScopeGuard(ScopeGuard const&);ScopeGuard& operator=(ScopeGuard const&); };這個(gè)類的使用很簡(jiǎn)單,你交給它一個(gè)std::function,它負(fù)責(zé)在析構(gòu)的時(shí)候執(zhí)行,絕大多數(shù)時(shí)候這個(gè)function就是lambda,例如:
HANDLE h = CreateFile(...); ScopeGuard onExit([&] { CloseHandle(h); });onExit在析構(gòu)的時(shí)候會(huì)忠實(shí)地執(zhí)行CloseHandle。為了避免給這個(gè)對(duì)象起名的麻煩(如果有多個(gè)變量,起名就麻煩大了),可以定義一個(gè)宏,把行號(hào)混入變量名當(dāng)中,這樣每次定義的ScopeGuard對(duì)象都是唯一命名的。
#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()函數(shù)也是Andrei的原始設(shè)計(jì)的一部分,其作用是為了支持rollback模式,例如:
ScopeGuard onFailureRollback([&] { /* rollback */ }); ... // do something that could fail onFailureRollback.Dismiss();在上面的代碼中,“do something”的過程中只要任何地方拋出了異常,rollback邏輯都會(huì)被執(zhí)行。如果“do something”成功了,onFailureRollback.Dismiss()會(huì)被調(diào)用,設(shè)置dismissed_為true,阻止rollback邏輯的執(zhí)行。
ScopeGuard是資源自動(dòng)釋放,以及在代碼出錯(cuò)的情況下rollback的不可或缺的設(shè)施,C++98由于沒有l(wèi)ambda和tr1::function的支持,ScopeGuard不但實(shí)現(xiàn)復(fù)雜,而且用起來(lái)非常麻煩,陷阱也很多,而C++11之后立即變得極其簡(jiǎn)單,從而真正變成了每天要用到的設(shè)施了。C++的RAII范式被認(rèn)為是資源確定性釋放的最佳范式(C#的using關(guān)鍵字在嵌套資源申請(qǐng)釋放的情況下會(huì)層層縮進(jìn),相當(dāng)?shù)牟荒躶cale),而有了ON_SCOPE_EXIT之后,在C++里面申請(qǐng)釋放資源就變得非常方便
Acquire Resource1 ON_SCOPE_EXIT( [&] { /* Release Resource1 */ })Acquire Resource2 ON_SCOPE_EXIT( [&] { /* Release Resource2 */ }) …這樣做的好處不僅是代碼不會(huì)出現(xiàn)無(wú)謂的縮進(jìn),而且資源申請(qǐng)和釋放的代碼在視覺上緊鄰彼此,永遠(yuǎn)不會(huì)忘記。更不用說只需要在一個(gè)地方寫釋放的代碼,下文無(wú)論發(fā)生什么錯(cuò)誤,導(dǎo)致該作用域退出我們都不用擔(dān)心資源不會(huì)被釋放掉了。我相信這一范式很快就會(huì)成為所有C++代碼分配和釋放資源的標(biāo)準(zhǔn)方式,因?yàn)檫@是C++十年來(lái)的演化所積淀下來(lái)的真正好的部分之一。
錯(cuò)誤處理
前面提到,輸入法是一個(gè)特殊的東西,某種程度上他就跟用戶態(tài)的driver一樣,對(duì)錯(cuò)誤的寬容度極低,出了錯(cuò)誤之后可能造成很嚴(yán)重的后果:用戶數(shù)據(jù)丟失。不像其他獨(dú)立跑的程序可以隨便崩潰大不了重啟(或者程序自動(dòng)重啟),所以從一開始,錯(cuò)誤處理就被非常嚴(yán)肅地對(duì)待。
這里就出現(xiàn)了一個(gè)兩難問題:嚴(yán)謹(jǐn)?shù)腻e(cuò)誤處理要求不要忽視和放過任何一個(gè)錯(cuò)誤,要么當(dāng)即處理,要么轉(zhuǎn)發(fā)給調(diào)用者,層層往上傳播。任何被忽視的錯(cuò)誤,都遲早會(huì)在代碼接下去的執(zhí)行流當(dāng)中引發(fā)其他錯(cuò)誤,這種被原始錯(cuò)誤引發(fā)的二階三階錯(cuò)誤可能看上去跟root cause一點(diǎn)關(guān)系都沒有,造成bugfix的成本劇增,這是我們項(xiàng)目快速的開發(fā)步調(diào)下所承受不起的成本。
然而另一方面,要想不忽視錯(cuò)誤,就意味著我們需要勤勤懇懇地檢查并轉(zhuǎn)發(fā)錯(cuò)誤,一個(gè)大規(guī)模的程序中隨處都可能有錯(cuò)誤發(fā)生,如果這種檢查和轉(zhuǎn)發(fā)的成本太高,例如錯(cuò)誤處理的代碼會(huì)導(dǎo)致代碼增加,結(jié)構(gòu)臃腫,那么程序員就會(huì)偷懶不檢查。而一時(shí)的偷懶以后總是要還的。
所以細(xì)心檢查是短期不斷付出成本,疏忽檢查則是長(zhǎng)期付出成本,看上去怎么都是個(gè)成本。有沒有既不需要短期付出成本,又不會(huì)導(dǎo)致長(zhǎng)期付出成本的辦法呢?答案是有的。我們的項(xiàng)目全面使用異常來(lái)作為錯(cuò)誤處理的機(jī)制。異常相對(duì)于錯(cuò)誤代碼來(lái)說有很多優(yōu)勢(shì),我曾經(jīng)在2007年寫過一篇博客《錯(cuò)誤處理:為何、何時(shí)、如何》進(jìn)行了詳細(xì)的比較,但是異常對(duì)于C++而言也屬于不容易用好的特性:
首先,為了保證當(dāng)異常拋出的時(shí)候不會(huì)產(chǎn)生資源泄露,你必須用RAII范式封裝所有資源。這在C++98中可以做到,但代價(jià)較大,一方面智能指針還沒有進(jìn)入標(biāo)準(zhǔn)庫(kù),另一方面智能指針也只能管內(nèi)存,其他資源莫非還都得費(fèi)勁去寫一堆wrapper類,這個(gè)不便很大程度上也限制了異常在C++98下的被廣泛使用。不過幸運(yùn)的是,我們這個(gè)項(xiàng)目開始的時(shí)候VS2010 SP1已經(jīng)具備了tr1和lambda function,所以寫完上文那個(gè)簡(jiǎn)單的ScopeGuard之后,資源的自動(dòng)釋放問題就非常簡(jiǎn)便了。
其次,C++的異常不像C#的異常那樣附帶Callstack。例如你在某個(gè)地方通過.at(i)來(lái)取一個(gè)vector的某個(gè)元素,然后i越界了,你會(huì)收到vector內(nèi)部拋出來(lái)的一個(gè)異常,這個(gè)異常只是說下標(biāo)越界了,然后什么其他信息都木有,連個(gè)行號(hào)都沒有。要是不拋異常直接讓程序崩潰掉好歹還可以抓到一個(gè)minidump呢,這個(gè)因素一定程度上也限制了C++異常的被廣泛使用。Callstack顯然對(duì)于我們迅速診斷程序的bug有至關(guān)重要的作用,由于我們是一個(gè)不大的團(tuán)隊(duì),所以我們對(duì)質(zhì)量的測(cè)試很依賴于微軟內(nèi)部的dogfood用戶,我們r(jià)elease給dogfood用戶的是release版,倘若我們不用異常,用assert的話,固然是可以在release版也打開assert,但assert同樣也只能提供很有限的信息(文件和行號(hào),以及assert的表達(dá)式),很多時(shí)候這些信息是不足夠理解一個(gè)bug的(更不用說還得手動(dòng)截屏拷貝黏貼發(fā)送郵件才能匯報(bào)一個(gè)bug了),所以往往接下來(lái)還需要在開發(fā)人員自己的環(huán)境下試圖重現(xiàn)bug。這就不夠理想了。理想情況下,一個(gè)bug發(fā)生的時(shí)刻,程序應(yīng)該自己具備收集一切必要的信息的能力。那么對(duì)于一個(gè)bug來(lái)說,有哪些信息是至關(guān)重要的呢?
如果程序能夠自動(dòng)把這些信息收集并打包起來(lái),發(fā)送給開發(fā)人員,那么就能夠?yàn)樵\斷提供極大的幫助(當(dāng)然,既便如此仍然還是會(huì)有難以診斷的bug)。而且這一切都要以不增加寫代碼過程中的開銷的方式來(lái)進(jìn)行,如果每次都要在代碼里面做一堆事情來(lái)收集這些信息,那煩都得煩死人了,沒有人會(huì)愿意用的。
那么到底如何才能無(wú)代價(jià)地盡量收集充足的信息為診斷bug提供幫助呢?
首先是callstack,有很多種方法可以給C++異常加上callstack,不過很多方法會(huì)帶來(lái)性能損失,而且用起來(lái)也不方便,例如在每個(gè)函數(shù)的入口處加上一小段代碼把函數(shù)名/文件/行號(hào)打印到某個(gè)地方,或者還有一些利用dbghelp.dll里面的StackWalk功能。我們使用的是沒有性能損失的簡(jiǎn)單方案:在拋C++異常之前先手動(dòng)MiniDumpWriteDump,在異常捕獲端把minidump發(fā)回來(lái),在開發(fā)人員收到minidump之后可以使用VS或windbg進(jìn)行調(diào)試(但前提是相應(yīng)的release版本必須開啟pdb)。可能這里你會(huì)擔(dān)心,minidump難道不是很耗時(shí)間的嘛?沒錯(cuò),但是既然程序已經(jīng)發(fā)生了異常,稍微多花一點(diǎn)時(shí)間也就無(wú)所謂了。我們對(duì)于“附帶minidump的異常”的使用原則是,只在那些真正“異常”的情況下拋出,換句話說,只在你認(rèn)為應(yīng)該使用的assert的地方用,這類錯(cuò)誤屬于critical error。另外我們還有不帶minidump的異常,例如網(wǎng)絡(luò)失敗,xml解析失敗等等“可以預(yù)見”的錯(cuò)誤,這類錯(cuò)誤發(fā)生的頻率較高,所以如果每次都minidump會(huì)拖慢程序,所以這種情況下我們只拋異常不做minidump。
然后是Error Message,如何才能像assert那樣,在Error Message里面包含表達(dá)式和文件行號(hào)?
最后,也是最重要的,如何能夠把上下文相關(guān)變量的值capture下來(lái),因?yàn)橐环矫鎟elease版本的minidump在調(diào)試的時(shí)候所看到的變量值未必正確,另一方面如果這個(gè)值在堆上(例如std::string的內(nèi)部buffer就在堆上),那就更看不著了。
所有上面這些需求我們通過一個(gè)ENSURE宏來(lái)實(shí)現(xiàn),它的使用很簡(jiǎn)單:
ENSURE(0 <= index && index < v.size())(index)(v.size());ENSURE宏在release版本中同樣生效,如果發(fā)現(xiàn)表達(dá)式求值失敗,就會(huì)拋出一個(gè)C++異常,并會(huì)在異常的.what()里面記錄類似如下的錯(cuò)誤信息:
Failed: 0 <= index && index < v.size() File: xxx.cpp Line: 123 Context Variables:index = 12345v.size() = 100(如果你為stream重載了接收vector的operator <<,你甚至可以把vector的元素也打印到error message里頭)
由于ENSURE拋出的是一個(gè)自定義異常類型ExceptionWithMinidump,這個(gè)異常有一個(gè)GetMinidumpPath()可以獲得拋出異常的時(shí)候記錄下來(lái)的minidump文件。
ENSURE宏還有一個(gè)很方便的feature:在debug版本下,拋異常之前它會(huì)先assert,而assert的錯(cuò)誤消息正是上面這樣。Debug版本assert的好處是可以讓你有時(shí)間attach debugger,保證有完整的上下文。
利用ENSURE,所有對(duì)Win32 API的調(diào)用所發(fā)生的錯(cuò)誤返回值就可以很方便地被轉(zhuǎn)化為異常拋出來(lái),例如:
ENSURE_WIN32(SHGetKnownFolderPath(rfid, 0, NULL, &p) == S_OK);為了將LastError附在Error Message里面,我們額外定義了一個(gè)ENSURE_WIN32:
#define ENSURE_WIN32(exp) ENSURE(exp)(GetLastErrorStr())其中GetLastErrorStr()會(huì)返回Win32 Last Error的錯(cuò)誤消息文本。
而對(duì)于通過返回HRESULT來(lái)報(bào)錯(cuò)的一些Win32函數(shù),我們又定義了ENSURE_SUCCEEDED(hr):
#define ENSURE_SUCCEEDED(hr) \if(SUCCEEDED(hr)) \ else ENSURE(SUCCEEDED(hr))(Win32ErrorMessage(hr))其中Win32ErrorMessage(hr)負(fù)責(zé)根據(jù)hr查到其錯(cuò)誤消息文本。
ENSURE宏使得我們開發(fā)過程中對(duì)錯(cuò)誤的處理變得極其簡(jiǎn)單,任何地方你認(rèn)為需要assert的,用ENSURE就行了,一行簡(jiǎn)單的ENSURE,把bug相關(guān)的三大重要信息全部記錄在案,而且由于ENSURE是基于異常的,所以沒有辦法被程序忽略,也就不會(huì)導(dǎo)致難以調(diào)試的二階三階bug,此外異常不像錯(cuò)誤代碼需要手動(dòng)去傳遞,也就不會(huì)帶來(lái)為了錯(cuò)誤處理而造成的額外的開發(fā)成本(用錯(cuò)誤代碼來(lái)處理錯(cuò)誤的最大的開銷就是錯(cuò)誤代碼的手工檢查和層層傳遞)。
ENSURE宏的實(shí)現(xiàn)并不復(fù)雜,打印文件行號(hào)和表達(dá)式文本的辦法和assert一樣,創(chuàng)建minidump的辦法(這里只討論win32)是在__try中RaiseException(EXCEPTION_BREAKPOINT…),在__except中得到EXCEPTION_POINTERS之后調(diào)用MiniDumpWriteDump寫dump文件。最tricky的部分是如何支持在后面capture任意多個(gè)局部變量(ENSURE(expr)(var1)(var2)(var3)…),并且對(duì)每個(gè)被capture的局部變量同時(shí)還得capture變量名(不僅是變量值)。而這個(gè)宏無(wú)限展開的技術(shù)也在大概十年前就有了,還是Andrei Alexandrescu寫的一篇DDJ文章:Enhanced Assertions?。神奇的是,我的CSDN博客當(dāng)年第一篇文章就是翻譯的它,如今十年后又在自己的項(xiàng)目中用到,真是有穿越的感覺,而且穿越的還不止這一個(gè),我們項(xiàng)目不用任何第三方庫(kù),包括boost也不用,這其實(shí)也沒有帶來(lái)什么不便,因?yàn)閎oost的大量有用的子庫(kù)已經(jīng)進(jìn)入了TR1,唯一的不便就是C++被廣為詬病的:沒有一個(gè)好的event實(shí)現(xiàn),boost.signal這種非常強(qiáng)大的工業(yè)級(jí)實(shí)現(xiàn)當(dāng)然是可以的,不過對(duì)于我們的項(xiàng)目來(lái)說boost.signal的許多feature根本用不上,屬于殺雞用牛刀了,因此我就自己寫了一個(gè)剛剛滿足我們項(xiàng)目的特定需求的event實(shí)現(xiàn)(使用tr1::function和lambda,這個(gè)signal的實(shí)現(xiàn)和使用都很簡(jiǎn)潔,可惜variadic templates沒有,不然還會(huì)更簡(jiǎn)潔一些)。我在03年寫boost源碼剖析系列的時(shí)候曾經(jīng)詳細(xì)剖析了boost.signal的實(shí)現(xiàn)技術(shù),想不到十年前關(guān)注的技術(shù)十年后還會(huì)在項(xiàng)目中用到。
由于輸入法對(duì)錯(cuò)誤的容忍度較低,所以我們?cè)谒械某隹谔幎荚O(shè)置了兩重柵欄,第一重catch所有的C++異常,如果是ExceptionWithMinidump類型,則發(fā)送帶有dump的問題報(bào)告,如果是其他繼承自std::exception的異常類型,則僅發(fā)送包含.what()消息的問題報(bào)告,最后如果是catch(…)收到的那就沒辦法了,只能發(fā)送“unknown exception occurred”這種消息回來(lái)了。
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++異常外面還加了一層負(fù)責(zé)捕獲Win32異常的,捕獲到unhandled win32 exception也會(huì)寫minidump并發(fā)回。
考慮到輸入法應(yīng)該“能不崩潰就不崩潰”,所以對(duì)于C++異常而言,除了彈出問題報(bào)告程序之外,我們并不會(huì)阻止程序繼續(xù)執(zhí)行,這樣做有以下幾個(gè)原因:
另一方面,對(duì)于Native Language而言,除了語(yǔ)言級(jí)別的異常,總還會(huì)有Platform Specific的“硬”異常,例如最常見的Access Violation,當(dāng)然這種異常越少越好(我們的代碼基中鼓勵(lì)使用ENSURE來(lái)檢查各種pre-condition和post-condition,因?yàn)橐话銇?lái)說Access Violation不會(huì)是第一手錯(cuò)誤,它們幾乎總是由其他錯(cuò)誤導(dǎo)致的,而這個(gè)“其他錯(cuò)誤”往往可以用ENSURE來(lái)檢查,從而在它導(dǎo)致Access Violation之前就拋出語(yǔ)言級(jí)別的異常。舉一個(gè)簡(jiǎn)單的例子,還是vector的元素訪問,我們可以直接v[i],如果i越界,會(huì)Access Violation,那么這個(gè)Access Violation便是由之前的第一手錯(cuò)誤(i越界)所導(dǎo)致的二階異常了。而如果我們?cè)趘[i]之前先ENSURE(0 <= i && i < v.size())的話,就可以阻止“硬”異常的發(fā)生,轉(zhuǎn)而成為匯報(bào)一個(gè)語(yǔ)言級(jí)別的異常,語(yǔ)言級(jí)別的異常跟平臺(tái)相關(guān)的“硬”異常相比的好處在于:
理想情況下,我們應(yīng)該、并且能夠通過ENSURE來(lái)避免幾乎所有“硬”異常的發(fā)生。但程序員也是人,只要是代碼就會(huì)有疏忽,萬(wàn)一真的發(fā)生了“硬”異常怎么辦?對(duì)于輸入法而言,即便出現(xiàn)了這種很遺憾的情況我們?nèi)匀徊幌M愕乃拗鞒绦虮罎?#xff0c;但另一方面,由于“硬”異常使得程序已經(jīng)處于不可知的狀態(tài),我們無(wú)法對(duì)程序以后的執(zhí)行作出任何的保障,所以當(dāng)我們的錯(cuò)誤邊界處捕獲這類異常的時(shí)候,我們會(huì)設(shè)置一個(gè)全局的flag,disable整個(gè)的輸入法內(nèi)核,從用戶的角度來(lái)看就是輸入法不工作了,但一來(lái)宿主程序沒有崩潰,二來(lái)你的所有鍵敲擊都會(huì)被直接被宿主程序響應(yīng),就像沒有打開輸入法的時(shí)候一樣。這樣一來(lái)即便在最壞的情況之下,宿主程序仍然有機(jī)會(huì)去保存數(shù)據(jù)并體面退出。
所以,綜上所述,通過基于C++異常的ENSURE宏,我們實(shí)現(xiàn)了以下幾個(gè)目的:
另一方面,如果使用error code而不用異常來(lái)匯報(bào)和處理錯(cuò)誤,當(dāng)然也是可以達(dá)到上這些目的,但會(huì)給開發(fā)帶來(lái)高昂的代價(jià),設(shè)想你需要把每個(gè)函數(shù)的返回值騰出來(lái)用作HRESULT,然后在每個(gè)函數(shù)返回的時(shí)候必須check其返回錯(cuò)誤,并且如果自己不處理必須勤勤懇懇地轉(zhuǎn)發(fā)給上層。所以對(duì)于error code來(lái)說,要想快就必須犧牲周密的檢查,要想周密的檢查就必須犧牲編碼時(shí)間來(lái)做“不相干”的事情(對(duì)于需要周密檢查的錯(cuò)誤敏感的應(yīng)用來(lái)說,最后會(huì)搞到代碼里面一眼望過去盡是各種if-else的返回值錯(cuò)誤檢查,而真正干活的代碼卻縮在不起眼的角落,看過win32代碼的同學(xué)應(yīng)該都會(huì)有這個(gè)體會(huì))。而只有使用異常和ENSURE,才真正實(shí)現(xiàn)了既幾乎不花任何額外時(shí)間、又不至于漏過任何一個(gè)第一手錯(cuò)誤的目的。
最后簡(jiǎn)單提一下異常的性能問題,現(xiàn)代編譯器對(duì)于異常處理的實(shí)現(xiàn)已經(jīng)做到了在happy path上幾乎沒有開銷,對(duì)于絕大多數(shù)應(yīng)用層的程序來(lái)說,根本無(wú)需考慮異常所帶來(lái)的可忽視的開銷。在我們的對(duì)速度要求很敏感的輸入法程序中,做performance profiling的時(shí)候根本看不到異常帶來(lái)任何可見影響(除非你亂用異常,例如拿異常來(lái)取代正常的bool返回值,或者在loop里面拋接異常,等等)。具體的可以參考GoingNative2012@Channel9上的The Importance of Being Native的1小時(shí)06分處。
C++11的其他特性的運(yùn)用
資源管理和錯(cuò)誤處理是現(xiàn)代C++風(fēng)格最醒目的標(biāo)志,接下來(lái)再說一說C++11的其他特性在我們項(xiàng)目中的使用。
首先還是lambda,lambda除了配合ON_SCOPE_EXIT使用威力無(wú)窮之外,還有一個(gè)巨大的好處,就是創(chuàng)建on-the-fly的tasks,交給另一個(gè)線程去執(zhí)行,或者創(chuàng)建一個(gè)delegate交給另一個(gè)類去調(diào)用(像C#的event那樣)。(當(dāng)然,lambda使得STL變得比原來(lái)易用十倍這個(gè)事情就不說了,相信大家都知道了),例如我們有一個(gè)BackgroundWorker類,這個(gè)類的對(duì)象在內(nèi)部維護(hù)一個(gè)線程,這個(gè)線程在內(nèi)部有一個(gè)message loop,不斷以Thread Message的形式接收別人委托它執(zhí)行的一段代碼,如果是委托的同步執(zhí)行的任務(wù),那么委托(調(diào)用)方便等在那里,直到任務(wù)被執(zhí)行完,如果執(zhí)行過程中出現(xiàn)任何錯(cuò)誤,會(huì)首先被BackgroundWorker捕獲,然后在調(diào)用方線程上重新拋出(利用C++11的std::exception_ptr、std::current_exception()以及std::rethrow_exception())。BackgroundWorker的使用方式很簡(jiǎn)單:
bgWorker.Send([&] { .. /* do something */ });有了lambda,不僅Send的使用方式像上面這樣直觀,Send本身的實(shí)現(xiàn)也變得很優(yōu)雅:
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 threadif(!(pCxxException == nullptr)){std::rethrow_exception(pCxxException);}if(win32ExceptionPointers){RaiseException(win32ExceptionCode, ..);}}return r; }這里我們先把外面?zhèn)鬟M(jìn)來(lái)的function wrap成一個(gè)新的lambda function,后者除了負(fù)責(zé)調(diào)用前者之外,還負(fù)責(zé)在調(diào)用完了之后flag一個(gè)event從而實(shí)現(xiàn)同步等待的目的,另外它還負(fù)責(zé)捕獲任務(wù)執(zhí)行中可能發(fā)生的錯(cuò)誤并保存下來(lái),留待后面在調(diào)用方線程上重新raise這個(gè)錯(cuò)誤。
另外一個(gè)使用lambda的例子是:由于我們項(xiàng)目中需要解析XML的地方用的是MSXML,而MSXML很不幸是個(gè)COM組件,COM組件要求生存在特定的Apartment里面,而輸入法由于是被動(dòng)加載的dll,其主線程不是輸入法本身創(chuàng)建的,所以主線程到底屬于什么Apartment不由輸入法來(lái)控制,為了確保萬(wàn)無(wú)一失,我們便將MSXML host在上文提到的一個(gè)專屬的BackgroundWorker對(duì)象里面,由于BackgroundWorker內(nèi)部會(huì)維護(hù)一個(gè)線程,這個(gè)線程的apartment是由我們?nèi)珯?quán)控制的。為此我們給MSXML創(chuàng)建了一個(gè)wrapper類,這個(gè)類封裝了這些實(shí)現(xiàn)細(xì)節(jié),只提供一個(gè)簡(jiǎn)便的使用接口:
XMLDom dom; dom.LoadXMLFile(xmlFilePath);dom.Visit([&](std::wstring const& elemName, IXMLDOMNode* elem) {if(elemHandlers.find(elemName) != elemHandlers.end()){elemHandlers[elemName](elem);} });基于上文提到的BackgroundWorker的輔助,這個(gè)wrapper類的實(shí)現(xiàn)也變得非常簡(jiǎn)單:
void Visit(TNodeVisitor const& visitor) {bgWorker_.Send([&] {ENSURE(pXMLDom_ != NULL);IXMLDOMElement* root;ENSURE(pXMLDom_->get_documentElement(&root) == S_OK);InternalVisit(root, visitor);}); }所有對(duì)MSXML對(duì)象的操作都會(huì)被Send到host線程上去執(zhí)行。
另一個(gè)很有用的feature就是static_assert,例如我們?cè)贓NSURE宏的定義里面就有一行:
static_assert(std::is_same<decltype(expr), bool>::value, "ENSURE(expr) can only be used on bool expression");避免調(diào)ENSURE(expr)的時(shí)候expr不是bool類型,確給隱式轉(zhuǎn)換成了bool類型,從而出現(xiàn)很隱蔽的bug。
至于C++11的Move Semantics給代碼帶來(lái)的變化則是潤(rùn)物細(xì)無(wú)聲的:你可以不用擔(dān)心返回vector, string等STL容易的性能問題了,代碼的可讀性會(huì)得到提升。
最后,由于VS2010 SP1并沒有實(shí)現(xiàn)全部的C++11語(yǔ)言特性,所以我們也并沒有用上全部的特性,不過話說回來(lái),已經(jīng)被實(shí)現(xiàn)的特性已經(jīng)相當(dāng)有用了。
代碼質(zhì)量
在各種長(zhǎng)期和短期壓力之下寫代碼,當(dāng)然代碼質(zhì)量是重中之重,尤其是對(duì)于C++代碼,否則各種積累的技術(shù)債會(huì)越壓越重。對(duì)于創(chuàng)新項(xiàng)目而言,代碼基處于不停的演化當(dāng)中,一開始的時(shí)候什么都不是,就是一個(gè)最簡(jiǎn)單的骨架,然后逐漸出現(xiàn)一點(diǎn)prototype的樣子,隨著不斷的加進(jìn)新的feature,再不斷重構(gòu),抽取公共模塊,形成concept和abstraction,isolate接口,拆分模塊,最終prototype演變成product。關(guān)于代碼質(zhì)量的書很多,有一些寫得很好,例如《The Art of Readable Code》,《Clean Code》或者《Implementation Patterns》。這里沒有必要去重復(fù)這些書已經(jīng)講得非常好的技術(shù),只說說我認(rèn)為最重要的一些高層的指導(dǎo)性原則:
對(duì)了,這篇文章從頭到尾是用英庫(kù)拼音輸入法寫的。最后貼個(gè)圖:(http://pinyin.engkoo.com/)
[我們?cè)谡腥薦?由于我們之前的star intern祁航同學(xué)離職去國(guó)外讀書了,所以再次尋找實(shí)習(xí)生一枚,參與英庫(kù)拼音輸入法client端的開發(fā),要求如下:
有興趣的請(qǐng)發(fā)簡(jiǎn)歷至liuweipeng@outlook.com。此外,為了節(jié)省我們雙方的時(shí)間,我希望你在發(fā)簡(jiǎn)歷的同時(shí)回答以下兩個(gè)問題:
總結(jié)
以上是生活随笔為你收集整理的C++11(及现代C++风格)和快速迭代式开发的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 方法论、方法论——程序员的阿喀琉斯之踵
- 下一篇: 跟vczh看实例学编译原理——零:序言