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)

接著範例使用者代碼可能是 (依據令牌如何擷取而定),

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

這不太不一樣,但有一個重要的區別。在此代碼中,客戶端必須在每次反覆運算中檢查錯誤,但在真實的 Scanner API 中,錯誤處理會從關鍵 API 元素 (也就是反覆運算令牌) 中抽象出來。因此,透過真實 API,客戶端的代碼會感覺更自然:反覆運算直到完成,再處理錯誤。錯誤處理不會模糊控制流程。

當然,發生在幕後的事情,是當 Scan 遇到 I/O 錯誤時,它便會記錄該錯誤並傳回 false。一個分開的方法 Err 在客戶端要求時報告錯誤值。儘管這很微不足道,但這與到處放置

if err != nil

或要求客戶端在每個令牌後檢查錯誤不同。這是使用錯誤值編寫程式。簡單的編寫程式,沒錯,但畢竟還是編寫程式。

值得強調的是,不論設計為何,最重要的是程式檢查錯誤,不論這些錯誤如何公開。這裡的討論不是關於如何避免檢查錯誤,而是關於使用該程式語言優雅地處理錯誤。

當我參加 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

它非常重複。在實際的程式碼中,碼較長,因此要進行執行,所以無法僅使用 Helper 函式進行轉譯,但就這個理想形式來說,關閉在 error 變數上的函式文字將會有幫助。

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
}

這個模式應用良好,不過需要在每一個執行寫入的函式使用封閉;一個獨立的 Helper 函式在使用上會比較繁瑣,因為在過程中需要維護 `err` 變數(試試看)。

透過借用上方 `Scan` 方法的概念,我們可以讓它更簡潔、通用及可重複使用。我在我們的討論中提到此技巧,不過 `@jxck_` 沒有看到如何應用。經過一連串的交流,由於語言隔閡而受到一些阻礙,我詢問是否能借用他的筆電,並透過編寫一些程式碼向他展示方法。

我定義一個名為 `errWrite` 的物件,類似以下範例

type errWriter struct {
    w   io.Writer
    err error
}

並賦予它一個方法:`write`。它不需要具備標準 `Write` 簽章,而使用小寫部分來說明區別。`write` 方法會呼叫基礎 `Writer` 的 `Write` 方法,並記錄第一個 error 以供未來參考。

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

一發生 error,`write` 方法就會變成 no-op,但會儲存 error 值。

針對 `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
}

這比使用封閉更簡潔,且讓實際執行的寫入順序更容易在頁面上顯示。現在沒有任何雜亂。使用 error 值(和介面)來編寫程式,讓程式碼更美觀。

同一個套件中其他程式碼很可能可以建立在這個想法上,甚至直接使用 `errWriter`。

此外,`errWriter` 在存在的情況下,能夠提供更多協助,特別是在較不人為的範例中。它可以累積位元組計數。它可以將寫入合併成一個可以原子化傳輸的單一緩衝區。而且還有更多功能。

事實上,這個模式在標準函式庫中出現的頻率很高。`archive/zip` 和 `net/http` 套件都會使用它。更能說明此討論的重點是,`bufio 套件的 `Writer`` 實際上是 `errWriter` 想法的實作。儘管 `bufio.Writer.Write` 會傳回一個 error,但這主要是為了遵循 `io.Writer` 介面。`bufio.Writer` 的 `Write` 方法行為與我們上面提到的 `errWriter.write` 方法類似,並以 `Flush` 來報告 error,所以我們可以使用類似以下方式撰寫我們的範例

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()
}

這種方法有一個顯著的缺點,至少適用於某些應用程式:無法得知在錯誤發生前已完成了多少運算處理。如果這些資訊很重要,那麼需要更精細的方法。不過,通常在最後執行全有或全無的檢查就已足夠。

我們剛剛檢視了其中一種避免重複錯誤處理程式碼的技術。請記住,errWriter 或 bufio.Writer 的使用並非簡化錯誤處理的唯一方法,而且這種方法並不適用於所有情況。然而,最重要的觀念是,錯誤就是值,而且可用 Go 程式語言的全部功能來處理它們。

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

但請記住:無論你做什麼,都必須檢查你的錯誤!

最後,要了解我與 @jxck_ 互動的完整故事,其中包括他所錄製的一段小影片,請瀏覽他的部落格

下一篇文章:套件名稱
上一篇文章:GothamGo:紐約市的大蘋果中的地鼠
部落格索引