錯誤是值 Errors are values
Rob Pike
12 January 2015
在程序員中,尤其是go新手,經常聽到的一個討論話題是:如何處理錯誤。當下面這段代碼出現次數過多時,這個話題大多數時候都會變成對go的悲嘆。
if err != nil {
return err
}
我們最近掃描了所有我們能找到的開源項目代碼,但是確發現這段代碼平均一兩頁出現一次,遠沒有我們原本設想的出現次數那么多。如果有人任然堅信一定要輸入if err !=nil
,那么肯定是那里出錯了,顯然,是go本身的問題。
這是不幸,有誤,並且容易糾正的一個問題。可能當go新手在詢問”如何處理錯誤“的時候,他們學會了這個模式(指if err !=nil
),並且對錯誤的認知到此為止了。在其他語言,一個程序員使用try-catch
代碼塊或者而其他類似的機制去處理錯誤。因此,這些程序員們認為,在我之前的語言中我用try-catch
去處理錯誤,那么我在go中我也要用相應的if err != nil
處理錯誤就行了。隨着時間過去,在go代碼中出現許多這樣的小片段,導致看起來十分愚蠢。
無論這個解釋再怎么好,很明顯的一點就是go程序員忽略了一個關於錯誤的概念:錯誤是值(Errors are value)。
我們可以對值進行編程,並且因為錯誤是值,所以我們也可以對錯誤進行編程。
原文:Values can be programmed, and since errors are values, errors can be programmed.
一個常見的涉及到錯誤的語句是測試錯誤值是不是空值,但是我們還可以對錯誤值進行無數種處理,使用其中一些處理,可以讓我們的程序變得更好,消滅那些每一錯誤都要用樣板if
處理的語句。
這里有一個簡單例子,來自bufio
包的Scanner
類,它的Scan
方法執行基礎的I/O,這其中明顯可能產生錯誤,然而,scan
方法並沒有一個錯誤,而是返回一個布爾值,並且在scan
方法執行結束后,運行一個單獨的方法,報告是否單獨發生錯誤。客戶端代碼如下
scanner := bufio.NewScanner(input)
for scanner.Scan() {
token := scanner.Text()
// process token
}
if err := scanner.Err(); err != nil {
// process the error
}
當然,可以對每個error進行一次空值檢查,但是它出現並執行一次,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中,只會對token
進行迭代,錯誤處理從關鍵API代碼中抽象出來了。所以在真實的API中,用戶端的代碼因此更加自然,一直循環直到結束,然后再考慮錯誤,錯誤處理並不會影響到控制流。
在這背后發生了什么呢?當scan
遇到了I/O錯誤,它會記錄並且返回false
,另一個單獨的方法Err
,報告這個錯誤當客戶端調用它的時候,雖然這有些瑣碎,但是不同於到處輸入if err != nil
,或者告訴客戶必須時刻檢查每個token是否有錯誤。這個方法對錯誤進行了編程。簡單的編程處理,仍然是編程。
值得強調的是,無論使用哪種設計,最至關重要的是,無論錯誤如何暴露出來,都要去檢查處理它。我們在這的討論,不是討論如何避免檢查錯誤,而是如何使用go更優雅地處理錯誤。
我在東京參加2014年秋季GoCon大會時,反復出現了錯誤檢查代碼的話題。一個熱情的 gopher,@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
}
這個方式看起來不錯,但是在執行寫操作的時候,每個函數都需要閉包操作,每次單獨的輔助函數調用的時候都需要維護一個共同變量。
我們可以做得更干脆點,更適用點,並且復用性更高,通過借鑒上文的Scan
方法,我向@jxck提到了這個技巧,但是他不知道如何去使用,在一段時間的交流后,由於語言障礙,我借用他的電腦,寫下了這樣一段代碼,試着去提醒他。
我定義了一個對象叫做errWriter
,如下:
type errWriter struct {
w io.Writer
err error
}
並且給他賦予一個方法write
,他並不需要標准的Write方法
簽名,並且他的小寫突出了區別。寫方法調用了Write
方法,並且記錄了第一個遇到的錯誤用於后續使用。
func (ew *errWriter) write(buf []byte) {
if ew.err != nil {
return
}
_, ew.err = ew.w.Write(buf)
}
當錯誤發生時,write
將會變成一個無操作函數,但是會保存錯誤值。
定義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
package's Writer
就是基於errWriter
想法實現的,雖然 bufio.Writer.Write
返回一個錯誤,這主要是基於io.Writer
接口。Writer
方法的實現就像我們上文寫的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 編程語言的全部功能可用於處理它們。
使用該語言簡化錯誤處理。
但是請記住:無論做什么,都要檢查錯誤!