Go 部落格

錯誤是值

Rob Pike
2015年1月12日

Go 程式設計師,尤其是新入門的程式設計師,經常會討論如何處理錯誤。討論常常會變成對以下程式碼序列出現次數的抱怨:

if err != nil {
    return err
}

我們最近掃描了所有能找到的開源專案,發現這段程式碼平均每頁或兩頁只出現一次,比某些人認為的要少。儘管如此,如果人們仍然覺得必須一直輸入

if err != nil

,那麼一定有問題,而最明顯的矛頭就是 Go 本身。

這是不幸的、誤導性的,並且很容易糾正。也許發生的情況是,Go 新手會問:“如何處理錯誤?”,然後學到這種模式,就停止了。在其他語言中,人們可能會使用 try-catch 塊或其他類似的機制來處理錯誤。因此,程式設計師會想,在我以前的語言中會使用 try-catch 的地方,現在在 Go 中我就只輸入 if err != nil。隨著時間的推移,Go 程式碼會累積許多這樣的程式碼片段,感覺很笨拙。

無論這個解釋是否貼切,很明顯這些 Go 程式設計師忽略了一個關於錯誤的基本點:錯誤是值。

值是可以程式設計的,而由於錯誤是值,錯誤也可以被程式設計。

當然,一個常見的涉及錯誤值的操作是測試它是否為 nil,但你還可以對錯誤值做無數其他事情,並且應用其中一些其他操作可以使你的程式更好,消除如果每個錯誤都用死板的 if 語句來檢查所產生的許多樣板程式碼。

這裡有一個來自 bufio 包的 Scanner 型別的簡單示例。它的 Scan 方法執行底層的 I/O,當然這可能導致錯誤。然而,Scan 方法根本不暴露錯誤。相反,它返回一個布林值,並且一個獨立的方法在掃描結束時報告是否發生了錯誤。客戶端程式碼看起來是這樣的:

scanner := bufio.NewScanner(input)
for scanner.Scan() {
    token := scanner.Text()
    // process token
}
if err := scanner.Err(); err != nil {
    // process the error
}

當然,這裡有一個對錯誤的 nil 檢查,但它只出現並執行一次。Scan 方法本可以定義為:

func (s *Scanner) Scan() (token []byte, error)

然後示例使用者程式碼可能是(取決於如何檢索 token):

scanner := bufio.NewScanner(input)
for {
    token, err := scanner.Scan()
    if err != nil {
        return err // or maybe break
    }
    // process token
}

這差別不大,但有一個重要的區別。在這段程式碼中,客戶端必須在每次迭代時檢查錯誤,但在真實的 Scanner API 中,錯誤處理已經從關鍵的 API 元素(即迭代 token)中抽象出來了。使用真實的 API,客戶端的程式碼感覺更自然:迴圈直到完成,然後處理錯誤。錯誤處理不會干擾控制流。

在底層,當 Scan 遇到 I/O 錯誤時,它會記錄下來並返回 false。一個獨立的方法 Err 在客戶端請求時報告錯誤值。雖然這很小,但與在

if err != nil

處到處都是,或者要求客戶端在每個 token 後都檢查錯誤,是不同的。這是使用錯誤值進行程式設計。簡單的程式設計,是的,但畢竟是程式設計。

值得強調的是,無論設計如何,程式都必須檢查錯誤,無論它們如何被暴露。這裡的討論不是關於如何避免檢查錯誤,而是關於如何優雅地使用語言來處理錯誤。

關於重複性錯誤檢查程式碼的話題,在我參加 2014 年秋季東京 GoCon 會議時出現了。一位熱情的 gopher,在 Twitter 上叫做 @jxck_,表達了對錯誤檢查的熟悉抱怨。他的一些程式碼看起來是這樣的:

_, err = fd.Write(p0[a:b])
if err != nil {
    return err
}
_, err = fd.Write(p1[c:d])
if err != nil {
    return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
    return err
}
// and so on

這非常重複。在真實的程式碼中,會更復雜,所以不容易簡單地用一個輔助函式重構,但在這種理想化的形式下,一個閉合錯誤變數的函式字面量會有幫助:

var err error
write := func(buf []byte) {
    if err != nil {
        return
    }
    _, err = w.Write(buf)
}
write(p0[a:b])
write(p1[c:d])
write(p2[e:f])
// and so on
if err != nil {
    return err
}

這種模式效果很好,但需要在每個執行寫入的函式中使用閉包;單獨的輔助函式使用起來更麻煩,因為 err 變數需要在呼叫之間維護(可以試試)。

我們可以透過借鑑上面 Scan 方法的思路,使其更簡潔、更通用、更可重用。我在討論中提到了這個技巧,但 @jxck_ 沒有看到如何應用它。經過長時間的交流,由於語言障礙有所影響,我問他是否可以借用他的筆記型電腦,透過輸入一些程式碼來向他展示。

我定義了一個名為 errWriter 的物件,大致如下:

type errWriter struct {
    w   io.Writer
    err error
}

並給它一個方法 write。它不需要具有標準的 Write 簽名,並且小寫部分是為了突出區別。write 方法呼叫底層 WriterWrite 方法,並記錄第一個錯誤以供將來參考:

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

一旦發生錯誤,write 方法就變成了一個 no-op(空操作),但錯誤值被儲存下來。

有了 errWriter 型別及其 write 方法,上面的程式碼可以重構為:

ew := &errWriter{w: fd}
ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])
// and so on
if ew.err != nil {
    return ew.err
}

這比使用閉包更簡潔,並且也使頁面上要執行的實際寫入序列更容易看到。沒有了混亂。使用錯誤值(和介面)進行程式設計使程式碼更美觀。

很可能同一個包中的其他程式碼可以借鑑這個想法,甚至直接使用 errWriter

此外,一旦 errWriter 存在,它還可以做更多事情來提供幫助,尤其是在不太人為的例子中。它可以累積位元組計數。它可以將寫入合併到一個可以原子地傳輸的緩衝區中。等等。

事實上,這種模式在標準庫中經常出現。archive/zipnet/http 包都使用了它。更與本討論相關的是,bufio 包的 Writer 實際上是 errWriter 思想的一種實現。雖然 bufio.Writer.Write 返回一個錯誤,但這主要是為了遵循 io.Writer 介面。bufio.WriterWrite 方法的行為就像我們上面的 errWriter.write 方法一樣,Flush 會報告錯誤,所以我們的示例可以這樣寫:

b := bufio.NewWriter(fd)
b.Write(p0[a:b])
b.Write(p1[c:d])
b.Write(p2[e:f])
// and so on
if b.Flush() != nil {
    return b.Flush()
}

這種方法有一個重大的缺點,至少對某些應用程式來說是這樣:無法知道在發生錯誤之前完成了多少處理。如果這些資訊很重要,則需要更細粒度的方法。然而,通常情況下,在最後進行一次全有或全無的檢查就足夠了。

我們只看了一種避免重複錯誤處理程式碼的技術。請記住,使用 errWriterbufio.Writer 並不是簡化錯誤處理的唯一方法,這種方法並不適用於所有情況。然而,關鍵的教訓是,錯誤是值,Go 程式語言的全部功能都可以用於處理它們。

使用語言來簡化你的錯誤處理。

但請記住:無論你做什麼,都要始終檢查你的錯誤!

最後,關於我和 @jxck_ 互動的完整故事,包括他錄製的一個小影片,請訪問他的部落格

下一篇文章:包名
上一篇文章:GothamGo:大蘋果裡的 gophers
部落格索引