《Go 语言原本》

7.5 错误处理的未来

前几节里我们见过 error 接口的朴素、%w 包装与错误链的检视。读者读到这里,心里多半积着 一个没有说出口的疑问:if err != nil 这三行模板,年复一年地铺满每个 Go 程序,难道就没有 办法收掉吗?这个疑问并不孤单。从 2018 年的 go2 草案到 2025 年的一纸结论,Go 团队为它前后 推敲了七年,提出过 check/handletry? 等多套语法,又把它们一一收回。本节把这条 没有走通的语法之路,与另一条悄然走通的库演进之路,并排摆出来,并交代 Go 团队最终给出的 判断:在可见的将来,错误处理不会迎来专门的语法。

理解这个结局,比记住任何一套被否决的语法都重要。它不是搁置,而是一次有据可依的「不做」, 背后是 Go 对「显式优于简洁」的一次郑重重申。

7.5.1 被否决的语法尝试

check / handle:2018 年的 go2 草案

错误处理语法化的第一次正式尝试,是 2018 年随「Go 2」蓝图公布的 check/handle 草案。 它引入两个关键字:check 是一个表达式,对返回 (T, error) 的调用求值,错误为 nil 时 取出 T,否则把控制权交给最近的 handle 块;handle 是一个语句,声明当前函数里检查 失败时的善后逻辑。一个典型的文件拷贝在草案下写成这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func CopyFile(src, dst string) error {
	handle err {
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}

	r := check os.Open(src)     // 失败则跳到上面的 handle
	defer r.Close()

	w := check os.Create(dst)
	handle err {                 // handle 可叠加,后声明的先生效
		w.Close()
		os.Remove(dst)
	}

	check io.Copy(w, r)
	check w.Close()
	return nil
}

它的野心在 handle 链:多个 handle 按声明逆序生效,让人联想到 defer 的栈式语义,能把 「出错时回滚」的清理逻辑沿调用顺序层层铺开。代价也正在于此。读者要读懂某一行 check 失败后会发生什么,得在脑子里维护一个 handle 栈,与 defer 栈彼此独立又彼此纠缠。社区 普遍反馈它「为省下 if 而引入了一套新的、需要单独学习的控制流」,复杂度未必比它消除的更低。 草案最终被判定过于复杂而未进入提案阶段。

try():2019 年那个撤回的内建函数

吸取 check/handle 太重的教训,Robert Griesemer 在 2019 年 6 月提出了走向另一极的方案, 提案 32437:一个名为 try 的内建函数。它的 签名可以这样理解:接受一个返回 (T1, ..., Tn, error) 的表达式,错误为 nil 时返回前 $n$ 个 值,否则用这个错误从所在函数提前返回。于是 CopyFile 的开头一段缩成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// try 之前:四行模板换装错误后向上抛
r, err := os.Open(src)
if err != nil {
	return fmt.Errorf("copy %s %s: %v", src, dst, err)
}

// try 之后:一行,错误为 nil 取值,否则 return
func CopyFile(src, dst string) error {
	r := try(os.Open(src))     // err != nil 时,CopyFile 立即 return err
	defer r.Close()

	w := try(os.Create(dst))
	defer w.Close()
	try(io.Copy(w, r))
	return nil
}

try 不引入新关键字、不引入新控制结构,只是一个内建函数,语法面看上去极克制。错误的 装饰则交给 defer 配合具名返回值去做。提案一出,社区的讨论旋即升温到 Go 历史上罕见的规模, 反对的核心只有一句:tryreturn 藏进了一个看起来像普通函数调用的表达式里。

这正是 Go 一贯所忌讳的,隐藏的控制流x := try(f()) 这一行可能让函数就此返回,而它 读起来和 x := len(f()) 没有区别。在一门把「读代码就能看出控制流走向」奉为信条的语言里, 这是难以接受的退让。提案还带来一连串次生问题:try 难以用在测试里(期望 t.Fatal 而非 return),对错误的装饰被迫绕道 defer,调试时断点也难以落在那条隐式的返回上。同年, 提案被作者主动撤回,理由正是社区在控制流可读性上的强烈分歧。

更轻的 if-err 与 ? 提案

try 撤回之后,社区并未死心,转而探索更轻的形态。最有代表性的是 2024 年的 ? 运算符 提案,它把 Rust 的 ? 几乎照搬过来:

1
r := os.Open(src)?     // 等价于 if err != nil { return ..., err }

后缀一个 ?,错误非空就携错返回。它比 try 更轻,连函数调用的外壳都省了,却也把 「隐藏控制流」的问题推到了极致:一个标点就能让函数返回。还有诸如以 || 兜底 (f() || return err)等变体,思路大同小异,都在「少写几个字符」与「让返回显形」之间 反复拉扯。它们都未能聚拢足够的支持。

七年里被认真对待又被放下的方案,可以列成一条清晰的时间线:

年份方案形态结局
2018check / handle关键字 + handle 栈过于复杂,未立案
2019try(...)内建函数,隐式返回隐藏控制流,作者撤回
2024expr?后缀运算符支持不足
2025(全部)错误处理语法决定不再追求

7.5.2 2025:决定不再追求专门语法

2025 年 6 月 3 日,Griesemer 代表 Go 团队发文,给这条路画上句号:在可见的将来,Go 不再 追求错误处理的语法变更,并将关闭现有及新到的、以错误处理语法为主旨的提案,不再逐一深究。 这不是一次拖延,而是一次有论据的结论。文中给出的理由值得逐条体会:

  • 十五年、数百份提案,始终没有共识。 连 Go 团队内部对该走哪条路都不能取得一致。一项 语言级改动,若连设计者都说服不了彼此,便没有落地的资格。
  • 语言改动是强制的,库改动是可选的。 这一条最关键。泛型(8)落地后, 不想用的人可以照旧不用;但一旦给错误处理加了新语法,几乎所有人都被迫去读、去学、去在 两种写法间做选择。Go 的设计准则之一是「不提供做同一件事的多种方式」,新语法恰恰会 打破它。
  • 更好的错误处理来自上下文,而非更短的语法。 团队反复强调,真正有价值的改进是给错误 补充上下文信息(错在哪、为何错),而不是把 if err != nil 压成一行。后者省的是打字, 前者省的是排障时间。
  • 实现与维护的成本高昂。 新语法要贯穿编译器、文档、gofmtgo vet、IDE 等整条工具链, 而 Go 团队规模不大,优先级有限。
  • 显式的 if err != nil 更利于调试。 一条独立的语句,可下断点、可插日志、可单步, 这些都是隐式返回所没有的。

文章给出的替代方向,没有一条是语法:用辅助函数补充错误上下文,往标准库添加像 cmp.Or 这样的小工具,以及借助 IDE 的代码补全与「折叠样板」功能,在显示层而非语言层缓解视觉噪音。

7.5.3 真正落地的,是库而非语言

把镜头从「没走通的语法」转向「走通了的演进」,会看到一条截然不同、却始终热闹的主线。 错误处理这些年所有真正落地的改进,无一例外发生在库一级,对语言文法毫发无伤:

  • Go 1.13(2019):%w 包装与错误链。 fmt.Errorf%w 动词让一个错误「包住」 另一个错误,配合 errors.Iserrors.Aserrors.Unwrap 形成可检视的错误链。这是 pkg/errors7.2)多年实践被吸收进标准库的成果,全部是 errorsfmt 两个包的事,编译器一行未动。

  • Go 1.20(2023):errors.Join 与多重 %w Join 把若干并列的错误合成一个, 通过 Unwrap() []error 暴露给 Is/As 检视;同一版本里 fmt.Errorf 也允许一次写多个 %w。错误链由此从一条线扩展为一棵树。

    1
    2
    
    err := errors.Join(errClose, errFlush) // 两个独立错误合成一个
    if errors.Is(err, errFlush) { /* 仍可逐一检视 */ }
    
  • Go 1.21(2023):log/slog 结构化日志进入标准库,错误从此能作为带键的结构化字段 (slog.Any("err", err))记录,与上下文一同输出,呼应了团队「错误处理重在上下文」的判断。 它同样是一个新包,而非新语法。

  • Go 1.26(2025):errors.AsType 泛型版。 签名 func AsType[E error](err error) (E, bool) 借泛型(8)取代了 As 那个需要传指针的别扭接口:

    1
    2
    3
    
    if pe, ok := errors.AsType[*fs.PathError](err); ok {
        fmt.Println("失败路径:", pe.Path)
    }
    

    值得玩味的是,它恰好印证了 2025 决定里的那条逻辑:泛型这种可选的语言能力一旦具备, 错误处理便能在库一级顺势改善,而无需任何错误处理专属的语法。

这条对照给出的结论很直白:语言层屡攻不下,库层却节节推进。原因不难理解,库改动是加法, 旧代码原样可用,新工具按需取用;语法改动是乘法,一旦引入便乘到每一份源码、每一个读者 头上。Go 在错误处理上的真实策略,是把所有能放进库的改进都放进库,把语言文法守得纹丝不动。

语法之路虽止,开放的余地仍在,只是都落在语言之外。2025 决定里点名的几个方向值得留意: 其一是工具链层面的「样板折叠」,让 IDE 在显示时把成段的 if err != nil 折起或淡化, 源码不变而观感变;其二是错误上下文的标准化,社区仍在讨论 errors.Join 之上更结构化的 诊断信息(如错误所携带的字段、调用栈、可机读的分类),这是把 pkg/errors7.2)的堆栈思路继续库化的延长线。这些都不触碰文法,却可能在未来几年里 继续改善错误处理的实际体验。换言之,故事没有结束,只是确定不会在语法这一章里续写。

7.5.4 稳定的哲学:显式优于简洁

退一步看这七年,会发现 Go 团队并非守旧,而是一次次把「显式」放在了「简洁」之前,而且 每一次都讲清了为什么。check/handle 败在它用复杂换简洁,try? 败在它们用隐式 控制流换简洁。被反复申辩的那条底线始终是:读一段 Go 代码,控制流应当一目了然,错误 不该在某个不起眼的表达式里悄悄改变函数的走向。

把 Go 放回语言谱系里看,对照尤其清楚。把错误处理交给语法,大致有两条路。一条是异常, C++、Java、Python 走的路:错误沿调用栈隐式上抛,正常路径写得干净,代价是控制流在 throwcatch 之间跳跃,难以从局部代码看清。Go 从一开始就拒绝了异常。另一条是 「值的语法糖」,Rust 的 ? 是其代表,错误仍是值(Result),只是用一个后缀标点省去 手写的分发。? 正是 try 想要的东西,而 Rust 选择了它,因为 Rust 本就接受「表达式 可以隐式改变控制流」(?match 都是表达式)。Go 看着同一个设计,选择了不要,这并非 没看见,而是看见了仍不取。两种语言在同一个十字路口走向相反,背后是对「控制流是否应当 始终显形」这件事不同的信仰。把错误当作普通返回值、用普通控制流处理,是 Go 自诞生起就 立下的「错误即值」立场,2025 年的决定,不过是把这个立场又一次明明白白地写了下来。

性能与简洁从不白来。Go 用满屏的 if err != nil 换来的,是任何人翻开任何一段 Go 代码, 都能不依赖语言魔法地看清错误流向何处。这笔交易未必人人乐意,但它是被反复权衡、被公开 辩护的一次清醒选择,而非疏忽。错误处理的未来,大概率就是它现在的样子:语法不变, 库继续生长。

延伸阅读的文献

  1. Robert Griesemer. Error Syntax: A Retrospective and Decision. The Go Blog, 2025-06-03. https://go.dev/blog/error-syntax (Go 团队决定不再追求错误处理语法的正式声明与理由)
  2. Robert Griesemer, et al. Proposal: A built-in Go error check function, “try”. 2019, issue 32437(已撤回). https://github.com/golang/go/issues/32437
  3. The Go Authors. Go 2 Draft: Error Handling Overview(check/handle). 2018. https://go.googlesource.com/proposal/+/master/design/go2draft-error-handling-overview
  4. Russ Cox. Go 2, Here We Come!(Toward Go 2 系列). The Go Blog, 2018-11-29. https://go.dev/blog/go2-here-we-come
  5. The Go Authors. Go 1.20 Release Notes(errors.Join 与多重 %w). 2023. https://go.dev/doc/go1.20#errors
  6. The Go Authors. Package errors(含 1.26 引入的泛型 AsType). https://pkg.go.dev/errors
  7. The Go Authors. Working with Errors in Go 1.13(%w 包装与错误链). The Go Blog, 2019. https://go.dev/blog/go1.13-errors

许可

© 2018-2026 The golang.design Initiative Authors. Licensed under CC-BY-NC-ND 4.0.