當函式發生錯誤時,程式語言Go的作法是以值傳回錯誤,開發者必須檢查函式是否傳回錯誤,而不是以特定語法捕捉錯誤(像是try-catch)。表面上看來,這會造成程式碼中四處檢查錯誤的麻煩,實際上,是可以有更多的設計方式,以便優雅處理錯誤。

err是否nil?

當程式因故無法繼續執行時,Go有error與panic兩種呈現方式。在先前專欄〈Go的error與panic〉中談過,panic是用來表示開發者無力處理的程式中斷,最好的方式就是不要處理、讓程式崩潰,因為panic通常是個必須修正的Bug,而不是可以恢復的情況;而對於可以處理的程式中斷,Go是以傳回錯誤值的方式來展現,此時,開發者要檢查是否有錯誤,並加以處理。

處理錯誤最基本的方式,就是察看錯誤值是否為nil,最常見的就是if err != nil之類的程式碼,然而,接觸Go不久的開發者很快就會發現,單純地if err != nil檢查會充斥在程式碼之中,這類程式碼會重複到令開發者懷疑人生,想著「這麼寫真的是對的嗎?」的問題。

在其他具例外處理機制的語言中,例外發生時,程式流程就會中斷;然而,在Go中,若不在檢查出錯誤時,進行return、break之類的動作,程式碼就會繼續執行,因此,為了能在錯誤發生時中斷流程,似乎就必須不斷地if err != nil,看看要不要return或break?

在〈Errors are values〉(https://bit.ly/35XkkbI)中談到的,就是這類情況,正如標題提醒開發者的是,在Go中,錯誤就是個值罷了,開發者可以對數值、字串等值進行各種設計上的處理。錯誤處理也是如此,其中提及的設計方式之一,是採用結構來包裹原本操作的對象,結構中包含記錄錯誤的欄位,在實際操作結構的方法時,若err不為nil,就直接return:

func (ew *errWriter) write(buf []byte) {
if ew.err != nil {
return
}
_, ew.err = ew.w.Write(buf)<

}

透過這個方式,在錯誤發生後,就算有多次的write動作,也不會有實際的寫出,所以,這就實現例外發生時程式流程就會中斷的效果,而錯誤檢查的動作可以安排在最後。正如文件中談到的,這種設計方式,往往只要在最後執行全有或全無的檢查,就足夠了。

目前的Go的標準程式庫,其實有不少地方採用類似的設計,例如:bufio.Writer。而關於這類設計,我們可以進一步變化,例如:bufio.Scanner的Scan方法會傳回布林值,不論是沒有下一行或中途發生錯誤,都會傳回false,因此,在以行掃描讀取時,迴圈的處理就只需使用Scan傳回的布林值,判斷有無下一行,而離開迴圈後,我們可透過Err方法檢查錯誤,如此一來,迴圈與檢查錯誤的程式碼區塊就可以各司其職。

錯誤類型比對

一般而言,錯誤的檢查,不單只是判斷錯誤是否為nil,也常要判斷是哪種類型的錯誤。例如,讀取檔案時,我們往往需以==來判斷錯誤是否為io.EOF,但實際上,它就只是errors.New建立的一個值罷了:

var EOF = errors.New("EOF")

也就是說,對於應用程式中常用的錯誤值,我們可以使用errors.New來建立,這麼一來,就可以直接使用==來判斷錯誤值是否相同;我們可以在errors.go原始碼(https://bit.ly/35XmKXQ)看到,errors.New只是個含有指定字串的結構,並實現了error介面的Error行為。

這表示開發者可以自行定義結構,以包含更多錯誤發生時的上下文資訊,只要符合error介面的Error行為,都可以當成是錯誤值傳回;在需要比對錯誤類型的場合時,若結構本身是Comparable(https://bit.ly/35YJA17),就可以使用==來比對代表錯誤的結構;另一種方式是使用型態斷言(Type assertion)(https://bit.ly/2YebrHY),在需要比對多種型態的場合,則可以使用type switch(https://bit.ly/34NCVXE)。

若錯誤實際上是從另一個函式傳回,以結構自定錯誤時,我們除了加入上下文的資訊,也可以將原錯誤作為結構欄位之一,並且在實作Error方法時,能夠包含原錯誤值的Error呼叫結果,而這樣的做法,能方便後續呼叫者進一步檢視錯誤的根源。在error.go原始碼(https://bit.ly/364xdk7),我們可以看到PathError、SyscallError等作法,都是這類設計的實例。

Go1.13錯誤處理

Go在1.13版的errors套件中,為了檢查錯誤類型而新增了Is與As函式。其中的Is(err, target error)函式可用來比對錯誤是否相等,相等的判斷方式之一是錯誤為Comparable,這時,我們可以使用==來比較;另一個方式是錯誤實作了Is方法,我們可以運用err.Is(target)傳回布林值來判斷。因此,if err == ErrNotFound這類的判斷,在Go 1.13時,我們可以寫為if errors.Is(err, ErrNotFound),令程式碼意圖更為清楚。

As(err error, target interface{})函式則用來比對錯誤值是否為特定類型,例如,if e, ok := err.(*QueryError); ok這樣的判斷,我們可以使用if errors.As(err, &e)來更清楚地表達:err可以是個實作As方法的錯誤,而err.As(target)的結果則能夠決定錯誤類型是否相符。

在Go1.13中,error可以實作Unwrap方法,如果e1.Unwrap傳回e2,這表示e1包裹e2,而Unwrap令取得被包裹的錯誤時,能有統一的方法名稱;另外,errors套件也新增了Unwrap函式,若錯誤實作了Unwrap方法,Unwrap函式就會傳回被包裹的錯誤,否則將傳回nil。

同時,Go1.13中fmt.Errorf支援%w佔位字元,可對應至實現error介面的錯誤值,因此,如果錯誤值具有Unwrap方法,會自動取得包裹的錯誤,而所傳回的錯誤,除了格式化後的訊息欄位之外,也會實作Unwrap方法。

對於Go1.13新增的錯誤處理功能有興趣,你可以進一步察看〈Working with Errors in Go 1.13〉(https://bit.ly/360ZZlI),其中,也有是否包裹根源錯誤的討論。事實上,這沒有一定答案,而是取決於被包裹的錯誤是否成為公開API的部份等考量。

優雅地處理錯誤

Go以檢查傳回值的方式,代表是否有錯誤發生,乍看之下,令錯誤檢查的程式碼無所不在,但是,實際上,是給予開發者更多處理錯誤的可能性,我在這邊只是談到幾個可用的設計,重點在於觀察程式碼的需求,並適時地進行重構。

這類將錯誤作為值的設計,目的並不在於避免檢查錯誤,而是如何優雅地處理錯誤,對於來自有例外處理機制的語言開發者而言,這可能是陌生的領域,如果一開始沒什麼著手的方向,建議你可以多觀察Go標準程式庫的原始碼實作,從中學習檢查錯誤的設計方式。

專欄作者

熱門新聞

Advertisement