十載寒窗一歲終,
莘莘學子事匆匆。
千封簡歷千封信,
業界新來代碼翁。
從此心中惟代碼,
天天夜夜調試中。
幾番辛苦今朝至,
多少青春昨日風!
(我sb了,中間兩聯要對仗的,兩年沒寫搞忘了)
posted @
2010-08-18 17:25 溪流 閱讀(1726) |
評論 (13) |
編輯 收藏
昨天我叫幾個可以爭論爭論的同事去看vczh的文章《關于造車輪》,這兩天也跟他們爭論造輪子的問題,剛才吃飯的時候也說這個。。。
這個問題,說得上綱上線一點,可以到人生觀的層面。你的心有多大,你的目標就有多遠。如果你愿意讓你的人生充滿意義充滿挑戰,那么,造輪子吧!青史留名的,都是造輪子的;歷史不會記住只會用輪子的人。
回到小處,用輪子的代價不會比造輪子小多少。如果一個輪子夠好——這里的“好”是指面面俱到,完全不用我們去考慮細節——那么它必然龐雜,大到你想用一下某個功能都很難。如果一個輪子足夠輕量,很多時候,往往需要你去把我細節,而要把握細節,你就得了解這個輪子,這與造輪子其實已經無異了。只有你自己了解你自己的習慣,只有你自己,才能造出適合你自己用的輪子,也只有你自己,才能造出你認為完美的輪子。
最不喜歡的看到的就是這樣一個論調:“這個功能,XXX已經有了,為什么不用呢?”“我去寫一個肯定寫不過它的。””你寫的比它好在哪里呢?”為了完成任務,為了趕時間,怎么快怎么搞,這可以理解。但不要告訴我你從來就沒打算過用心去做一個東西。無論為了學習也好,想要超越也好,造輪子都是最佳途徑。第一次,你可能寫不過它,第二次,你可能還是寫不過它,但是你可以知道它好在哪里,第三次,也許你就寫過它了。可能你也不一定非寫過它不可,你的定位就是輕量、方便,那也未為不可。這都是有意義的。別人在茫然的學用一個又一個的輪子的時候,你造了一個輪子,你就比別人成長了一大截。在不經意的某一天,青史留名的可能就是你。
----------華麗的分割線----------
順便做個調查:有多少人會在公司里積累API上層庫?無論是公司要求的,還是自己默默做的。
posted @
2010-07-02 13:16 溪流 閱讀(5988) |
評論 (22) |
編輯 收藏
原文:http://blog.csdn.net/pongba/archive/2007/10/08/1815742.aspx
By 劉未鵬(pongba)
C++的羅浮宮(http://blog.csdn.net/pongba)
TopLanguage(http://groups.google.com/group/pongba)
引言
錯誤處理(Error-Handling)這個重要議題從1997年(也許更早)到2004年左右一直是一個被廣泛爭論的話題,曾在新聞組上、博客上、論壇上引發口水無數(不亞于語言之爭),Bjarne Stroustrup、James Gosling、Anders Hejlsberg、Bruce Eckel、Joel Spolsky、Herb Sutter、Andrei Alexandrescu、Brad Abrams、Raymond Chen、David Abrahams…,各路神仙紛紛出動,好不熱鬧:-)
如今爭論雖然已經基本結束并結果;只不過結論散落在大量文獻當中,且新舊文獻陳雜,如果隨便翻看其中的幾篇乃至幾十篇的話都難免管中窺豹。就連Gosling本人寫的《The Java Programming Language》中也語焉不詳。所以,寫這篇文章的目的便是要對這個問題提供一個整體視圖,相信我,這是個有趣的話題:-)
為什么要錯誤處理?
這是關于錯誤處理的問題里面最簡單的一個。答案也很簡單:現實世界是不完美的,意料之外的事情時有發生。
一個現實項目不像在學校里面完成大作業,只要考慮該完成的功能,走happy path(也叫One True Path)即可,忽略任何可能出錯的因素(呃.. 你會說,怎么會出錯呢?配置文件肯定在那,矩陣文件里面肯定含的是有效數字.. 因為所有的環境因素都在你的控制之下。就算出現什么不測,比如運行到一半被人拔了網線,那就讓程序崩潰好了,再雙擊一下不就行了嘛)。
然而現實世界的軟件就必須考慮錯誤處理了。如果一個錯誤是能夠恢復的,要盡量恢復。如果是不能恢復的,要妥善的退出模塊,保護用戶數據,清理資源。如果有必要的話應該記錄日志,或重啟模塊等等。
簡而言之,錯誤處理的最主要目的是為了構造健壯系統。
什么時候做錯誤處理?(或者:什么是“錯誤”?)
錯誤,很簡單,就是不正確的事情。也就是不該發生的事情。有一個很好的辦法可以找出哪些情況是錯誤。首先就當自己是在一個完美環境下編程的,一切precondition都滿足:文件存在那里,文件沒被破壞,網絡永遠完好,數據包永遠完整,程序員永遠不會拿腦袋去打蒼蠅,等等… 這種情況下編寫的程序也被稱為happy path(或One True Path)。
剩下的所有情況都可以看作是錯誤。即“不應該”發生的情況,不在算計之內的情況,或者預料之外的情況,whatever。
簡而言之,什么錯誤呢?調用方違反被調用函數的precondition、或一個函數無法維持其理應維持的invariants、或一個函數無法滿足它所調用的其它函數的precondition、或一個函數無法保證其退出時的postcondition;以上所有情況都屬于錯誤。(詳見《C++ Coding Standards: 101 Rules, Guidelines, and Best Practices》第70章,或《Object Oriented Software Construction, 2nd edition》第11、12章)
例如文件找不到(通常意味著一個錯誤)、配置文件語法錯誤、將一個值賦給一個總應該是正值的變量、文件存在但由于訪問限制而不能打開,或打開不能寫、網絡傳輸錯誤、網絡中斷、數據庫連接錯誤、參數無效等。
不過話說回來,現實世界中,錯誤與非錯誤之間的界定其實是很模糊的。例如文件缺失,可能大多數情況下都意味著一個錯誤(影響程序正常執行并得出正常結果),然而有的情況下也可能根本就不是錯誤(或者至少是可恢復的錯誤),比如你的街機模擬器的配置文件缺失了,一般程序只要創建一個缺省的即可。
因此,關于把哪些情況界定為錯誤,具體的答案幾乎總是視具體情況而定的。但話雖如此,仍然還是有一些一般性的原則,如下:
哪些情況不屬于錯誤?
1. 控制程序流的返回值不是錯誤:如果一個情況經常發生,往往意味著它是用來控制程序流程的,應該用status-code返回(注意,不同于error-code),比如經典的while(cin >> i)。讀入整型失敗是很常見的情況,而且,這里的“讀入整型失敗”其實真正的含義是“流的下一個字段不是整型”,后者很明確地不代表一個錯誤;再比如在一個字符串中尋找一個子串,如果找不到該子串,也不算錯誤。這類控制程序流的返回值都有一個共同的特點,即我們都一定會利用它們的返回值來編寫if-else,循環等控制結構,如:
if(foo(…)) { … }
else { … }
或
while(foo(…)) { … }
這里再摘兩個相應的具體例子,一個來自Gosling的《The Java Programming Language》,是關于stream的。
使用status-code:
while ((token = stream.next()) != Stream.END)
process(token);
stream.close();
使用exception:
try {
for(;;) {
process(stream.next());
}
} catch (StreamEndException e) {
stream.close();
}
高下立判。
另一個例子來自TC++PL(Well, not exactly):
size_t index;
try {
index = find(str, sub_str);
… // case 1
} catch (ElementNotFoundException& e) {
… // case 2
}
使用status-code:
int index = find(str, sub_str)
if(index != -1) {
… // case 1
} else {
… // case 2
}
以上這類情況的特點是,返回值本身也是程序主邏輯(happy path)的一部分,返回的兩種或幾種可能性,都是完全正常的、預料之中的。
1’. 另一方面,還有一種情況與此有微妙的區別,即“可恢復錯誤”。可恢復錯誤與上面的情況的區別在于它雖說也是預料之中的,但它一旦發生程序往往就會轉入一個錯誤恢復子過程,后者會盡可能恢復程序主干執行所需要的某些條件,恢復成功程序則再次轉入主干執行,而一旦恢復失敗的話就真的成了一個貨真價實的讓人只能干瞪眼的錯誤了。比如C++里面的operator new如果失敗的話會嘗試調用一個可自定義的錯誤恢復子過程,當然,后者并非總能成功將程序恢復過來。除了轉入一個錯誤恢復子過程之外,另一種可能性是程序會degenerate入一條second-class的支流,后者也許能完成某些預期的功能,但卻是“不完美”地完成的。
這類錯誤如何處理后面會討論。
2. 編程bug不是錯誤。屬于同一個人維護的代碼,或者同一個小組維護的代碼,如果里面出現bug,使得一個函數的precondition得不到滿足,那么不應該視為錯誤。而應該用assert來對付。因為編程bug發生時,你不會希望棧回滾,而是希望程序在assert失敗點上直接中斷,調用調試程序,查看中斷點的程序狀態,從而解決代碼中的bug。
關于這一點,需要尤其注意的是,它的前提是:必須要是同一個人或小組維護的代碼。同一個小組內可以保證查看到源代碼,進行debug。如果調用方和被調用方不屬于同一負責人,則不能滿足precondition的話就應該拋出異常。總之記住一個精神:assert是用來輔助debug的(assert的另一個作用是文檔,描述程序在特定點上的狀態,即便assert被關閉,這個描述功能也依然很重要)。
注意,有時候,為了效率原因,也會在第三方庫里面使用assert而不用異常來報告違反precondition。比如strcpy,std::vector的operator[]。
3. 頻繁出現的不是錯誤。頻繁出現的情況有兩種可能,一是你的程序問題大了(不然怎么總是出錯呢?)。二是出現的根本不是錯誤,而是屬于程序的正常流程。后者應該改用status-code。
插曲:異常(exception)vs錯誤代碼(error-code)
異常相較于錯誤代碼的優勢太多了,以下是一個(不完全)列表。
異常與錯誤代碼的本質區別之一——異常會自動往上層棧傳播:一旦異常被拋出,執行流就立即中止,取而代之的是自動的stack-unwinding操作,直到找到一個適當的catch子句。
相較之下,使用error-code的話,要將下層調用發生的錯誤傳播到上層去,就必須手動檢查每個調用邊界,任何錯誤,都必須手動轉發(返回)給上層,稍有遺漏,就會帶著錯誤的狀態繼續往下執行,從而在下游不知道離了多遠的地方最終引爆程序。這來了以下幾個問題:
1. 麻煩。每一個可能返回錯誤代碼的調用邊界都需要檢查,不管你實際上對不對返回的錯誤作響應,因為即便你自己不解決返回的錯誤,也要把它傳播到上層去好讓上層解決。
2. 不優雅且不可伸縮(scalability)的代碼(錯誤處理代碼跟One True Path(也叫happy path)攪和在一起)。關于這一條普遍的論述都不夠明確,比如有人可能會反駁說,那錯誤反正是要檢查的,用異常難道就不需要捕獲異常了嗎?當然是需要的,但關鍵是,有時候我們不一定會在異常發生的立即點上捕獲并處理異常。這時候,異常的優勢就顯現出來了,比如:
void foo()
{
try {
op1;
op2;
…
opN;
} catch (…) {
… // log
… // clean up
throw;
}
}
如果用error-code的話:
int foo()
{
if(!op1()) {
… // log? clean up?
return FAILED;
}
if(!op2()) {
… // log? clean up?
return FAILED;
}
…
return SUCCEEDED;
}
好一點的是這樣:
int foo()
{
if(!op1()) goto FAILED;
if(!op2()) goto FAILED;
…
if(!opN()) goto FAILED;
return SUCCEEDED;
FAILED:
… // log, clean up
return FAILED;
}
就算是最后一種做法(所謂的“On Error Goto”),One True Path中仍然夾雜著大量的噪音(如果返回的錯誤值不只是FAILED/SUCCEEDED兩種的話噪音會更大)。此外手動檢查返回值的成功失敗畢竟是很error-prone的做法。
值得注意的是,這里我并沒有用一個常被引用的例子,即:如果你是用C寫代碼(C不支持局部變量自動析構(RAII)),那么程序往往會被寫成這樣:
int f()
{
int returnCode = FAILED;
acquire resource1;
if(resource1 is acquired) {
acquire resource2;
if(resource2 is acquired) {
acquire resource3;
if(resource3 is acquired) {
if(doSomething1()) {
if(doSomething2()) {
if(doSomething3()) {
returnCode = SUCCEEDED;
}
}
}
release resource3;
}
release resource2;
}
release resource1;
}
return returnCode;
}
或者像這樣:
int f()
{
int returnCode = FAILED;
acquire resource1;
if(resources1 is not acquired)
return FAILED;
acquire resource2;
if(resource2 is not acquired) {
release resource1;
return FAILED;
}
acquire resource3;
if(resource3 is not acquired) {
release resource2;
release resource1;
return FAILED;
}
... // do something
release resource3;
release resource2;
release resource1;
return SUCCEEDED;
}
(一個更復雜的具體例子可以參考[16])
以上兩種方案在可伸縮性方面的問題是顯而易見的:一旦需要獲取的資源多了以后代碼也會隨著越來越難以卒讀,要么是if嵌套層次隨之線性增多,要么是重復代碼增多。所以即便項目中因為某些現實原因只能使用error-code,也最好采用前面提到的“On Error Goto”方案。
另一方面,當整個函數需要保持異常中立的時候,異常的優勢就更顯現出來了:使用error-code,你還是需要一次次的小心check每個返回的錯誤值,從而阻止執行流帶著錯誤往下繼續執行。用異常的話,可以直接書寫One True Path,連try-catch都不要。
當然,即便是使用異常作為錯誤匯報機制,錯誤安全(error-safety)還是需要保證的。值得注意的是,錯誤安全性屬于錯誤處理的本質困難,跟使用異常還是error-code來匯報錯誤沒有關系,一個常見的謬誤就是許多人把在異常使用過程中遇到的錯誤安全性方面的困難歸咎到異常身上。
3. 脆弱(易錯)。只要忘了檢查任意一個錯誤代碼,執行流就必然會帶著錯誤狀態往下繼續執行,后者幾乎肯定不是你想要的。帶著錯誤狀態往下執行好一點的會立即崩潰,差一點的則在相差十萬八千里的地方引發一個莫名其妙的錯誤。
4. 難以(編寫時)確保和(review時)檢查代碼的正確性。需要檢查所有可能的錯誤代碼有沒有都被妥善check了,其中也許大部分都是不能直接對付而需要傳播給上級的錯誤。
5. 耦合。即便你的函數是一個異常中立的函數,不管底層傳上來哪些錯誤一律拋給上層,你仍然需要在每個調用的邊界檢查,并妥善往上手動傳播每一個錯誤代碼。而一旦底層接口增加、減少或改動錯誤代碼的話,你的函數就需要立即作出相應改動,檢查并傳播底層接口改動后相應的錯誤代碼——這是很不幸的,因為你的函數只是想保持異常中立,不管底層出什么錯一律拋給上層調用方,這種情況下理想情況應該是不管底層的錯誤語意如何修改,當前層都應該不需要改動才對。
6. 沒有異常,根本無法編寫泛型組件。泛型組件根本不知道底層會出哪些錯,泛型組件的特點之一便是錯誤中立。但用error-code的話,怎么做到錯誤中立?泛型組件該如何檢查,檢查哪些底層錯誤?唯一的辦法就是讓所有的底層錯誤都用統一的SUCCEEDED和FAILED代碼來表達,并且將其它錯誤信息用GetLastError來獲取。姑且不說這個方案的丑陋,如何、由誰來統一制定SUCCEEDED和FAILED、GetLastError的標準?就算有這個統一標準,你也可以設想一下某個標準庫泛型算法(如for_each)編寫起來該是如何丑陋。
7. 錯誤代碼不可以被忘掉(忽視)。忘掉的后果見第3條。此外,有時候我們可能會故意不管某些錯誤,并用一個萬能catch來捕獲所有未被捕獲的錯誤,log,向支持網站發送錯誤報告,并重啟程序。用異常這就很容易做到——只要寫一個unhandled exception handler(不同語言對此的支持機制不一樣)即可。
異常與錯誤代碼的本質區別之二——異常的傳播使用的是一個單獨的信道,而錯誤代碼則占用了函數的返回值;函數的返回值本來的語意是用來返回“有用的”結果的,這個結果是屬于程序的One True Path的,而不是用來返回錯誤的。
利用返回值來傳播錯誤導致的問題如下:
8. 所有函數都必須將返回值預留給錯誤。如果你的函數最自然的語意是返回一個double,而每個double都是有效的。不行,你得把這個返回值通道預留著給錯誤處理用。你可能會說,我的函數很簡單,不會出錯。但如果以后你修改了之后,函數復雜了呢?到那個時候再把返回的double改為int并加上一個double&作為out參數的話,改動可就大了。
9. 返回值所能承載的錯誤信息是有限的。NULL?-1?什么意思?具體的錯誤信息只能用GetLastError來提供… 哦,對了,你看見有多少人用過GetLastError的?
10. 不優雅的代碼。呃…這個問題前面不是說過了么?不,這里說的是另一個不優雅之處——占用了用來返回結果的返回值通道。本來很自然的“計算——返回結果”,變成了“計算——修改out參數——返回錯誤”。當然,你可以說這個問題不是很嚴重。的確,將double res = readInput();改為double res; readInput(&res);也沒什么大不了的。如果是連調用呢?比如,process(readInput());呃… 或者readInput() + …?或者一般地,op1(op2(), op3(), …);?
11. 錯誤匯報方案不一致性。看看Win32下面的錯誤匯報機制吧:HRESULT、BOOL、GetLastError…本質上就是因為利用返回值通道是一個補丁方案,錯誤處理是程序的一個方面(aspect),理應有其單獨的匯報通道。利用異常的話,錯誤匯報方案就立即統一了,因為這是一個first-class的語言級支持機制。
12. 有些函數根本無法返回值,如構造函數。有些函數返回值是語言限制好了的,如重載的操作符和隱式轉換函數。
異常與錯誤代碼的本質區別之三——異常本身能夠攜帶任意豐富的信息。
13. 有什么錯誤報告機制能比錯誤報告本身就包含盡量豐富的信息更好的呢?使用異常的話,你可以往異常類里面添加數據成員,添加成員函數,攜帶任意的信息(比如Java的異常類就缺省攜帶了非常有用的調用棧信息)。而錯誤代碼就只有一個單薄的數字或字符串,要攜帶其它信息只能另外存在其它地方,并期望你能通過GetLastError去查看。
異常與錯誤代碼的本質區別之四——異常是OO的。
14. 你可以設計自己的異常繼承體系。呃…那這又有什么用呢?當然有用,一個最大的好處就是你可以在任意抽象層次上catch一組異常(exception grouping),比如你可以用catch(IOException)來捕獲所有的IO異常,用catch(SQLException)來捕獲所有的SQL異常。用catch(FileException)來catch所有的文件異常。你也可以catch更明確一點的異常,如StreamEndException。總之,catch的粒度是粗是細,根據需要,隨你調節。當然了,你可以設計自己的新異常。能夠catch一組相關異常的好處就是你可以很方便的對他們做統一的處理。
異常與錯誤代碼的本質區別之五——異常是強類型的。
15. 異常是強類型的。在catch異常的時候,一個特定類型的catch只能catch類型匹配的異常。而用error-code的話,就跟enum一樣,類型不安全。-1 == foo()?FAILED == foo()?MB_OK == foo()?大家反正都是整數。
異常與錯誤代碼的本質區別之六——異常是first-class的語言機制。
16. 代碼分析工具可以識別出異常并進行各種監測或分析。比如PerfMon就會對程序中的異常做統計。這個好處放在未來時態下或許怎么都不應該小覷。
選擇什么錯誤處理機制?
看完上面的比較,答案相信應該已經很明顯了:異常。
如果你仍然是error-code的思維習慣的話,可以假想將所有error-code的地方改為拋出exception。需要注意的是,error-code不是status-code。并非所有返回值都是用來報告真正的錯誤的,有些只不過是控制程序流的。就算返回的是bool值(比如查找子串,返回是否查找到),也并不代表false的情況就是一個錯誤。具體參加上一節:“哪些情況不屬于錯誤”。
一個最為廣泛的誤解就是:異常引入了不菲的開銷,而error-code沒有開銷,所以應該使用error-code。這個論點的漏洞在于,它認為只要是開銷就是有問題的,而不關心到底是在什么情況下的開銷。實際上,現代的編譯器早已能夠做到異常在happy path上的零開銷。當然,空間開銷還是有的,因為零開銷方案用的是地址表方案;但相較于時間開銷,這里的空間開銷幾乎從來都不是個問題。另一方面,一旦發生了異常,程序肯定就出了問題,這個時候的時間開銷往往就不那么重要了。此外有人會說,那如果頻繁拋出異常呢?如果頻繁拋出異常,往往就意味著那個異常對應的并非一個錯誤情況。
《C++ Coding Standards: 101 Rules, Guidelines, and Best Practices》里面一再強調:不要在項目里面關閉異常支持。因為就算你的項目里面不拋出異常,標準庫也依賴于異常。一旦關閉異常,不僅你的項目代碼都要依賴于error-code(error-code的缺點見下一節),整個標準庫便也都要依賴于非標準的途徑來匯報錯誤,或者干脆就不匯報錯誤。如果你的項目是如此的硬實時,乃至于你在非常小心且深入的分析之后發覺某些操作真的負擔不起異常些微的空間開銷和unhappy path上的時間開銷的話,也要盡量別在全局關閉異常支持,而是盡量將這些敏感的操作集中到一個模塊中,按模塊關閉異常。
插曲:異常的例外情況
凡事都有例外。《C++ Coding Standards: 101 Rules, Guidelines, and Best Practices》上面陳述了兩個例外情況:一,用異常沒有帶來明顯的好處的時候:比如所有的錯誤都會在立即調用端解決掉或者在非常接近立即調用端的地方解決掉。二,在實際作了測定之后發現異常的拋出和捕獲導致了顯著的時間開銷:這通常只有兩種情況,要么是在內層循環里面,要么是因為被拋出的異常根本不對應于一個錯誤。
如何進行錯誤處理?
這個問題同樣極其重要。它分為三個子問題:
1. 何時拋出異常。
2. 何時捕獲異常。
3. 如何避開異常,保持異常中立(或“異常透明”)。
其中最后一個問題最為重要,屬于錯誤處理的本質性困難之一。
先說前兩個問題。
從本質上,錯誤分為兩種,一種是可恢復的,另一種是不可恢復的。
對于可恢復的錯誤。有兩種方案:
1. 在錯誤的發生點上立即就予以恢復。比如配置文件不存在便創建一個缺省的,某個配置項缺失就使用缺省值等等。這一方案的好處是當前函數不返回任何錯誤,因為錯誤被當即搞定了,就像沒發生一樣。這種方案的前提是當前函數必須要有對付該錯誤的足夠上下文,如果一個底層的函數對全局語意沒有足夠的視圖,這時就可以拋出異常,由上層函數負責恢復。
2. 在某個上層棧上恢復。這種情況下,在負責恢復的那層棧以下的調用一般被看成一個整體事務,其中發生的任何錯誤都導致整個事務回滾,回滾到錯誤恢復棧層面時,由相應的catch子句進行恢復,并重新執行整個事務,或者將程序引向另一條備選路徑(alternative)。
對于不可恢復的錯誤。也有兩種方案:
1. Sudden Death。在錯誤的發生點上退出模塊(可能伴隨著重啟模塊)。退出模塊前往往需要先釋放資源、保存關鍵數據、記錄日志,等等。該方案的前提是在錯誤的發生點的上下文中必須要能夠釋放所有資源,要能夠保存關鍵數據。要滿足這個前提,可以用一個全局的沙盒來保存整個模塊到當前為止申請的所有資源,從而在任何出錯點上都可以將這個沙盒整個釋放掉。也可以用智能垃圾收集,這樣在出錯點上只要記錄日志和保存數據,把掃尾工作留給智能垃圾收集器完成。這個方案的弱點是如果釋放資源是要按某種次序的就比較麻煩。
2. 回滾。如果你并沒有用智能垃圾收集(要智能到能夠回收文件句柄,網絡端口等,不光是內存),或者你并沒有在某個全局可訪問的位置保存到當前為止模塊申請的所有資源,或者你的資源互相之間有依賴關系,必須按照分配的逆序釋放,等等,那么就必須按照調用棧的反方向回滾事務。回滾到一個所謂的Fault Barrier,用一個catch-all在那里等著,所謂Fault-Barrier的作用就是為了抓這些沒法妥善恢復的錯誤的,它做的事情通常就是logging、發送錯誤報告、可能也會重啟模塊。Fault Barrier一般在一個內聚的單一職責的功能模塊的邊界出現。
嚴格來說,其實還有第三種情況,即編寫當前代碼的時候并不確定某個錯誤是否能被恢復。這種時候拋出一個異常往往是最靈活的選擇,因為在沒有想好恢復方案的時候,上層調用對該異常都是中立的。一旦后面想好恢復方案了,不管是在某個上層調用內捕獲該異常,還是在最底層錯誤發生點上就立即解決錯誤從而根本取消掉該異常,都沒有問題。
異常轉換
在如何拋出和捕獲異常的問題上,還有一個子問題就是異常的轉換(translation)。以下情況下應該轉換一個由底層傳上來的異常:
1. 拋出一個對應于當前抽象層的異常。比如Document::open當接收到底層的文件異常(或數據庫異常,網絡異常,取決于這個Document來自何方)時,將其轉換為“文檔無效或被破壞”異常,增加高層語意,并避免暴露底層實現細節(對異常的一個批評就是會暴露內部實現,而實際上,通過適當轉換異常,可以使得異常總位于當前的抽象層次上,成為接口的一部分)。
2. 在模塊邊界上。如果一個模塊,在內部使用異常,但在邊界上必須提供C API的話,就必須在邊界上捕獲異常并將其轉換為error-code。
沒有時間機器——錯誤處理的本質困難
剛才提到“回滾”。那么,在異常發生的時候,如何回滾既然已經發生的操作?這就是要說的第三個問題:如何在異常從發生點一路傳播到捕獲點的路徑上保持異常中立,即回滾操作,釋放資源。簡而言之就是要做到錯誤安全(error-safe)。錯誤安全攸關強異常安全保證和事務語意。在異常編程里面,錯誤安全是最重要的一環。
理想情況下,我們要的是一個時間機器:打碎的杯子要能還原,釋放的內存要能重新得到,銷毀的對象就像沒銷毀前一模一樣,發射的導彈就像從來也沒有離開發射筒一樣,數據庫就像從來沒被寫入一樣…
沒有時間機器。
那么,如何回滾木已成舟的操作?
目前有兩個主要方案:
1. Discard(丟棄):一個例子就能夠說明這種做法,源自STL的“copy-swap手法”。比如一個vector,你要往里面插入一個元素,如果插入元素失敗的話你想要vector維持原狀,就好像從來沒有動過一樣。如何做到這一點呢?你可以先把這個vector拷貝一份,往拷貝里面插入元素,然后將兩個vector調換(swap)一下即可,swap是不會失敗的,因為它只是把兩個指針互換了一下。而如果往那個拷貝里面插入元素失敗的話,拷貝就會被Discard(丟棄掉),不會帶來任何實際的副作用。當然,這種做法是有代價的,誰叫你要強異常安全保證呢?再比如一個拷貝賦值操作符可以這樣寫:MyClass(other).swap(*this); 當然,前提還是swap()必須具有標準的no-throw語意。
這種做法一般化的描述就是:“在一個‘副本’里把所有的事情都做好了,然后用一個不會出錯的函數提交(commit)”。這樣一來,中途出了任何錯誤只要丟棄那個副本即可(往往只要任其析構)。要做到這一點,一個原則就是:“在破壞一份信息之前要確保其新的版本一定能夠無錯的替換掉原信息”,例如在拷貝構造函數中,不能先delete再new,因為new可能失敗,一旦new失敗了,delete掉的信息可就找不回來了。
2. Undo(撤銷):有時候,你一方面不想付出Discard方案的(通常不菲的)空間開銷,另一方面你又想擁有強異常安全保證。這也是有辦法的,比如:
void World::addPerson(Person const& person)
{
m_persons.push_back(person);
scope(failure) { m_persons.pop_back(); }
… // other operations
}
scope(failure)是D語言的特性,其語意顯而易見:如果當前的scope以失敗(異常)退出的話,{}內的語句就被執行。這么一來,在上面的例子中,如果后續的操作失敗,那么這個person就會被從m_persons中pop_back出來,一次事務撤銷,使得這個函數對m_persons的影響歸零。
該方案的前提有兩個:一,回滾操作(比如這里的m_persons.pop_back())必須存在且不會失敗(no-throw)。比如missile.lunch()就不能回滾,操作系統API一般也無法回滾;I/O操作也無法回滾;另一方面,內存操作一般而言都是可以回滾的,只要回復原來內存的值即可。二,被回滾的操作(比如這里的m_persons.push_back(person))也一定要是強異常保證的。比如在中間而不是尾部插入一個person(m_persons.insert(iter, person))就不是強保證的,這種時候就要訴諸前一個方案(Discard)。
D的scope(failure)(還有scope(exit)、scope(success))是非常強大的設施。利用它,一個具有事務語意的函數的一般模式如下:
void Transaction()
{
op1; // strong operation
scope(failure) { undo op1; }
op2; // strong operation
scope(failure) { undo op2; }
…
opN; // strong operation
scope(failure) { undo opN; }
}
在C++里面也可以模擬D的scope(failure),Andrei Alexandrescu曾實現了一個ScopeGuard類[11],而旨在完全模擬D的scope特性的boost.scope-exit也在review中。只不過C++03里面的模擬方案有一些學習曲線和使用注意點,C++09之后會有更方便的方案。在其它不支持scope(failure)的語言中,也可以模擬這種做法,不過做法很笨拙。
3. 呃… 哪來的第三個方案?前面不是說了只有兩個方案嗎?是的。因為這第三個方案是“理想”方案,目前還沒有進入主流語言,不過在haskell里面已經初見端倪了。強異常安全保證的核心思想其實就是事務語意,而事務語意的核心思想就是“不成功便成仁”(這個思想有許多有趣的說法,比如:“武士道原則”、“要么直著回來要么橫著回來”、“干不了就去死”),根據這個想法,其實最簡單的方案是把一組屬于同一事務的操作簡單地圈起來(標記出來),把回滾操作留給語言實現去完成:
stm {
op1;
op2;
…
opN;
}
只要op1至opN中任意一個失敗,整個stm塊對內存的寫操作就全被自動拋棄(這要求編譯器和運行時的支持,一般是用一個緩沖區或寫日志來實現),然后異常被自動拋出這個stm塊之外。這個方案的優點是它太優美了,我們幾乎只要關注One True Path即可,唯一要做的事情就是用stm{…}圈出代碼中需要強保證的代碼段。這個方案的缺點有兩個:一,它跟第一個方案一樣,有空間開銷,不過空間開銷通常要小一點,因為只要緩存特定的寫操作。二,當涉及到操作系統API,I/O等“外部”操作的時候,底層實現就未必能夠回滾這些操作了。另一個理論上的可能性是,當回滾操作和被回滾操作并非嚴格物理對應(所謂物理對應就是說,回滾操作將內存回滾到目標操作發生之前的狀態)的時候,底層實現也不知道如何回滾。
(STM(軟件事務內存)目前在haskell里面實現了,Intel也釋出了C/C++的STM預覽版編譯器。只不過STM原本的意圖是實現鎖無關算法的。后者就是另一個話題了。)
RAII
實際上剛才還有一個問題沒有說,那就是如何確保資源一定會被釋放(即便發生異常),這在D里面對應的是scope(exit),在Java里面對應的是finally,在C# 里面對應的是scoped using。
簡而言之就是,不管當前作用域以何種方式退出,某某操作(通常是資源釋放)都一定要被執行。
這個問題的答案其實C++程序員們應該耳熟能詳了:RAII。RAII是C++最為強大的特性之一。在C++里面,局部變量的析構函數剛好滿足這個語意:無論當前作用域以何種方式退出,所有局部變量的析構函數都必然會被倒著調用一遍。所以只要將有待釋放的資源包裝在析構函數里面,就能夠保證它們即便在異常發生的情況下也會被釋放掉了。為此C++提供了一系列的智能指針:auto_ptr、scoped_ptr、scoped_array… 此外所有的STL容器也都是RAII的。在C++里面模擬D的scope(exit)也是利用的RAII。
RAII相較于java的finally的好處和C#的scoped using的好處是非常明顯的。只要一段代碼就高下立判:
// in Java
String ReadFirstLineFromFile( String path )
{
StreamReader r = null;
String s = null;
try {
r = new StreamReader(path);
s = r.ReadLine();
} finally {
if ( r != null ) r.Dispose();
}
return s;
}
// in C#
String ReadFirstLineFromFile( String path )
{
using ( StreamReader r = new StreamReader(path) ) {
return r.ReadLine();
}
}
顯然,Java版本的(try-finally)最臃腫。C#版本(scoped using)稍微好一些,但using畢竟也不屬于程序員關心的代碼邏輯,仍然屬于代碼噪音;況且如果不連續地申請N個資源的話,使用using就會造成層層嵌套結構。
如果使用RAII手法來封裝StreamReader類的話(std::fstream就是RAII類的一個范例),代碼就簡化為:
// in C++, using RAII
String ReadFirstLineFromFile(String path)
{
StreamReader r(path);
return r.ReadLine();
}
好處是顯而易見的。完全不用擔心資源的釋放問題,代碼也變得“as simple as possible”。此外,值得注意的是,以上代碼只是演示了最簡單的情況,其中需要釋放的資源只有一個。其實這個例子并不能最明顯地展現出RAII強大的地方,當需要釋放的資源有多個的時候,RAII的真正強大之處才被展現出來,一般地說,如果一個函數依次申請N個資源:
void f()
{
acquire resource1;
…
acquire resource2;
…
acquire resourceN;
…
}
那么,使用RAII的話,代碼就像上面這樣簡單。無論何時退出當前作用域,所有已經構造初始化了的資源都會被析構函數自動釋放掉。然而如果使用try-finally的話,f()就變成了:
void f()
{
try {
acquire resource1;
… // #1
acquire resource2;
… // #2
acquire resourceN;
… // #N
} finally {
if(resource1 is acquired) release resource1;
if(resource2 is acquired) release resource2;
…
if(resourceN is acquired) release resourceN;
}
}
為什么會這么麻煩呢,本質上就是因為當執行流因異常跳到finally塊中時,你并不知道執行流是從#1處、#2處…還是#N處跳過來的,所以你不知道應該釋放哪些資源,只能挨個檢查各個資源是否已經被申請了,如果已申請了便將其釋放;要能夠檢查每個資源是否已經被申請了,往往就要求你要在函數一開始將各個資源的句柄全都初始化為null,這樣才可以通過if(hResN==null)來檢查第N個資源是否已經申請。
最后,RAII其實是scope(exit)的特殊形式。但在資源釋放方面,RAII有其特有的優勢:如果使用scope(exit)的話,每個資源分配之后都需要用一個scope(exit)跟在后面保護起來;而如果用RAII的話,一個資源申請就對應于一個RAII對象的構造,釋放工作則被隱藏在對象的析構函數中,從而使代碼主干保持了清爽。
總結
本文討論了錯誤處理的原因、時機和方法。錯誤處理是編程中極其重要的一環;也是最被忽視的一環,Gosling把這個歸因于大多數程序員在學校的時候都是做著大作業當編程練習的,而大作業鮮有老師要求要妥善對待錯誤的,大家都只要“work on the one true path”即可;然而現實世界的軟件可必須面對各種各樣的意外情況,往往一個程序的錯誤處理機制在第一次面對錯誤的時候便崩潰了… 另一方面,錯誤處理又是一個極其困難的問題,其本質困難來源于兩個方面:一,哪些情況算是錯誤。二,如何做到錯誤安全(error-safe)。相較之下在什么地點解決錯誤倒是容易一些了。本文對錯誤處理的問題作了詳細的分析,總結了多年來這個領域爭論的結果,提供了一個實踐導引。
下期預告
關于在C++里面為什么不應該使用異常規格聲明(exception specification),參考文章后面的延伸閱讀[5]。
關于如何設計異常繼承體系,checked exception/unchecked exception之爭,關于異常處理的迷思、謬論、誤解,等等同樣有意思的問題,請聽下回分解(如果有下回的話:-))。
延伸閱讀
書
[1] 《C++ Coding Standards: 101 Rules, Guidelines, and Best Practices》,“Error Handling and Exceptions”部分,第68至75章。
[2] 《The C++ Programming Language, 3rd special edition》,第14章,尤其是14.4.7、14.5、14.8。第21章的21.3.6。以及附錄E。
[3] 《The Design and Evolution of C++》,整個16章。
[4] 《Exceptional C++》,整本書。
[5] 《Exceptional C++ Style》,第11、12、13章。
[6] 《The Java Programming Language, 4th edition》,第12章。
[7] 《Effective Java》,Item39至Item47。
[8] 《Object Oriented Software Construction, 2nd edition》,第11、12章。
文章
[1] B. Stroustrup: Programming with Exceptions(pdf)
[2] B. Stroustrup: Exception Safety: Concepts and Techniques
[3] D. Abrahams: Error and Exception Handling
[4] D. Abrahams: Exception-Safety in Generic Components
[5] Tom Cargill: Exception Handling, A False Sense of Security
[6] Damien Katz: Error codes or Exceptions? Why is Reliable Software so Hard?
[7] Alan Griffiths: Here be Dragons
[8] Ned Batchelder: Exceptions vs. status returns
[9] Ned Batchelder: Exceptions in the rainforest
[10] Ned Batchelder: Fix error handling first
[11] A. Alexandrescu, P. Marginean: Change the Way You Write Exception-Safe Code — Forever
[12] Brad Abrams: Exceptions and Error Codes
[13] A. Alexandrescu: Exception Safety Analysis(pdf)
[14] Brian Goetz: Exceptional practices
[15] James Gosling: Failure and Exceptions
[16] Sun: Advantages of Exceptions
[17] Vishal Kochhar: How a C++ compiler implements exception handling
(注:以上皆是與本文相關的,與下一個議題相關的文章就暫不列出了)
本文來自CSDN博客,轉載請標明出處:http://blog.csdn.net/pongba/archive/2007/10/08/1815742.aspx
posted @
2010-06-12 11:01 溪流 閱讀(615) |
評論 (0) |
編輯 收藏
單一用戶休想改變全局狀態!
好討厭,ri啊ri。。。
各位有沒有突破方法:找到一個任何用戶都可以可靠讀寫的位置?
posted @
2010-06-01 21:24 溪流 閱讀(3330) |
評論 (10) |
編輯 收藏
昨晚搞到近 2 點,終于把相關部分搞完了,放出個預覽版玩玩。這一版本將支持組合鍵,比如,可以分別改Q、Ctrl+Q、Alt+Q 等,也將支持所有鼠標操作。此外,增加了一個腳本系統,允許用戶進行更大程度上的自定義。
猛擊這里下載
腳本使用 lua 語言,格式大致為(正式版出來之前可能還會有改動):
目前支持的 API 有:
void PressKey(number vkcode [, boolean ctrl [, boolean shift [, boolean alt [, boolean win]]]]) | 按下某鍵。 第一個參數為虛擬鍵碼,也就是 Windows 的 VK_*,腳本里給出了一個 Keys 變量,可以直接使用 Keys.VK_*。其中 0 - 9 用 Keys.VK_0、……、Keys.VK_9,字母鍵用 Keys.VK_A、……、Keys.VK_Z。 后面四個表示狀態鍵是否被按下。默認 false。 若要按下 Ctrl+1,可寫成 PressKey(Keys.VK_1, true, false, false, false); 也可簡寫為 PressKey(Keys.VK_1, true)。 |
void ReleaseKey(number vkcode [, boolean ctrl [, boolean shift [, boolean alt [, boolean win]]]]) | 同上,放開某鍵。
|
void MoveMouse(number x, number y) | 移動鼠標到 (x, y) 位置。此坐標是相對于屏幕上最前面的那個窗口的客戶區的左上角的(以后將相對于魔獸窗口)。 |
void PressMouseButton(number button) button: 1 - Left 2 - Right 3 - XButton1 4 - XButton2 | 按下鼠標按鈕 |
void ReleaseMouseButton(number button) button: 1 - Left 2 - Right 3 - XButton1 4 - XButton2
| 放開鼠標按鈕 |
void MouseWheel(number button, number delta) button: 1 - VWheel 2 - HWheel
| 滾動鼠標滾輪 |
void SetClipboard(string str)
| 將一個字符串放到剪貼板 |
string AnsiToUtf8(string ansi) | 將 Ansi 字符串轉換為 UTF-8 |
width, height GetClientSize()
| 取客戶區大小。目前是取屏幕上最前面的那個窗口的客戶區的大小(以后將會是魔獸窗口的大小)。
|
x, y GetCursorPosition() | 取鼠標光標當前位置。此坐標是相對于屏幕上最前面的那個窗口的客戶區的左上角的(以后將相對于魔獸窗口)。 |
void Delay(number milliseconds)
| 等待一段時間,參數的單位是毫秒。 |
上面附件中的示例腳本實現了一次性扔掉物品欄所有物品的功能。請打開魔獸,然后按 Alt+1 來看效果。
論壇相應主題:http://forum.streamlet.org/thread-236-1-1.html
介紹到此結束。
最后,如果有興趣的,希望探討下,目前的腳本格式定義是否合理,API 設置是否合理,腳本安全性如何控制,等等。
歡迎在下面評論中跟帖。
posted @
2010-05-10 13:46 溪流 閱讀(2640) |
評論 (7) |
編輯 收藏