關于Go錯誤處理新提案的一個想法:?操作符這樣用行不行
0. 背景
Ian Taylor在關閉了旨在消除Go錯誤處理樣板代碼的issue[1]之后,又另起了一個“同名”的discussion[2]。錯誤處理真不愧是Go社區呼聲最高的問題,幾天之內又收到了近500條回復!不過到目前為止,依然沒有形成統一和高贊的意見。
關于error handling的樣板代碼過多,其實我個人是可以接受的,即便不做出任何改變也是ok的,估計Go社區與我有相同看法的也不在少數。比如就有人引用了Rob Pike的權威觀點[3],并認為Go應該按照Rob大神的思路,保持Go語法穩定:
不過自然也會有另外一批人強烈希望錯誤處理的樣板代碼得到改進。
Ian Taylor在discussion中明確了該提案的目標是引入一種新語法,在不影響控制流清晰度的前提下,減少正常情況下檢查錯誤所需的代碼量。
Ian最初的Proposal由于隱式聲明變量err以及可選代碼塊等問題而備受“批評”,并且似乎該proposal違反了他自己提出的目標。
今天在discussion中看到一位名為Mukunda Johnson的gopher的評論[4],我覺得很有道理。其核心觀點就是:盡量保持Go的傳統語法形式。他還給出了期望中的語法示例:
// 當前錯誤處理樣板代碼過多的示例
f, err := open(file)
if err != nil {
return err
}
defer f.Close()
if err = binwrite(f, signature); err != nil {
return err
}
if err = binwrite(f, header); err != nil {
return err
}
if err = binwrite(f, zeroSegment); err != nil {
return err
}
for _, s := range segments {
if err = binwrite(f, s); err != nil {
return err
}
}
if err = binwrite(f, footer); err != nil {
return err
}
vs.
// 使用新語法改進后的代碼
f, err := open(file)?
defer f.Close()
binwrite(f, signature)?
binwrite(f, header)?
binwrite(f, zeroSegment)?
for _, s := range segments {
binwrite(f, s)?
}
binwrite(f, footer)?
這給了我很大啟發:我們可以引入?語法,但是如果結合原先err變量的聲明形式豈不是更好!比如:
f, err := open(file)?
豈不是要比下面兩種形式更好!
f := open(file)?
或
f := open(file)? err { }
通過僅引入一個問號(?)操作符,并避免引入過多的新語法形式,卻能解決60%的錯誤處理樣板問題。根據jba對Go開源代碼中錯誤處理的抽樣統計[5],超過60%的錯誤處理都是直接返回err,而沒有對err進行任何修飾。此外,顯式聲明err可以最大程度地避免隱式聲明帶來的問題,同時提升代碼的可讀性。
因此,基于盡量使用已有Go代碼風格、最大程度避免隱式聲明,并僅解決最常見的錯誤處理樣板代碼的原則,下面我基于Ian提案的錯誤處理改進語法,談點自己關于新?操作符使用的想法,大家看看是否可行。
1. 對于最常見的未經修飾的錯誤處理代碼
err := SomeFunction2()
if err != nil {
return err
}
或是
if err := SomeFunction2(); err != nil {
return err
}
我們使用下面的新語法做等價替代:
err := SomeFunction2() ?
如果聲明的錯誤變量名為err,也可省略賦值操作符左側代碼,從而簡化為:
SomeFunction2() ? // 這里略帶隱式
2. 如果函數返回值有多個,甚至有多個錯誤變量的情況
比如下面代碼:
a, b, err0, err1, err2 := SomeFunction3()
if err2 != nil {
return err2
}
我們可以將其改寫為:
a, b, err0, err1, err2 := SomeFunction3()?
其語義是如果err2不為nil,返回err2,但前提要保證賦值語句的左側的最后一個變量err2必須是實現error接口的類型的變量。
如果是像下面這樣在err2 != nil時有多個返回值,又該如何處理呢?
a, b, err0, err1, err2 := SomeFunction3()
if err2 != nil {
return a, b, err2
}
對于這種情況,我認為可以不在新方案的考慮范圍之內,現在怎么寫,請繼續這么寫。如果非要解決,請繼續看后面支持可選代碼塊的情況。
實現以上兩種情況,就能解決60%以上的錯誤樣板代碼問題了!
3. 對于對返回的error值進行修飾的情況
對于像下面兩種對返回的error變量進行修飾的情況:
r, err := SomeFunction()
if err != nil {
return fmt.Errorf("something failed: %v", err)
}
和
if err := SomeFunction2(); err != nil {
return fmt.Errorf("something else failed: %v", err)
}
我的第一想法是保持現狀 ,不在新方案考慮范圍之內。
不過如果非要在新方案中解決,那就需要引入可選代碼塊(optional block)了!比如:
r, err := SomeFunction() ? {
return fmt.Errorf("something failed: %v", err)
}
err := SomeFunction2() ? {
return fmt.Errorf("something else failed: %v", err)
}
和Ian的原proposal中語法不同,這里我們依然顯式聲明了err,當然你也可以不用err這個名字,由于是顯式聲明,你用任何名字均可,比如:
r, e := SomeFunction() ? {
return fmt.Errorf("something failed: %v", e)
}
myErr := SomeFunction2() ? {
return fmt.Errorf("something else failed: %v", myErr)
}
這將避免隱式聲明帶來的諸多問題!
基于可選代碼塊,我們也可以處理一下前面提到的返回多個值的情況。下面代碼
a, b, err0, err1, err2 := SomeFunction3()
if err2 != nil {
return a, b, err2
}
可以改寫為:
a, b, err0, err1, err2 := SomeFunction3() ? {
return a, b, err2
}
這里加入可選代碼塊后,我建議開發人員負責顯式調用return,而不是由?操作符來自動return,也就是說完全將控制權交給你。如果你沒有在可選代碼塊中調用return,那么代碼在執行完可選代碼塊中的代碼后,還會繼續向下執行。可選代碼塊相當于一個error handler,而不帶可選代碼塊的情況,默認的error handler其實就是一個return err,偽代碼類似這樣:
err := SomeFunction2() ?
<=>
err := SomeFunction2() ? {
return err
}
這樣解釋后,你是不是覺得在語義層面,不帶可選代碼塊與帶有可選代碼塊的情況就統一和一致了呢!
本質上來說,?+可選代碼塊僅是讓你少敲了個if以及err != nil。
4. 綜合示例
Mukunda Johnson給出的示例其實已經可以很好地展示?操作符+顯式聲明err方案帶來的消除樣板代碼的效果,這里再回顧一下(這里沒用到可選代碼塊,因此代碼顯得格外清晰):
f, err := open(file)?
defer f.Close()
binwrite(f, signature)?
binwrite(f, header)?
binwrite(f, zeroSegment)?
for _, s := range segments {
binwrite(f, s)?
}
binwrite(f, footer)?
此外,在原discussion中,另外一個gopher提出的示例,我們也可以用上面的想法改寫一下:
// 最常見的情況
SomeFunc() ?
// 多個返回值,最后一個為error變量
a, err1 := SomeFunction2() ?
// 返回前對err進行修飾
err := SomeFunc() ? {
return fmt.Errorf("oh no: %w", err)
}
// 顯式聲明避免變量遮蔽
err := SomeFunc() ? {
otherErr := OtherFunc() ? {
err = errors.Wrap(err, otherErr) // 在可選代碼塊中沒有顯式調用return,代碼還會繼續向后執行
}
return fmt.Errorf("oh no: %w", err)
}
5. 小結
再來簡單總結一下上面想法中的語法形式的優勢:
- 與傳統Go語法形式幾乎一致,盡量避免引入過多新語法形式,在不使用可選代碼塊的時候,只是多了一個問號(?)。
- 顯式聲明err變量,最大程度避免隱式聲明帶來的問題。
- 專注解決最常見的錯誤處理樣板情景,其他場景保持當前寫法即可。
- 即便引入可選代碼塊,本質上與不用可選代碼塊的語法在語義層面也是統一和一致的。
這一語法方案保留了原Ian提案中的優勢,并能消除一些缺點,如變量遮蔽和隱式聲明等。不過,仍然有些原proposal中的劣勢問題無法完全消除,但這些問題顯然不是主要關注點。
需要注意的是,以上想法目前僅停留在形式討論層面,技術層面是否可行尚不確定。
大家認為我的想法可行嗎?希望大家能提出更具建設性的意見^_^。
參考資料
[1] 旨在消除Go錯誤處理樣板代碼的issue: https://github.com/golang/go/issues/71203
[2] discussion: https://github.com/golang/go/discussions/71460
[3] Rob Pike的權威觀點: https://go.dev/talks/2015/simplicity-is-complicated.slide#9
[4] Mukunda Johnson的gopher的評論: https://github.com/golang/go/discussions/71460#discussioncomment-12084482
[5] jba對Go開源代碼中錯誤處理的抽樣統計: https://github.com/golang/go/issues/71203#issuecomment-2585915103