1. 程式人生 > >轉載 -- 如何設計一門語言(六)——exception和error code c,c++ 還沒看

轉載 -- 如何設計一門語言(六)——exception和error code c,c++ 還沒看

如何設計一門語言(六)——exception和error code

http://www.cppblog.com/vczh/archive/2013/06/10/200920.html

 

如何設計一門語言(六)——exception和error code

我一直以來對於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),是一個多大的損失……