傳遞訛態的慣例:Exceptions vs Status Returns

作者:   發佈於:   #programming

所謂訛態,指的是程式執行期間所發生的「某種已知的錯誤」。例如:http client 函式在連接到指定網站時,發現網站的名稱無法解爲 IP 地址。名稱解析失敗是一種在程式設計期間就可以預想到會發生的錯誤,因此這種錯誤應該要被妥善處理。既然要被處理,那就表示與其相關的所有資訊都應當被暫時保存在記憶體檔中,才能被傳遞到其他地方去處理。

訛態處理手法不外乎:重試三遍、或顯示一些訊息在 UI 上、或直接無視。而如何將訛態從其發生地點傳遞到其處理地點去,基本上有兩派:一派是用程式語言提供的 Exception,另一派則是將訛態視爲函式回傳值的一部分,也就是所謂的 Status Return。

在 Exception 與 status returns 的這個題目上有不少好文章可以讀。跟 Emacs vs vi 一樣是個長年爭議題。各有優劣,但沒什麼固定答案。

以下是幾則舊文的結論摘要。:

Joel on Software article 13.

=> https://www.joelonsoftware.com/2003/10/13/13/

由y Joel Spolsky 提出:

Status return 較好,因爲:

Exception vs Status returns.

=> https://nedbatchelder.com/text/exceptions-vs-status.html

由 Ned Batchelder 提出:

Exception 較好,因爲:

# Perl, OO-based Exception

=> https://www.perl.com/pub/2002/11/14/exception.html

由 Arun Udaya Shankar 提出:

Exception 較好,因爲:

值得一提:Rust 與 go.

這兩個新世代語言內,都沒有 Exception,而是採用 status return。

只是,語言中內建的 panic 函式能帶著錯誤訊息立即跳離目前函式。這函式的用法就跟丟出 Exception 是一樣的:行到 panic 處,無條件離開。只是 caller 端的收到訛態後的處理方式與處理 Exception 不同。

上一層的 caller 必須要寫幾行算式去來應對,要不就是明確表示要無視,要不就是要明確地寫出處理辦法。也無法在某個更上層的函式內做一個「錯誤處理中心」自動處理目前 call stack 層層下好發生的錯誤。

或許由不少人認爲這套辦法也可以算是一種「輕量的」Exception,但就處理機制而言,更像是 status return。

一定意義上或許這表示 status return 較好。(或是:沒有 Exception 也沒差。)

Perl 語言中的訛態處理

Perl 語言內要丟出 Exception 是以 die 這個內建函式。這個函式本身不會有傳回值,而是在執行時會讓以下這幾件事情依序發生:

  1. 其參數會被存至 $@ 這個全域變數中。
  2. 如果 $SIG{__DIE__} 處理函式存在的話,使其執行。
  3. 立刻終止目前身處的 「eval 領域」(可能在 call stack 中好幾層上面),使其傳回 undef
  4. 若程式碼中沒有任何 eval 領域,perl 預設的處理方式是將 $@ 輸出至 STDERR

也就是說 Perl 語言內的 Exception 的處理,能由很上層的某個函式全數包辦。程式中也不必明確地將 Exception 重複再以 die 傳遞給上層,因爲這會自動發生。

一定意義上最最「省事」的做法,就是在進入點內寫個 eval { } 區塊,把所有算式都包入,並且,在所有其他函式內都不寫 eval。這麼一來,在各處發生的 Exception,都可被最上層的 eval 領域捕捉到,並且統一處理。雖然這招有很多缺點,不一定四處都通用。但如果程式碼任務範圍內不容許任何 Exception 被忽略,發生錯誤也不必重試,那這個做法確實可以讓任務本體程式變得十分簡潔。因爲所有處理訛態的程式碼都被寫在別的地方了。

Perl 語言:訛態傳遞界面的轉換

Perl 語言內要丟出 Exception 是以 die 這個內建函式。透過 Try::Tiny 提供的語法,可以簡單地做出一個轉換器:

use Try::Tiny;

sub error_or_result (&) {
    my ($f) = @_;
    try {
        undef, $f->()
    } catch {
        $_
    }
}

用法:

my ($err, @ret) = error_or_result { foo() };

如果 foo() 執行途中以 die 傳 Exception 出來,那麼 $err 內會裝有錯誤內容,而 @ret 則會是空的。如果沒有錯誤發生,那麼 $err 會是 undef,而 @ret 會裝有 foo() 的傳回值。

也就是它會把 Exception 轉換成 status returns。如果你用了一些函式庫是以 Exception 來將訛態丟回來,但慣例上專案程式碼內是使用 status return,那麼將訛態的使用轉換成一貫的慣例,或許有助於讓同組組員都能夠快速讀懂。

附帶一提:空值

尚有一派做法是把訛態對應到某種空值。最常見的使用時機是在呼叫外部 API 時。如果外部 API 所帶進來的資料不是必需的,是「有顯示可能有用,但不顯示出來也沒有關的」的這種狀況,那麼當這外部呼叫出錯時,與其將訛態顯示給使用者看,不如將其視爲「成功地取得了零筆資料回來」。也就是直接當成是跟「沒有資料」是一樣的狀態來處理。

當然,實務上我們會希望外部呼叫出錯時,會要在記錄檔裡面留下一些資訊,讓日後能夠分析。

經驗談

這個題目真的就是個優缺取捨題:若因 A 的優點而選了 A,同時也要將 A 的缺點全數概括承受下來。

如果把程式的任務範圍限制在 Web app 來討論的話,以個人觀察及參與所得到的經驗而言,選用 Exception 的好處前三名大致上爲:

  1. 讓「立刻顯示錯誤畫面』的這需求變得能夠簡單。無論是在 call stack 中多深的地方,都可以利用丟出特定 Exception 物件的方式來直接表示要顯示錯誤畫面。雖然這完全就是像在用 goto 一樣,但由於標的都只限制在 30x、40x、50x 這十幾種不同的狀態,其實反而讓語意更加清晰好懂。
  2. 能讓錯誤記錄中存有完整的資訊。多數 Exception 物件都帶有 stacktrace,因此在留記錄時可以一併留存下來,便於日後分析。
  3. 便於「不處理錯誤」。有不少時候,其實碰到錯誤後不處理,使其停機,也是一種正確的做法。

同時,缺點的前三名爲:

  1. 訛態的挽救多半是都是特例處理,很容易就會讓本來看來「簡潔無比」的程式碼變得複雜。
  2. 訛態的處理不見得夠通用,通常是 Web request cycle 情境之內有一套做法,之外則是另外一套做法。
  3. 在配合許多不同的框架下,Exception 物件的 stacktrace 未必精準,反而成爲記錄檔中妨礙閱讀的噪音。

只是我必須在此說明:所有我參與過的 Web app 專案都中都用上了 Exception,也就是說我沒有實際上只用 status returns 就完成一整套 Web app 的經驗。或許這幾點觀察都算是有點偏心了。但若真要問我偏向那一邊,我又不是很確定。

顯然,在語言有支援 Exception 機制的工作環境中工作久了,必然就會碰到需要將兩者「混搭」的狀況。或許,能夠讓程式設計師自由轉換的設計,纔是夠便利的。