Go 部落格
[ 有關 | 關於 ] 錯誤處理的語法支援
關於 Go 語言,最古老也最持續的抱怨之一就是錯誤處理的冗長。我們都非常熟悉(有些人可能會說痛苦地熟悉)這種程式碼模式:
x, err := call()
if err != nil {
// handle err
}
if err != nil
的測試可能無處不在,以至於淹沒了程式碼的其餘部分。這通常發生在大量 API 呼叫,且錯誤處理粗略並簡單返回的程式中。有些程式最終的程式碼看起來像這樣:
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
}
在這個函式體中,十行程式碼中只有四行(呼叫和最後兩行)似乎在做實際的工作。其餘六行則顯得多餘。冗長是真實存在的,因此毫不奇怪,關於錯誤處理的抱怨多年來一直位居我們年度使用者調查榜首。(有一段時間,缺少泛型超越了對錯誤處理的抱怨,但現在 Go 支援泛型,錯誤處理又回到了榜首。)
Go 團隊認真對待社群反饋,因此多年來我們一直與 Go 社群的意見一起,努力尋找解決此問題的方法。
Go 團隊的第一次明確嘗試可以追溯到 2018 年,當時 Russ Cox 正式描述了這個問題,作為我們當時所稱的 Go 2 工作的一部分。他根據 Marcel van Lohuizen 的草案設計概述了一個可能的解決方案。該設計基於 check
和 handle
機制,並且相當全面。該草案包括對替代解決方案的詳細分析,包括與其他語言所採取方法的比較。如果您想知道您特定的錯誤處理想法是否曾被考慮過,請閱讀此文件!
// 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
方法被認為過於複雜,大約一年後,在 2019 年,我們推出了大幅簡化且現在臭名昭著的try
提案。它基於 check
和 handle
的思想,但 check
偽關鍵字變成了 try
內建函式,並且省略了 handle
部分。為了探索 try
內建函式的影響,我們編寫了一個簡單的工具(tryhard),它使用 try
重寫了現有的錯誤處理程式碼。該提案經過了激烈的爭論,在 GitHub issue 上接近 900 條評論。
// printSum implementation using the proposed try mechanism.
func printSum(a, b string) error {
// use a defer statement to augment errors before returning
x := try(strconv.Atoi(a))
y := try(strconv.Atoi(b))
fmt.Println("result:", x + y)
return nil
}
然而,try
透過在出錯時從封閉函式返回來影響控制流,並且可能從深度巢狀的表示式中這樣做,從而隱藏了這種控制流。這使得該提案對許多人來說難以接受,儘管我們對該提案投入了大量精力,但我們決定放棄這項工作。回想起來,引入一個新關鍵字可能更好,因為現在我們可以透過 go.mod
檔案和特定於檔案的指令來對語言版本進行細粒度控制。將 try
的使用限制在賦值和語句中可能緩解了一些其他擔憂。Jimmy Frasche 的最近提案,實質上回到了最初的 check
和 handle
設計,並解決了該設計的一些缺點,正朝著這個方向發展。
try
提案的餘波導致了許多深刻的反思,包括 Russ Cox 的一系列部落格文章:《思考 Go 提案流程》。一個結論是,我們很可能透過提出一個幾乎完全成熟的提案,而沒有給社群反饋留下太多空間,並設定一個“威脅性”的實現時間表,從而降低了獲得更好結果的機會。根據《Go 提案流程:重大變更》:“回想起來,try
是一個足夠大的變更,我們釋出的新設計 […] 應該是一個第二稿設計,而不是一個帶有實現時間表的提案”。但無論在這種情況下可能存在的流程和溝通失敗,使用者對該提案的情緒都非常強烈地不贊成。
當時我們沒有更好的解決方案,並且幾年內沒有繼續進行錯誤處理的語法更改。然而,社群中很多人受到了啟發,我們收到了源源不斷的錯誤處理提案,其中許多彼此非常相似,有些很有趣,有些難以理解,還有一些不可行。為了跟蹤不斷擴大的局面,又過了一年,Ian Lance Taylor 建立了一個總括議題,總結了改進錯誤處理提議的當前狀態。建立了一個Go Wiki 來收集相關的反饋、討論和文章。獨立地,其他人多年來也開始跟蹤許多錯誤處理提案。看到所有這些提案的龐大數量真是令人驚歎,例如在 Sean K. H. Liao 關於“Go 錯誤處理提案”的部落格文章中。
關於錯誤處理冗長的抱怨持續存在(參見Go 開發者調查 2024 年上半年結果),因此,在一系列日益完善的 Go 團隊內部提案之後,Ian Lance Taylor 於 2024 年釋出了“使用 ?
減少錯誤處理樣板”。這次的想法是借鑑Rust 中實現的一種構造,特別是?
運算子。我們希望透過依賴一種使用既定符號的現有機制,並考慮到我們多年來的經驗教訓,我們應該能夠最終取得一些進展。在小型非正式使用者研究中,當向程式設計師展示使用 ?
的 Go 程式碼時,絕大多數參與者正確地猜出了程式碼的含義,這進一步說服我們再試一次。為了能夠看到這一變化的影響,Ian 編寫了一個工具,將普通的 Go 程式碼轉換為使用提議的新語法的程式碼,我們還在編譯器中原型化了該功能。
// 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
}
不幸的是,與其他的錯誤處理想法一樣,這個新提案也很快被評論和許多小的調整建議淹沒,這些建議通常基於個人偏好。伊恩關閉了該提案,並將內容移至討論以促進對話並收集進一步的反饋。一個稍微修改的版本獲得了稍微更積極的評價,但廣泛的支援仍然難以獲得。
經過這麼多年的嘗試,Go 團隊提出了三個成熟的提案,以及社群的數百 (!) 個提案,其中大多數都是主題的變體,但都未能獲得足夠的(更不用說壓倒性的)支援。我們現在面臨的問題是:該如何繼續?我們應該繼續下去嗎?
我們認為不應該。
更準確地說,至少在可預見的未來,我們應該停止嘗試解決*語法問題*。提案流程為這一決定提供了理由:
提案流程的目標是在及時的情況下就結果達成普遍共識。如果在問題跟蹤器的討論中,提案審查無法確定普遍共識,通常的結果是提案被拒絕。
此外
可能會出現這種情況:提案審查未能達成普遍共識,但很明顯該提案不應被直接拒絕。[…]如果提案審查小組無法確定共識或提案的下一步,則向前推進的決定將交給 Go 架構師 […],他們將審查討論並旨在在他們之間達成共識。
所有錯誤處理提案都沒有達成任何接近共識的意見,因此它們都被拒絕了。即使是 Google Go 團隊中最資深的成員目前也無法就最佳前進道路達成一致意見(也許將來會改變)。但如果沒有強烈的共識,我們無法合理地向前推進。
有一些支援現狀的有效論點:
-
如果 Go 語言早早引入了針對錯誤處理的特定語法糖,今天很少有人會為此爭論。但我們已經走過了 15 年,機會已經過去,Go 擁有一種完美的錯誤處理方式,即使有時可能顯得冗長。
-
換個角度看,假設我們今天找到了完美的解決方案。將其納入語言只會將一群不滿意的使用者(支援變革的那群)轉變為另一群(偏愛現狀的那群)。當我們決定向語言新增泛型時,我們也處於類似的情況,儘管有一個重要的區別:今天沒有人被迫使用泛型,而且優秀的泛型庫編寫得很好,以至於使用者大多可以忽略它們是泛型的事實,這得益於型別推斷。相反,如果向語言新增新的錯誤處理語法結構,幾乎每個人都將需要開始使用它,否則他們的程式碼將變得不地道。
-
不新增額外語法與 Go 的設計規則之一相符:不要提供多種做同樣事情的方法。在高“流量”領域存在此規則的例外:賦值就是一例。諷刺的是,在短變數宣告(
:=
)中重新宣告變數的能力是為了解決因錯誤處理而產生的問題:如果沒有重新宣告,錯誤檢查序列需要為每次檢查使用不同名稱的err
變數(或額外的獨立變數宣告)。那時,一個更好的解決方案可能是為錯誤處理提供更多的語法支援。那樣,可能就不需要重新宣告規則了,隨著它的消失,各種相關的複雜問題也將不復存在。 -
回到實際的錯誤處理程式碼,如果錯誤真正*得到處理*,冗長就會退居次要位置。良好的錯誤處理通常需要向錯誤新增額外資訊。例如,使用者調查中一個反覆出現的評論是關於錯誤缺乏堆疊跟蹤。這可以透過生成並返回增強錯誤的輔助函式來解決。在這個( admittedly contrived)示例中,樣板程式碼的相對數量要小得多:
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 }
-
新的標準庫功能也可以幫助減少錯誤處理樣板,這與 Rob Pike 2015 年的部落格文章《錯誤是值》非常一致。例如,在某些情況下,
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 }
-
編寫、閱讀和除錯程式碼都是截然不同的活動。編寫重複的錯誤檢查可能很繁瑣,但今天的 IDE 提供了強大的、甚至由 LLM 輔助的程式碼補全功能。編寫基本的錯誤檢查對於這些工具來說很簡單。冗長在閱讀程式碼時最明顯,但工具也可能在這裡提供幫助;例如,具有 Go 語言設定的 IDE 可以提供一個切換開關來隱藏錯誤處理程式碼。這樣的開關已經存在於其他程式碼部分,例如函式體。
-
在除錯錯誤處理程式碼時,能夠快速新增
println
或在偵錯程式中為設定斷點提供專用行或源位置很有幫助。當已經有專用的if
語句時,這很容易。但是,如果所有錯誤處理邏輯都隱藏在check
、try
或?
後面,則可能需要先將程式碼更改為普通的if
語句,這會使除錯複雜化,甚至可能引入細微的錯誤。 -
還有一些實際考慮:想出新的錯誤處理語法很簡單;因此社群湧現出大量提案。想出能經受住嚴格審查的好解決方案:就沒那麼容易了。需要協同努力才能正確設計語言變更並實際實現它。真正的成本還在後面:所有需要更改的程式碼、需要更新的文件、需要調整的工具。綜合來看,語言變更非常昂貴,Go 團隊相對較小,還有很多其他優先事項需要解決。(這些後一點可能會改變:優先事項可能會轉移,團隊規模可能會增減。)
-
最後,我們中的一些人最近有機會參加了Google Cloud Next 2025,Go 團隊在那裡設有一個展位,我們還舉辦了一個小型的 Go Meetup。我們有機會詢問的每一位 Go 使用者都堅決表示我們不應該改變語言以更好地處理錯誤。許多人提到,Go 中缺乏特定的錯誤處理支援在剛從其他具有該支援的語言轉過來時最明顯。隨著人們變得更流利並編寫更多地道的 Go 程式碼,這個問題變得不那麼重要了。當然,這不足以代表所有 Go 使用者,但這可能是一群與我們在 GitHub 上看到的不同的使用者,他們的反饋作為另一個數據點。
當然,也有支援變革的有效論據:
-
缺乏更好的錯誤處理支援仍然是我們使用者調查中最主要的抱怨。如果 Go 團隊確實認真對待使用者反饋,我們最終應該對此做些什麼。(儘管似乎也沒有壓倒性的支援語言更改。)
-
也許過於關注減少字元數是錯誤的。更好的方法可能是透過關鍵字使預設錯誤處理高度可見,同時仍然消除樣板程式碼(
err != nil
)。這種方法可能使讀者(程式碼審查員!)更容易看到錯誤已處理,而無需“看兩遍”,從而提高程式碼質量和安全性。這將使我們回到check
和handle
的起點。 -
我們不確定這個問題在多大程度上是錯誤檢查的直接語法冗長,還是良好錯誤處理的冗長:構建對 API 有用並對開發人員和終端使用者都有意義的錯誤。這是我們希望更深入研究的問題。
然而,迄今為止,所有解決錯誤處理的嘗試都未能獲得足夠的關注。如果我們誠實地評估我們所處的位置,我們只能承認我們既沒有對問題達成共識,也沒有都同意一開始就存在問題。考慮到這一點,我們做出了以下務實的決定:
在可預見的未來,Go 團隊將停止尋求錯誤處理的語法語言變更。我們還將關閉所有主要關注錯誤處理語法的開放和傳入提案,不再進行進一步調查。
社群為探討、討論和辯論這些問題付出了巨大的努力。雖然這可能沒有導致錯誤處理語法的任何改變,但這些努力促成了 Go 語言和我們流程的許多其他改進。也許,在未來的某個時候,關於錯誤處理的更清晰的圖景將會出現。在此之前,我們期待將這份巨大的熱情集中在新的機會上,使 Go 對每個人都變得更好。
謝謝!