Go 錯誤處理之殤:Go 團(tuán)隊(duì)的決策與未來
Go 語言有很多優(yōu)秀的語法特性,例如,可以使用 go關(guān)鍵字很方便的啟動一個新的協(xié)程。但是,Go 同樣也有一些被 Go 開發(fā)者所詬病的語法特性,例如:泛型、錯誤處理。其中,錯誤處理,自 Go 誕生以來,一直被很多 Go 開發(fā)者詬病,沒有停止過。那么 Go 團(tuán)隊(duì)未來有無計(jì)劃改進(jìn) Go 的錯誤處理機(jī)制?
本篇文章,就來詳細(xì)介紹下 Go 團(tuán)隊(duì)對于錯誤處理的嘗試、思考和未來的計(jì)劃。
一、持續(xù) 15 年的抱怨
Go 語言自誕生以來,關(guān)于“錯誤處理太啰嗦”的抱怨就從未停歇。大家對下面這種代碼模式早已耳熟能詳(有人甚至說“看到都頭疼”):
x, err := call()
if err != nil {
// handle err
}
if err != nil 的檢查往往鋪天蓋地,以至于掩蓋了真正的業(yè)務(wù)邏輯。API 調(diào)用多、錯誤處理又只是簡單返回的程序尤為明顯,最終會出現(xiàn)類似下面的代碼:
func printSum(a, b string) error {
x, err := strconv.Atoi(a)
if err != nil {
return err
}
y, err := strconv.Atoi(b)
if err != nil {
return err
}
fmt.Println("result:", x + y)
return nil
}
在這個 10 行的函數(shù)體里,只有 4 行(兩次調(diào)用和最后兩行)真正“干活”,其余 6 行都是噪音。冗長確實(shí)存在,因此多年里“錯誤處理太啰嗦”一直穩(wěn)居 Go 年度用戶調(diào)查的抱怨榜首。(一度“缺少泛型”的吐槽超過了它,但在 Go 引入泛型后,錯誤處理又重新登頂。)
Go 團(tuán)隊(duì)十分重視社區(qū)反饋,多年來一直在嘗試與社區(qū)共同尋找解決方案。
二、三次“官宣”方案,全軍覆沒
Go 團(tuán)隊(duì)先后提出過 3 種錯誤處理方案,但是最終都全軍覆沒:
- 2018:check / handle 機(jī)制;
- 2019:try 提案;
- 2024:? 運(yùn)算符。
1. 2018:check / handle 機(jī)制
團(tuán)隊(duì)最早的正式嘗試可追溯到 2018 年。當(dāng)時 Russ Cox 在所謂的 “Go 2” 計(jì)劃中正式描述了這一問題,并在 Marcel van Lohuizen 的草案基礎(chǔ)上提出了可能的解決思路。該設(shè)計(jì)基于 “check / handle” 機(jī)制,內(nèi)容相當(dāng)全面。草案還詳細(xì)對比了其他語言的各種替代方案。如果你想知道自己的某個錯誤處理點(diǎn)子是否曾被討論過,不妨翻閱 Error Handling — Draft Design[1]。該方案的一段示例代碼如下:
// printSum implementation using the proposed check/handle mechanism.
func printSum(a, b string) error {
handle err { return err }
x := check strconv.Atoi(a)
y := check strconv.Atoi(b)
fmt.Println("result:", x + y)
return nil
}
“check / handle” 方案被認(rèn)為過于復(fù)雜,最終擱置。
2. 2019:try 提案
一年后的 2019 年,Go 團(tuán)隊(duì)推出了大幅簡化、如今頗具“傳奇”色彩的 try 提案[2]。它沿用了 check / handle 的思路,但把偽關(guān)鍵字 check 改成內(nèi)置函數(shù) try,并省去了 handle 部分。
為了評估 try 的效果,Go 團(tuán)隊(duì)編寫了一個簡單工具 tryhard[3],可將現(xiàn)有的錯誤處理代碼自動改寫為 try 形式。該提案在 GitHub 議題(#32437[4])中引發(fā)了激烈爭論,評論數(shù)接近 900 條。
// 使用 try 機(jī)制的 printSum 實(shí)現(xiàn)
func printSum(a, b string) error {
// defer 用于在返回前增強(qiáng)錯誤信息
x := try(strconv.Atoi(a))
y := try(strconv.Atoi(b))
fmt.Println("result:", x + y)
return nil
}
然而,try 會在發(fā)生錯誤時直接從外圍函數(shù)返回,而且可能出現(xiàn)在深層嵌套的表達(dá)式里,導(dǎo)致控制流被“藏”起來。許多人對此難以接受。盡管Go 團(tuán)隊(duì)在此方案上投入巨大,最終仍決定放棄。事后看來,如果當(dāng)時采用新關(guān)鍵字、并借助 go.mod 等機(jī)制進(jìn)行版本隔離,或許會更好。再者,如果把 try 的使用限制在賦值和語句層面,也許能緩解部分擔(dān)憂。Jimmy Frasche 最近的一份提案[5]基本回到最初的 check / handle 設(shè)計(jì),并針對其缺點(diǎn)進(jìn)行了改進(jìn),正是朝這一方向探索。
try 提案的后續(xù)反思催生了 Russ Cox 的系列博文思考 Go 提案流程[6]。其中一個結(jié)論是:Go 團(tuán)隊(duì)當(dāng)時拿出的是“半成品就要落地”的方案,給社區(qū)預(yù)留的討論空間太小、實(shí)施時間表又顯得“咄咄逼人”,因而錯失了更好結(jié)果。按照 Go 提案流程:重大變更[7] 中的說法:“事后看來,try 屬于足夠大的變更,其發(fā)布的設(shè)計(jì)應(yīng)算第二版草案,而不該附帶實(shí)施時間表?!?無論流程溝通是否到位,用戶的總體態(tài)度都十分明確:不買賬。
當(dāng)時Go 團(tuán)隊(duì)沒有更好的方案,于是幾年內(nèi)都未再嘗試修改錯誤處理語法。社區(qū)卻靈感不斷,各種提案源源不斷:有的高度相似,有的頗具創(chuàng)意,也有些難以理解或注定不可行。為梳理紛繁的討論,一年后 Ian Lance Taylor 建立了一個 雨傘[8] Issue,匯總改進(jìn)錯誤處理的所有提案。同時在 Go Wiki[9] 收集相關(guān)反饋、討論與文章。社區(qū)里還有人獨(dú)立整理多年來出現(xiàn)的所有提案——例如 Sean K. H. Liao 的博文 go error handling proposals[10] 就展示了數(shù)量之多的令人咋舌。
“錯誤處理太啰嗦”的抱怨依舊(參見《Go 開發(fā)者調(diào)查 2024 H1 結(jié)果》[11])。于是,經(jīng)過數(shù)輪內(nèi)部方案迭代,Ian Lance Taylor 于 2024 年發(fā)布了《使用 ? 減少錯誤處理樣板》[12]。這回借鑒了 Rust 的 ? 運(yùn)算符。
3. 2024:? 運(yùn)算符
Go 團(tuán)隊(duì)希望利用已有且被驗(yàn)證的記號,加上多年積累的經(jīng)驗(yàn),能真正向前邁一步。在一次小型非正式用戶試驗(yàn)中,開發(fā)者看到帶 ? 的 Go 代碼后,絕大多數(shù)能正確猜出含義,進(jìn)一步增強(qiáng)了Go 團(tuán)隊(duì)繼續(xù)嘗試的信心。為了直觀評估變化,Ian 編寫了一個工具,把普通 Go 代碼轉(zhuǎn)換為新語法版本。Go 團(tuán)隊(duì)也在編譯器中做了原型實(shí)現(xiàn)。
// printSum implementation using the proposed "?" statements.
func printSum(a, b string) error {
x := strconv.Atoi(a) ?
y := strconv.Atoi(b) ?
fmt.Println("result:", x + y)
return nil
}
不幸的是,同其他錯誤處理方案一樣,這份新提案也很快被大量評論和各種“微調(diào)建議”淹沒,其中不少僅基于個人偏好。Ian 關(guān)閉了該提案,并將內(nèi)容移入 Discussion[13] 以便后續(xù)討論、收集更多反饋。稍加修改后的版本雖然評價好了一點(diǎn)[14],但依舊難以獲得廣泛支持。
三、統(tǒng)計(jì):數(shù)百份社區(qū)提案,仍無共識
這么多年走來,Go 團(tuán)隊(duì)已正式提出 3 份完整方案,社區(qū)也提供了字面意義上的數(shù)百份(!)變體,結(jié)果卻都是雷聲大、雨點(diǎn)?。簺]有任何方案能贏得足夠(更別說壓倒性)的支持。Go 團(tuán)隊(duì)現(xiàn)在面對的問題是:下一步該怎么辦?甚至要不要繼續(xù)折騰?Go 團(tuán)隊(duì)的結(jié)論是:暫時不折騰。
更準(zhǔn)確地說,在可預(yù)見的未來,Go 團(tuán)隊(duì)不再嘗試通過新增語法來解決錯誤處理的冗長問題。Go 提案流程[15]為這一決定提供了依據(jù):提案流程的目標(biāo)是在合理時間內(nèi)達(dá)成普遍共識。如果在議題討論中無法形成共識,提案通常會被否決。
進(jìn)一步地:如果既沒法形成共識,也不能直接否決,最終將由 Go 架構(gòu)師審閱討論并嘗試在內(nèi)部達(dá)成共識。
四、維持現(xiàn)狀的理由
沒有任何一個錯誤處理提案能夠接近共識,所以它們?nèi)勘环駴Q。即便是 Google Go 團(tuán)隊(duì)最資深的成員,目前也未能一致同意最佳的前進(jìn)方向(也許以后會改變)。但在缺乏強(qiáng)有力共識的情況下,Go 團(tuán)隊(duì)無法合理地繼續(xù)推進(jìn)。
以下是支持維持現(xiàn)狀的一些合理論點(diǎn):
如果 Go 在早期就為錯誤處理引入了特定的語法糖,今天大概很少有人會為此爭論。但 Go 已經(jīng)走過了 15 年,這個機(jī)會已成過往,Go 現(xiàn)有的錯誤處理方式雖然有時看起來啰嗦,卻完全可用;
換個角度想,假設(shè)今天突然發(fā)現(xiàn)了完美的解決方案,把它并入語言后,只會把一群不滿(支持改動的人)換成另一群不滿(偏好現(xiàn)狀的人)。當(dāng)初決定在語言中加入泛型時,情況也類似,不過有一個重要區(qū)別:今天沒人被強(qiáng)制使用泛型,而且由于類型推斷的幫助,優(yōu)秀的泛型庫寫得足夠透明,用戶幾乎感覺不到它們是泛型。相反,如果在語言中直接添加新的錯誤處理語法,幾乎所有人都得開始用它,否則代碼就不夠 idiomatic 了;
不增添額外語法,也符合 Go 的一條設(shè)計(jì)原則:不要為同一件事提供多種做法。這條規(guī)則在一些“高頻”場景(比如賦值)上有例外。具有諷刺意味的是,短變量聲明[16](:=)中允許重聲明變量,正是為了解決錯誤處理帶來的問題:如果不能重聲明,連續(xù)的錯誤檢查就需要為每次檢查都使用不同名稱的 err 變量(或額外先聲明一個變量)。那時候也許更好的做法是為錯誤處理提供更多語法支持,那么重聲明規(guī)則就無需存在,也不會衍生那么多復(fù)雜性;
- 回到實(shí)際的錯誤處理代碼:如果真的在認(rèn)真處理錯誤,冗長感就會淡化。良好的錯誤處理往往需要在錯誤中添加更多信息。比如用戶調(diào)查中經(jīng)常提到缺少錯誤的堆棧跟蹤??梢酝ㄟ^輔助函數(shù)生成并返回增強(qiáng)后的錯誤來解決。舉個(雖有點(diǎn)勉強(qiáng))的例子,樣板代碼就少多了:
func printSum(a, b string) error {
x, err := strconv.Atoi(a)
if err != nil {
return fmt.Errorf("invalid integer: %q", a)
}
y, err := strconv.Atoi(b)
if err != nil {
return fmt.Errorf("invalid integer: %q", b)
}
fmt.Println("result:", x + y)
return nil
}
標(biāo)準(zhǔn)庫新增功能也能大幅減少錯誤處理樣板,正如 Rob Pike 2015 年博客 “Errors are values[17]” 所倡導(dǎo)的那樣。例如,在某些場景下可以用 [cmp.Or](https://go.dev/pkg/cmp#Or "cmp.Or") 一次合并多次錯誤:
func printSum(a, b string) error {
x, err1 := strconv.Atoi(a)
y, err2 := strconv.Atoi(b)
if err := cmp.Or(err1, err2); err != nil {
return err
}
fmt.Println("result:", x+y)
return nil
}
寫代碼、讀代碼、調(diào)試代碼是三件截然不同的事。寫一堆重復(fù)的錯誤檢查的確枯燥,但如今的 IDE 提供了強(qiáng)大的、甚至是 LLM 輔助的代碼補(bǔ)全,基本的 if err != nil 對它們來說毫無壓力。冗長感在閱讀時代最明顯,工具也能幫忙:比如 IDE 可以設(shè)置一個開關(guān),折疊掉所有錯誤處理代碼——類似于已經(jīng)存在的函數(shù)體折疊功能;
調(diào)試時,能快速插入一行 println 或在專門的行/位置下斷點(diǎn)非常方便。有了明顯的 if 語句,這些都很容易。但如果所有錯誤處理邏輯都隱藏在某種 check、try 或 ? 機(jī)制里,調(diào)試前還得先把它改回普通的 if,反而更麻煩,甚至容易引入細(xì)微的 bug;
還有現(xiàn)實(shí)的考量:想出一個新的錯誤處理語法點(diǎn)子很廉價,所以社區(qū)出現(xiàn)了無數(shù)提案;但要想出一個經(jīng)得起推敲的好方案,卻不容易。設(shè)計(jì)一次語言變動并真正實(shí)現(xiàn),需要大家共同努力。真正的成本在后面:需要改動的海量代碼、要更新的文檔、要適配的工具鏈……綜合來看,語言變更的代價極高,而 Go 團(tuán)隊(duì)人手有限,還有很多其他優(yōu)先級更高的事情要做。(當(dāng)然,優(yōu)先級可以調(diào)整,團(tuán)隊(duì)規(guī)模也可能增減);
最后,Go 團(tuán)隊(duì)中有些人在 2025 年 Google Cloud Next [18]上有機(jī)會與許多 Go 用戶面對面交流。Go 團(tuán)隊(duì)問到的每一個人都堅(jiān)決認(rèn)為:不要為了更好的錯誤處理而改變語言。很多人提到,一開始從其它帶有專用錯誤處理語法的語言轉(zhuǎn)來時,這點(diǎn)最明顯。但寫得越多、越熟練,寫出更地道的 Go 代碼后,這個問題就不再那么重要了。雖然這不是一個足夠具備代表性的樣本,但它提供了不同于 GitHub 討論區(qū)的、有價值的用戶視角。
五、支持變更的理由
當(dāng)然,也有支持變更的合理論點(diǎn):
- 在用戶調(diào)查中,對更好錯誤處理支持的訴求依然是首要抱怨。如果 Go 團(tuán)隊(duì)真心重視用戶反饋,就應(yīng)當(dāng)在未來某個時候?qū)Υ擞兴鶆幼?。(雖然目前也沒有跡象顯示對語言變更有壓倒性支持。);
- 也許我們對“減少敲字量”的單一追求本身就是誤導(dǎo)。更好的方法或許是引入一個關(guān)鍵字,讓默認(rèn)的錯誤處理一目了然,同時依然消除 err != nil 樣板代碼。這樣,代碼閱讀者(尤其是審查者)無需“多看幾遍”就能立刻察覺到錯誤已被處理,從而提升代碼質(zhì)量和安全性——這也正是最初 check/handle 思路的出發(fā)點(diǎn);
- 我們尚不清楚,問題究竟在于錯誤檢查語法的冗長,還是在于“優(yōu)質(zhì)”錯誤處理本身需要更多代碼:比如構(gòu)造對 API 有意義、對開發(fā)者和最終用戶都友好的錯誤信息。這一點(diǎn)值得更深入地研究。
然而,到目前為止,所有針對錯誤處理的嘗試都未獲得足夠的支持。
六、結(jié)論:暫緩一切語法層面方案
如果誠實(shí)地看待現(xiàn)狀,只能承認(rèn):既沒有對問題達(dá)成共識,也并非一致認(rèn)為這是個必須解決的問題。鑒于此,Go 團(tuán)隊(duì)做出以下務(wù)實(shí)決定:
- 在可預(yù)見的未來,Go 團(tuán)隊(duì)將停止推動任何針對錯誤處理的語法變更;
- Go 團(tuán)隊(duì)會關(guān)閉所有以錯誤處理語法為主要關(guān)注點(diǎn)的現(xiàn)有及新提案,不再作進(jìn)一步評估。
社區(qū)在探索、討論和辯論這些議題上付出了巨大努力。雖然這些努力未能改變錯誤處理語法,卻為 Go 語言及其流程帶來了諸多改進(jìn)。或許將來某個時刻,Go 團(tuán)隊(duì)會對錯誤處理有更清晰的認(rèn)識。在那之前,Go 團(tuán)隊(duì)期待把這份熱情投入到讓 Go 變得更好的其他新機(jī)遇中。