2025年6月,Go团队在官方博客发表了一篇题为"[On | No] syntactic support for error handling"的文章,宣布了一个意味深长的决定:停止追求错误处理的语法级改进,关闭所有相关的开放提案。这不是一次普通的功能延期,而是Go语言诞生十六年来,在错误处理这个核心议题上的彻底认输。
这背后是一场持续十年的拉锯战。三次正式提案、数百个社区方案、近千条GitHub评论,最终却没有一个能达成共识。为什么一个看似简单的"减少if err != nil样板代码"问题,会演变成编程语言设计史上最持久的争议之一?
一切始于一个设计选择
2007年,Rob Pike、Robert Griesemer和Ken Thompson在Google开始设计一门新语言。他们观察到的现状是:C++和Java过于复杂,编译缓慢;Python和JavaScript虽然灵活,但缺乏类型安全;而并发编程在当时几乎没有语言提供真正好用的支持。
在这些目标之外,他们对"错误处理"这个话题有一个明确的立场。Go FAQ中写道:
“We believe that coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code. It also tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional.”
这个观点并非空穴来风。Go的三位设计者都来自贝尔实验室,亲历了C语言和Unix的黄金时代。在他们看来,文件打开失败不是"异常",而是完全正常的、程序应该主动处理的情况。try-catch机制不仅让控制流变得难以追踪,更重要的是它改变了程序员的思维习惯——把本该认真对待的错误变成了"随便catch一下就好"的东西。
Go给出的答案是:错误是值(Errors are values)。
f, err := os.Open("filename.ext")
if err != nil {
// handle error
}
这四行代码成为Go程序中最常见的模式。它的哲学很清晰:错误处理不应该隐藏在某个魔法关键字背后,它应该像其他任何代码一样显式、可见、可控。
当"简洁"变成"冗余"
问题在于,这个"显式"的选择带来了一个副作用:代码膨胀。
一个典型的Go函数往往长这样:
func CopyFile(src, dst string) error {
r, err := os.Open(src)
if err != nil {
return err
}
defer r.Close()
w, err := os.Create(dst)
if err != nil {
return err
}
defer w.Close()
_, err = io.Copy(w, r)
if err != nil {
return err
}
return w.Close()
}
有人统计过,在大型Go项目中,err、if、return、nil这四个词的出现频率超过了func和string的总和。Ten lines of code, four of them are error handling boilerplate——这句话成了Go社区的流行吐槽。
Go开发者调查的结果也印证了这一点:错误处理连续多年位居开发者痛点榜首。即使在泛型问题解决后,它依然是用户抱怨最多的领域。问题是,抱怨归抱怨,怎么改?
2018年:check与handle
第一次严肃的尝试出现在2018年。Russ Cox在Go 2的规划中正式提出了解决错误处理冗余的问题,核心思路来自Marcel van Lohuizen的draft design:引入两个新关键字check和handle。
func CopyFile(src, dst string) error {
handle err { return err }
r := check os.Open(src)
defer r.Close()
w := check os.Create(dst)
defer func() {
w.Close()
if err != nil {
os.Remove(dst)
}
}()
check io.Copy(w, r)
check w.Close()
return nil
}
check会自动检查最后一个返回值是否为nil,如果不是则触发错误处理器。handle则定义了错误处理的逻辑,可以被defer语句访问。这个设计确实消除了大量重复的if语句,但社区的反应却相当复杂。
批评者指出,handle的语义过于复杂。它像一个隐藏在暗处的幽灵,你不知道它何时被触发,也不知道它的作用域边界在哪里。更麻烦的是,handle与Go已有的defer机制存在功能重叠——两者都可以在函数返回时执行代码,这让控制流变得难以推理。
Go团队最终没有推进这个提案。他们的结论是:太复杂了。
2019年:try的内测与翻车
吸取了check/handle的教训,Go团队在2019年提出了一个大幅简化的方案:try。
try是一个内置函数(built-in function),不是新关键字,这保证了向后兼容性。它的语义极其简单:
// Before
f, err := os.Open(filename)
if err != nil {
return err
}
// After
f := try(os.Open(filename))
如果os.Open返回非nil的error,try会自动把这个错误赋值给外层函数的error返回值,然后立即return。一行代码搞定四行样板。
这个提案看起来简洁优雅,但它踩中了一个致命的雷区:隐式控制流。
Go语言从诞生之初就有一条铁律:控制流必须是显式的。你不会在一个表达式的中间突然跳到函数末尾,除非你明确写了return或panic。但try打破了这条规则——它看起来只是一个普通的函数调用,却能在幕后悄悄改变程序的执行路径。
争议迅速爆发。在GitHub issue上,讨论帖接近900条评论。反对者的核心论点是:
- 可读性灾难:在复杂表达式中,
try可能嵌套多层,读者完全无法判断哪些调用可能触发提前返回。 - 调试困难:如果你想在某次错误返回前打印日志或设置断点,现在你必须把代码改回显式的if语句。
- 与错误包装冲突:如果你想给返回的错误添加上下文信息,
try本身不提供这个能力,你还得用defer来处理,反而更复杂。
Robert Griesemer在最终关闭提案时写道:“We have obviously underestimated the possible reaction towards it."(我们显然低估了社区对这个提案的反应。)
2024-2025年:向Rust取经
try提案失败后,Go团队沉默了几年。但开发者调查的数据没有变——错误处理依然是头号痛点。于是,Ian Lance Taylor在2024年提出了新的方案:借用Rust的?操作符。
Rust的?是出了名的优雅:
fn read_username_from_file() -> Result<String, io::Error> {
let mut username = String::new();
File::open("hello.txt")?.read_to_string(&mut username)?;
Ok(username)
}
?会在遇到错误时提前返回,但它有一个关键特性:它是一个后缀操作符,明确标记在可能失败的表达式后面。这让控制流的变化一目了然。
Go的版本长这样:
func printSum(a, b string) error {
x := strconv.Atoi(a) ?
y := strconv.Atoi(b) ?
fmt.Println("result:", x + y)
return nil
}
Go团队做了一些非正式的用户研究,发现大多数人在看到?时都能正确猜到它的含义——Rust的普及功不可没。但问题在于,Go和Rust是两门设计哲学截然不同的语言。
Go有一条重要的设计原则:不要为同一件事提供多种方式(Do not provide multiple ways of doing the same thing)。如果引入?,那么if err != nil和?就变成了两种并存的错误处理方式。新代码用什么?旧代码要不要改?代码审查时怎么统一标准?
更深层的问题是,Rust的?能优雅运作,是因为Rust有Result<T, E>类型和强大的类型系统支撑。Go没有sum types,没有泛型错误类型,?能做的事情实际上相当有限。
这个提案的结局和前两次一样:没有达成共识,最终被放弃。
为什么每一次都失败?
回顾三次尝试,它们的失败原因看似各不相同,但深挖下去,都指向同一个问题:Go的设计哲学与"简洁的错误处理语法"存在根本性冲突。
冲突一:显式 vs 隐式
Go的灵魂是"显式”。你看到什么,代码就做什么。if err != nil虽然冗长,但它的语义完全透明——任何人,哪怕是Go新手,都能一眼看懂。
而try、check、?这些方案,本质上都是在用"隐式"换取"简洁"。它们把错误检查的逻辑藏到了语法糖背后。Go老用户对此有一种本能的抗拒:这是在背叛Go的核心价值观。
冲突二:统一 vs 多样
Go的成功很大程度上归功于它的"固执"。一门语言,一种风格,大家都这么写,阅读别人的代码就不费劲。这种统一性在大团队协作中价值巨大。
但如果引入新的错误处理语法,这种统一就会破裂。代码库中会出现两种风格并存的混乱局面,代码审查和风格指南都会变得更加复杂。Go团队不想冒这个险。
冲突三:简单 vs 强大
这里有一个悖论:如果错误处理语法足够简单,它就不够强大;如果足够强大,它就不够简单。
try够简单,但它不能处理错误包装、不能添加上下文、不能区分不同类型的错误。check/handle够强大,但它的语义复杂到连Go团队自己都觉得难以维护。
Rust走了另一条路:它用强大的类型系统解决了这个问题。Result<T, E>是泛型,?可以自动进行类型转换,你还可以用map_err等方法定制错误处理逻辑。但这一切的前提是Rust有一个比Go复杂得多的类型系统——而Go的设计者从一开始就决定不要这种复杂度。
其他语言的答案
在编程语言的世界里,错误处理从来不是新问题。不同的语言给出了不同的答案,每个答案背后都是一套完整的设计哲学。
Java选择了checked exceptions:编译器强制你处理每个可能抛出的异常,用throws关键字声明它们。这个设计在理论上很美好,但实际效果是——开发者为了省事,要么写空的catch块,要么把所有异常都包装成RuntimeException抛出去。checked exceptions最终成了Java最受争议的特性之一。
Python和Ruby选择了unchecked exceptions:异常可以随时抛出,你可以选择捕获也可以选择忽略。这种方式写代码很爽,但调试时是噩梦——你永远不知道一个函数调用背后藏着多少可能炸掉的地方。
Haskell和OCaml选择了monad:Maybe和Either类型配合do-notation,实现了优雅的错误传播。但monad的概念门槛太高,大多数程序员看到这个词就头疼。
Rust选择了Result类型和?操作符:在简洁性和安全性之间找到了一个不错的平衡点。但它的代价是一个相对复杂的类型系统,以及较慢的编译速度。
Go选择了最朴素的方式:错误作为普通值返回,用显式的if语句检查。这种方式任何人都能理解,但它牺牲了代码的简洁性。
没有完美答案。每个选择都是在取舍,每个取舍都对应一种价值观。
2025年的终局
在2025年的那篇博客文章中,Go团队给出了最终的判决:
“After so many years of trying, with three full-fledged proposals by the Go team and literally hundreds (!) of community proposals, most of them variations on a theme, all of which failed to attract sufficient (let alone overwhelming) support, the question we now face is: how to proceed? Should we proceed at all?”
“We think not.”
他们列举了维持现状的理由:
- Go已经存在了15年,现在的错误处理方式虽然冗长,但它是"好用的"。如果当初设计时就加入了语法糖,今天没人会抱怨;但现在改变,只会让一部分人高兴,让另一部分人不满。
- 不添加新语法符合Go的设计原则:不要提供多种方式做同一件事。
- 在真正复杂的错误处理场景中,样板代码的比例并没有那么高——因为你需要添加上下文、包装错误、记录日志,这些
if err != nil只是其中的小部分。 - IDE和AI工具的进步让写样板代码变得不那么痛苦。
还有一个有趣的观察:Go团队在Google Cloud Next 2025上询问现场的Go用户,每个人都表示不希望改变错误处理语法。这和GitHub上的讨论形成了鲜明对比——在线上发声的大多是新用户或不满者,而真正把Go用得顺风顺水的老用户,反而喜欢现状的稳定。
这不是技术问题
Go错误处理争议的本质,不是"哪个方案更好"的技术问题,而是"我们更在乎什么"的价值观问题。
想简洁?用try或?,但接受隐式控制流。想统一?维持现状,但忍受代码膨胀。想强大?引入类型系统,但接受语言复杂度上升。
你可以拥有两个,但不能拥有三个。
Go选择了"显式"和"统一",牺牲了"简洁"。这个选择在2007年做出,在2025年被再次确认。它不是一个错误,而是一个设计决策——一个很多人不喜欢,但足够多的人接受,以至于能够支撑一个庞大生态的决策。
编程语言设计从来不是寻找最优解,而是在无数约束条件下寻找一个足够好的妥协。Go的错误处理,就是这种妥协的极致体现:它不漂亮,但它工作;它有缺陷,但这个缺陷在大多数场景下可以接受。
十年争议,最终没有赢家。或者换个角度看,Go本身才是赢家——它坚持了自己的设计哲学,没有被社区的压力带着走。在一个用户导向的时代,这种固执反而是稀缺品。
参考
- Griesemer, R. (2025). [On | No] syntactic support for error handling. The Go Blog.
- Pike, R. (2015). Errors are values. The Go Blog.
- Go Team. (2018). Go 2 Draft Designs: Error Handling.
- Griesemer, R. (2019). Proposal: A built-in Go error check function, “try”. GitHub Issue #32437.
- Taylor, I. L. (2024). proposal: spec: reduce error handling boilerplate using “?”. GitHub Issue #71203.
- Go Team. (2025). Results from the 2025 Go Developer Survey. The Go Blog.
- Rust Documentation. The ? operator for error propagation. The Rust Programming Language.
- Go Team. Frequently Asked Questions (FAQ). go.dev.
- Cheney, D. (2011). Error handling and Go. The Go Blog.
- Sean K. H. Liao. (2020). Go error handling proposals. seankhliao.com.