Go 部落格
錯誤是值
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
方法呼叫底層 Writer
的 Write
方法,並記錄第一個錯誤以供將來參考:
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/zip
和 net/http
包都使用了它。更與本討論相關的是,bufio
包的 Writer
實際上是 errWriter
思想的一種實現。雖然 bufio.Writer.Write
返回一個錯誤,但這主要是為了遵循 io.Writer
介面。bufio.Writer
的 Write
方法的行為就像我們上面的 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()
}
這種方法有一個重大的缺點,至少對某些應用程式來說是這樣:無法知道在發生錯誤之前完成了多少處理。如果這些資訊很重要,則需要更細粒度的方法。然而,通常情況下,在最後進行一次全有或全無的檢查就足夠了。
我們只看了一種避免重複錯誤處理程式碼的技術。請記住,使用 errWriter
或 bufio.Writer
並不是簡化錯誤處理的唯一方法,這種方法並不適用於所有情況。然而,關鍵的教訓是,錯誤是值,Go 程式語言的全部功能都可以用於處理它們。
使用語言來簡化你的錯誤處理。
但請記住:無論你做什麼,都要始終檢查你的錯誤!
最後,關於我和 @jxck_ 互動的完整故事,包括他錄製的一個小影片,請訪問他的部落格。
下一篇文章:包名
上一篇文章:GothamGo:大蘋果裡的 gophers
部落格索引