我一直以來對于exception的態度都是很明確的。首先exception是好的,否則就不會有絕大多數的語言都支持他了。其次,error code也沒什么問題,只是需要一個前提——你的語言得跟Haskell一樣有monad和comonad。你看Haskell就沒有exception,大家也寫的很開心。為什么呢?因為只要把返回帶error code結果的函數給做成一個monad/comonad,那么就可以用CPS變換把它變成exception了。所以說CPS作為跟goto同樣基本的控制流語句真是當之無愧呀,只是CPS是type rich的,goto是type poor的。
其實很多人對于exception的恐懼心理在于你不知道一個函數會拋什么exception出來,然后程序一crash你就傻逼了。對于server來講情況還好,出了問題只要殺掉快速重啟就行了,如今沒個replication和fault tolerance還有臉說你在寫后端(所以不知道那些做web的人究竟在反對什么)?這主要的問題還是在于client。只要client上面的東西還沒保存,那你一crash數據就完蛋了是不是——當然這只是你的想象啦,其實根本不是這樣子的。
我們的程序拋了一個access violation出來,和拋了其它exception出來,究竟有什么區別呢?access violation是一個很奇妙的東西,一旦拋了出來就告訴你你的程序沒救了,繼續執行下去說不定還會有破壞作用。特別是對于C/C++/Delphi這類語言來說,你不小心把錯誤的東西寫進了什么亂七八糟的指針里面去,那會兒什么事情都沒發生,結果程序跑著跑著就錯了。因為你那個算錯了得到的野指針,說不定是隔壁的不知道什么object的成員變量,說不定是heap里面的數據結構,或者說別的什么東西,就這么給你寫了。如果你寫了別的object的成員變量那封裝肯定就不管用了,這個類的不變量就給你破壞了。既然你的成員函數都是基于不變量來寫的,那這個時候出錯時必須的。如果你寫到了heap的數據結構那就更加呵呵呵了,說不定下次一new就崩了,而且你還不知道為什么。
出了access violation以外的exception基本是沒什么危害的,最嚴重的大概也就是網線被拔了,另一塊不是裝OS的硬盤突然壞了什么的這種反正你也沒辦法但是好歹還可以處理的事情。如果這些exception是你自己拋出來的那就更可靠了——那都是計劃內的。只要程序未來不會進入access violation的狀態,那證明你現在所能拿到的所有變量,還有指針指向的memory,基本上都還是靠譜的。出了你救不了的錯誤,至少你還可以吧數據安全的保存下來,然后讓自己重啟——就跟word一樣。但是你有可能會說,拿出了access violation怎么就不能保存數據了呢?因為這個時候內存都毀了,指不定你保存數據的代碼new點東西然后掛了,這基本上是沒準的。
所以無論你喜歡exception還是喜歡error code,你所希望達到的效果本質上就是避免程序未來會進入access violation的狀態。想做到這一點,方法也是很簡單粗暴的——只要你在函數里面把運行前該對函數做的檢查都查一遍就好了。這個無論你用exception還是用error code,寫起來都是一樣的。區別在于調用你的函數的那個人會怎么樣。那么我來舉個例子,譬如說你覺得STL的map實在是太傻比了,于是你自己寫了一個,然后有了一個這樣子的函數:
// exception版本 Symbol* SymbolMap::Lookup(const wstring& name); // error code版本 int SymbolMap::Lookup(const wstring& name, Symbol*& result); // 其實COM就是你們最喜歡的error code風格了,寫起來應該很開心才對呀,你們的雙重標準真嚴重 HRESULT ISymbolMap::Lookup(BSTR name, ISymbol** result);
于是拿到了Lookup函數之后,我們就要開始來完成一個任務了,譬如說拿兩個key得到兩個symbol然后組合出一個新的symbol。函數的錯誤處理邏輯是這樣的,如果key失敗了,因為業務的原因,我們要告訴函數外面說key不存在的。調用了一個ComposeSymbol的函數丟出什么IndexOutOfRangeException顯然是不合理的。但是合并的那一步,因為業務都在同一個領域內,所以suppose里面的異常外面是可以接受的。如果出現了計劃外的異常,那我們是處理不了的,只能丟給上面了,外面的代碼對于不認識的異常只需要報告任務失敗了就可以了。于是我們的函數就會這么寫:
Symbol* ComposeSymbol(const wstring& a, const wstring& b, SymbolMap* map) { Symbol* sa=0; Symbol* sb=0; try { sa=map->Lookup(a); sa=map->Lookup(b); } catch(const IndexOutOfRangeException& ex) { throw SymbolKeyException(ex.GetIndex()); } return CreatePairSymbol(sa, sb); }
看起來還挺不錯。現在我們可以開始考慮error code的版本了。于是我們需要思考幾個問題。首先第一個就是Lookup失敗的時候要怎么報告?直接報告key的內容是不可能的,因為error code是個int。
題外話,error code當然可以是別的什么東西,如果需要返回豐富內容的錯誤的話,那怎樣都得是一個指針了,這個時候你們就會面臨下面的問題——這已經他媽不滿足誰構造誰釋放的原則了呀,而且我這個指針究竟直接返回出去外面理不理呢,如果只要有一個環節不理了,那內存豈不是泄露了?如果我要求把錯誤返回在參數里面的話,我每次調用函數都要創建出那么個結構來保存異常,不僅有if的復雜度,還有創建空間的復雜度,整個代碼都變成了屎。所以還是老老實實用int吧……
那我們要如何把key的信息給編碼在一個int里面呢?因為key要么是來自于a,要么是來自于b,所以其實我們就需要兩個code了。那Lookup的其他錯誤怎么辦呢?CreatePairSymbol的錯誤怎么辦呢?萬一Lookup除了ERROR_KEY_NOT_FOUND以外,或者是CreatePairSymbol的錯誤剛好跟a或者b的code重合了怎么辦?對于這個問題,我只能說:
要不你們team的人先開會討論一下最后記錄在文檔里面備查以免后面的人看了傻眼了……
好了,現在假設說會議取得了圓滿成功,會議雙方加深了互相的理解,促進了溝通,最后還寫了一個白皮書出來,有效的落實了對a和b的code的指導,于是我們終于可以寫出下面的代碼了:
#define SUCCESS 0 // global error code for success #define ERROR_COMPOSE_SYMBOL_WRONG_A 1 #define ERROR_COMPOSE_SYMBOL_WRONG_B 2 int ComposeSymbol(const wstring& a, const wstring& b, SymbolMap* map, Symbol*& result) { int code=SUCCESS; Symbol* sa=0; Symbol* sb=0; switch(code=map->Lookup(a, sa)) { case SUCCESS: break; case ERROR_SYMBOL_MAP_KEY_NOT_FOUND: return ERROR_COMPOSE_SYMBOL_WRONG_A; default: return code; } switch(code=map->Lookup(b, sb)) { case SUCCESS: break; case ERROR_SYMBOL_MAP_KEY_NOT_FOUND: return ERROR_COMPOSE_SYMBOL_WRONG_B; default: return code; } return CreatePairSymbol(sa, sb, result); }
啊,好像太長,干脆我還是不負責任一點吧,反正代碼寫的好也漲不了工資,干脆不認識的錯誤都返回ERROR_COMPOSE_SYMBOL_UNKNOWN_ERROR好了,于是就可以把代碼變成下面這樣……都到這份上了不要叫自己程序員了,叫程序狗吧……
#define SUCCESS 0 // global error code for success #define ERROR_COMPOSE_SYMBOL_WRONG_A 1 #define ERROR_COMPOSE_SYMBOL_WRONG_B 2 #define ERROR_COMPOSE_SYMBOL_UNKNOWN_ERROR 3 int ComposeSymbol(const wstring& a, const wstring& b, SymbolMap* map, Symbol*& result) { Symbol* sa=0; Symbol* sb=0; if(map->Lookup(a, sa)!=SUCCESS) return ERROR_COMPOSE_SYMBOL_UNKNOWN_ERROR; if(map->Lookup(b, sb)!=SUCCESS) return ERROR_COMPOSE_SYMBOL_UNKNOWN_ERROR; if(CreatePairSymbol(sa, sb, result)!=SUCCESS) return ERROR_COMPOSE_SYMBOL_UNKNOWN_ERROR; return SUCCESS; }
當然,如果大家都一樣不負責任的話,還是exception完爆error code:
Symbol* ComposeSymbol(const wstring& a, const wstring& b, SymbolMap* map)
{
return CreatePairSymbol(map->Lookup(a), map->Lookup(b));
}
大部分人人只會用在當前條件下最容易寫的方法來設計軟件,而不是先設計出軟件然后再看看怎樣寫比較容易,這就是為什么我說,只要你一個月給程序員還給不到一狗半,還是老老實實在政策上落實exception吧。至少exception寫起來還不會讓人那么心煩,可以把程序寫得堅固一點。
好了,單線程下面至少你還可以爭吵說究竟exception好還是error code好,但是到了異步程序里面就完全不一樣了。現在的異步程序都很多,譬如說有良心的手機app啦,譬如說javascript啦,metro程序等等。一個try根本沒辦法跨線程使用所以一個這樣子的函數(下面開始用C#,C++11的future/promise我用的還不熟):
class Normal
{
public string Do(string args);
}
最后就會變成這樣:
class Async
{
// before .NET 4.0
IAsyncResult BeginDo(string args, Action<IAsyncResult> continuation);
string EndDo(IAsyncResult ar);
// after .NET 4.0
Task<string> DoAsync(string args);
}
當你使用BeginDo的時候,你可以在continuation里面調用EndDo,然后得到一個string,或者得到一個exception。但是因為EndDo的exception不是在BeginDo里面throw出來的,所以無論你EndDo返回string也好,返回Tuple<string, Exception>也好,對于BeginDo和EndDo的實現來說其實都一樣,沒有上文所說的exception和error code的區別。
不過.NET從BeginDo/EndDo到DoAsync經歷了一個巨大的進步。雖然形式上都一樣,但是由于C#并不像Haskell那樣可以完美的操作函數,C#還是面向對象做得更好,于是如果我們吧Task<T>看成下面的樣子,那其實兩種寫法是沒有區別的:
class Task<T>
{
public IAsyncResult BeginRun(Action<IAsyncResult> continuation);
public T EndRun(IAsyncResult ar);
}
不過如果還是用BeginRun/EndRun這種方法來調用的話,使用起來還是很不方便,而且也很難把更多的Task組合在一起。所以最后.NET給出的Task是下面這個樣子的(Comonad!):
class Task<T>
{
public Task<U> ContinueWith<U>(Func<Task<T>, U> continuation);
}
盡管真實的Task<T>要比上面那個復雜得多,但是總的來說其實就是圍繞著基本簡單的函數建立起來的一大堆helper function。到這里C#終于把CPS變換在異步處理上的應用的這一部分給抽象出來了。在看CPS的效果之前,我們先來看一個同步函數:
void button1_Clicked(object sender, EventArgs e) { // 假設我們有string Http.Download(string url); try { string a = Http.Download(url1); string b = Http.Download(url2); textBox1.Text=a+b; } catch(Exception ex) { textBox1.Text=ex.Message; } }
這段代碼顯然是一個GUI里面的代碼。我們如果在一個GUI程序里面這么寫,就會把程序寫得跟QQ一樣卡了。所以實際上這么做是不對的。不過為了表達程序需要做的所有事情,就有了這么一個同步的版本。那么我們嘗試吧這個東西修改成異步的把!
void button2_Clicked(object sender, EventArgs e)
{
// 假設我們有Task<string> Http.DownloadAsync(string url);
// 需要MethodInvoker是因為,對textBox1.Text的修改只能在GUI線程里面做
Http.DownloadAsync(url1).ContinueWith(ta=>new MethodInvoker(()=>
{
try
{
// 這個時候ta已經運行完了,所以對ta.Result的取值不會造成GUI線程等待IO。
// 而且如果DownloadAsync內部出了錯,異常會在這里拋出來。
string a=ta.Result;
Http.DownloadAsync(url2).ContinueWith(tb=>new MethodInvoker(()=>
{
try
{
string b=tb.Result;
textBox1.Text=a+b;
}
catch(Exception ex)
{
textBox1.Text=ex.Message;
}
})));
}
catch(Exception ex)
{
textBox1.Text=ex.Message;
}
})));
}
我們發現,異步操作發生的異常,把優越的exception拉低到了丑陋的error code的同一個情況上面——我們需要不斷地對每一個操作重復同樣的錯誤處理過程!而且在這種地方我們連“不負責任”的選項都沒有了,如果你不try-catch(或者不檢查error code),那到時候程序就會發生一些莫名其妙的問題,在GUI那一層你什么事情都不知道,整個程序就變成了傻逼。
現在可以開始解釋一下什么是CPS變換了。CPS變換就是把所有g(f(x))都給改寫成f(x, r=>g(r))的過程。通俗一點講,CPS變換就是幫你把那個同步的button1_Click給改寫成異步的button2_Click的這個過程。盡管這么說可能不太嚴謹,因為button1_Click跟button2_Click所做的事情是不一樣的,一個會讓GUI卡成qq,另一個不會。但是我們討論CPS變換的時候,我們討論的是對代碼結構的變換,而不是別的什么東西。
現在就是激動人心的一步了。既然CPS可以把返回值變換成lambda表達式,那反過來我們也可以把所有的以這種形式存在的lambda表達式都改寫成返回值嘛。現在我們滾回去看一看button2_Click,會發現這個程序其實充滿了下面的pattern:
// lambda的參數名字故意起了跟前面的變量一樣的名字(previousTask)因為其實他們就是同一個東西 previousTask.ContinueWith(previousTask=>new MethodInvoker(()=> { try { continuation(previousTask.Result); } catch(Exception ex) { textBox1.Text=ex.Message; } })));
我們可以“發明”一個語法來代表這個過程。C#用的是await關鍵字,那我們也來用await關鍵字。假設說上面的代碼永遠等價于下面的這個代碼:
try
{
var result=await previousTask;
continuation(result);
}
catch(Exception ex)
{
textBox1.Text=ex.Message;
}
兩段代碼的關系就跟i++;和i=i+1;一樣是可以互相替換的,只是不同的寫法而已。那我們就可以用相同的方法來把button2_Click給替換成下面的button3_Click了:
void button3_Click(object sender, EventArgs e)
{
try
{
var a=await Http.DownloadAsync(url1);
try
{
var b=await Http.DownloadAsync(url2);
textBox1.Text=a+b;
}
catch(Exception ex)
{
textBox1.Text=ex.Message;
}
}
catch(Exception ex)
{
textBox1.Text=ex.Message;
}
}
聰明的讀者立刻就想到了,兩個try其實是重復的,那為什么不把他們合并成一個呢!當然我想告訴大家的是,異常是在不同的線程里面拋出來的,只是我們用CPS變換把代碼“改寫”成這種形式而已。理論上兩個try是不能合并的。但是!我們的C#編譯器君是很聰明的。正所謂語言的抽象高級了一點,那么編譯器對你的代碼也就理解得更多了一點。如果編譯器發現你在try里面寫了兩個await,馬上就明白了過來他需要幫你復制catch的部分——或者說他可以幫你自動的復制catch的部分,那情況就完全不同了,最后就可以寫成:
// C#要求函數前面要加一個async來允許你在函數內使用await
// 當然同時你的函數也就返回Task而不是void了
// 不過沒關系,C#的event也可以接受一個標記了async的函數,盡管返回值不一樣
// 設計語言這種事情就是牽一發而動全身呀,加個await連event都要改
async void button4_Click(object sender, EventArgs e)
{
try
{
string a=await Http.DownloadAsync(url1);
string b=await Http.DownloadAsync(url2);
textBox1.Text=a+b;
}
catch(Exception ex)
{
textBox1.Text=ex.Message;
}
}
把兩個await換成回調已經讓我們寫的夠辛苦了,那么如果我們把await寫在了循環里面,事情就不那么簡單了。CPS需要把循環翻譯成遞歸,那你就得把lambda表達時拿出來寫成一個普通的函數——這樣他就可以有名字了——然后才能遞歸(寫出一個用于CPS的Y-combinator是一件很困難的事情,盡管并沒有比Y-combinator本身困難多少)。這個例子就復雜到爆炸了,我在這里就不演示了。
總而言之,C#因為有了CPS變換(await),就可以把button4_Click幫你寫成button3_Click然后再幫你寫成button2_Click,最后把整個函數變成異步和回調的形式(真正的做法要更聰明一點,大家可以反編譯去看)在異步回調的寫法里面,exception和error code其實是一樣的。但是CPS+exception和CPS+error code就跟單線程下面的exception和error code一樣,有著重大的區別。這就是為什么文章一開始會說,我只會在帶CPS變換的語言(Haskell/F#/etc)里面使用error code。
在這類語言里面利用相同的技巧,就可以不是異步的東西也用CPS包裝起來,譬如說monadic parser combinator。至于你要選擇monad還是comonad,基本上就是取決于你要自動提供錯誤處理還是要手動提供錯誤處理。像上面的Task.ContinueWith,是要求你手動提供錯誤處理的(因為你catch了之后可以干別的事情,Task無法自動替你選擇最好的措施),所以他就把Task.ContinueWith寫成了comonad的那個樣子。
寫到這里,不禁要同情寫前端的那幫javascript和自以為可以寫后端的node.js愛好者們,你們因為小小的eval的問題,不用老趙的windjs(windjs給javascript加上了await但是它不是一個altjs所以得顯式調用eval),是一個多大的損失……