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 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 Function和tr1::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: // noncopyableScopeGuard(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 = 12345v.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_ptr、std::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 threadif(!(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://www.pswp.cn/news/447379.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/447379.shtml
英文地址,請注明出處:http://en.pswp.cn/news/447379.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

軟件系統分析師與架構師技能大PK(您具備了哪些呢?)

軟件系統分析師與架構師在職責與技能方面沒有明顯的界線&#xff0c;你中有我我中有你&#xff0c;同時也有各自自己的世界&#xff0c;很值得拿來比比。 系統分析師職責:告訴我們系統應該做什么。 l 管理到技術的橋梁 各領域業務到信息化技術的通知翻譯者。 l 對軟件項目進行整…

桌面系統和程序員

本周我出席了OpenStack峰會。在峰會上絕大多數應用部署都是基于Linux的&#xff0c;然而&#xff0c;大家使用的筆記本電腦最多的還是蘋果產的。人們寫代碼&#xff0c;最終要把代碼部署到Linux上&#xff0c;但在編碼時卻使用另外一種不同的操作系統。 最有趣的還是他們使用的…

原型設計工具的對比評測記錄(一)

概述&#xff1a;本文是美國的專業設計公司cooper公司的交互設計師Emily Schwartzman所寫的一篇對眾多原型設計工具的對比評測。這里是前半部分&#xff0c;從總體角度介紹評測及其結果。原型設計工具的對比評測記錄&#xff08;一&#xff09;原型設計工具的對比評測記錄&…

軟件開發人員進修必備的20本書

初級軟件開發人員進修必備的20本書(上) #原創 程序開發 申請達人&#xff0c;去除贊助商鏈接開發和編程向來就不是一件輕松的事&#xff0c;有時候你可能需要別人稍微點化一下&#xff0c;或者從書中得到一些有用的幫助。總而言之&#xff0c;無論是工作流程還是進修你的專業&a…

為什么我喜歡單獨編程

大約是一年前&#xff0c;我寫了一篇關于我為什么不喜歡結對編程的帖子。此文也是對該帖子的補充。 至今為止我已經在加利福尼亞州科學院工作5個月了。我創建了一個由美國國家科學基金會資助的關于螞蟻分類學的網站&#xff1a;antcat.org。 這是我作為一個程序員25年以來最棒…

如何看待自己寫的爛代碼

如果你不是入行不久的新程序員&#xff0c;你很可能會遇到一些你曾經寫過的老代碼&#xff0c;看到它們&#xff0c;你可能會有這樣的反應&#xff1a; 哦&#xff0c;shit&#xff01;這是什么&#xff1f;當時我腦袋進水了&#xff1f; 我就這樣過。我的朋友和同事們都經歷過…

給創業者的一些建議

CEO要像個產品經理 就拿我的項目來說&#xff0c;網站的模板設計&#xff0c;視覺體驗&#xff0c;操作體驗&#xff0c;產品展示&#xff0c;網址等都是不合格的。創始人開始創業其實就是一個產品經理&#xff0c;好好雕琢你的產品。 不做機會最大的&#xff0c;只做自己擅長…

談操作系統的碎片化和融合

摘要&#xff1a;目前&#xff0c;Android和iOS統治著移動操作系統&#xff0c;桌面端被Windows所主宰&#xff0c;隨著智能家居、可穿戴設備等新興平臺的興起&#xff0c;造成了各家操作系統四分五裂的情況&#xff0c;但在不久的將來他們終究會走向融合。 目前&#xff0c;An…

盤點大數據的十大發展方向,Scale-out將成主流

摘要&#xff1a;隨著人們對大數據認知的加強&#xff0c;除去特點之外&#xff0c;人們開始更加關注于它的使用價值和未來的服務方向。本文列舉了大數據的十大發展方向&#xff0c;其中Scale-out將成為主流。 【編者按】如今&#xff0c;大數據儼然成為IT領域最受關注的熱詞之…

程序員的成功是否有規律可循?

摘要&#xff1a;自然界中存在許多規律&#xff0c;那么在程序人生上是否有規律可循呢&#xff1f;這種規律是如大多數人期望的那樣嗎&#xff1f;V眾投發起人李智勇對此進行了探討&#xff0c;他分析了必然與偶然、本質與細節&#xff0c;并就程序人生規律的三要素進行了解讀。…

S?D?I?與?A?S?I 接口詳解介紹

分量編碼 在對彩色電視信號進行數字化處理和傳輸是&#xff0c;一種常用的方式是分別對其3個分量&#xff08;Y&#xff0c;R-Y&#xff0c;B-Y&#xff09;進行數字化編碼。這就是分量分量編碼&#xff0c;另外還有全信號編碼&#xff0c;全信號編碼是對彩色全電視信號直接進行…

FFMPEG解碼多線程

FFMPEG多線程解碼 FFMPEG多線程編碼器一般以在Slice內分功能模塊進行多線程編碼&#xff0c;如h263&#xff0c;h263P&#xff0c;msmpeg(v1, v2, v3)&#xff0c;wmv1。包含以下幾個線程&#xff1a;(1)Pre_estimation_motion_thread運動估計前的準備;(2)Estimation_motion_t…

SpringNBoot日志配置

市面上的日志框架 日志門面 &#xff08;日志的抽象層&#xff09;日志門面 &#xff08;日志的抽象層&#xff09;JCL&#xff08;Jakarta Commons LoggingSLF4j&#xff08;Simple Logging Facade for Java&#xff09; jboss-loggiJUL&#xff08;java.util.logging&#x…

ffmpeg的內部Video Buffer管理和傳送機制

本文主要介紹ffmpeg解碼器內部管理Video Buffer的原理和過程&#xff0c;ffmpeg的Videobuffer為內部管理&#xff0c;其流程大致為&#xff1a;注冊處理函數->幀級釋放->幀級申請->清空。 1 注冊get_buffer()和release_buffer() FFAPI_InitCodec() avcodec_alloc_c…

springBoot的模版引擎

模版引擎 常見的模版引擎有JSP、Velocity、Freemarker和Thymeleaf Thymeleaf模版 使用時需要把html頁面放在classpath&#xff1a;/templates/文件夾下&#xff0c;thymeleaf就能自動渲染創建模版文件&#xff0c;并需要導入thymeleaf的名稱空間 <html lang"en"…

FFmepg 多線程解碼歷程

FFmepg 多線程解碼歷程 - 1:validate_thread_parameters /** * Set the threading algorithms used.//設置線程的使用算法 * Threading requires more than one thread.//需要一個以上的線程 * Frame threading requires entire frames to be passed to the codec,//幀線程…

SpringMVC自動配置

springboot為SpringMVC配置了自動配置&#xff0c;以下是SpringBoot對SpringMVC的默認配置 org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration 自動配置在Spring的默認配置之上添加了以下功能 包含ContentNegotiatingViewResolver和BeanNameView…

做一名合格的生意人

偶然的成功&#xff0c;包含著某種必然的因素&#xff0c;那就是一個生意人本身的素質。作為一名現代生意人&#xff0c;要想使自己所從事的事業取得新的成功&#xff0c;就要加強自身的修養&#xff0c;不斷掌握新知識&#xff0c;努力使自己成為“全才”。 □ 事業成功的十個…

restful風格的增刪改查

注意 如果靜態資源放到了靜態資源文件夾下卻無法訪問&#xff0c;請檢查一下是不是在自定義的配置類上加了EnableWebMvc注解templete文件夾不是靜態資源的文件夾&#xff0c;默認是無法訪問的&#xff0c;所以要添加視圖映射 package cn.xxxxxx.hellospringbootweb.config;imp…

歷史上最有影響力的10款開源項目

開源是大趨勢&#xff0c;開源軟件也在越來越多的出現在日常電腦桌面上&#xff0c;如Firefox瀏覽器、Ubuntu操作系統等。人們選擇開源軟件的原因&#xff0c;主要有低成本、安全無病毒侵害、更加透明和開放等。按照大多數的開源協議如GPL&#xff0c;開源軟件允許自由分發。在…