Go 错误处理演进:从 1.0 到 1.26
if err != nil 是 Go 最常被诟病的一段样板代码。但很少有人注意到,它恰恰是官方历经七年、三轮正式提案之后,仍然决定保留的部分。
Go 错误处理的十四年演进,内核始终没变:错误是值。生长只发生在两个维度,一是错误能承载多少信息(表达侧),二是调用方如何检视错误(检视侧)。
一、历史地图
| 阶段 | 版本 / 年份 | 核心能力 | 维度 |
|---|---|---|---|
| 内核 | Go 1.0 / 2012 | error 接口 |
无 |
| 哨兵错误 | 1.0 起 | errors.New 加 == 比身份 |
检视侧 |
| 自定义类型 | 很早 | 实现 Error(),用类型断言取字段 |
表达侧 |
| 错误包裹 | 1.13 / 2019 | %w、errors.Is、errors.As |
表达加检视 |
| 多错误聚合 | 1.20 / 2023 | errors.Join、多个 %w |
表达侧 |
| 泛型检视 | 1.26 / 2026 | errors.AsType[E] |
检视侧 |
二、不变的内核:错误是值
Java、Python、C++ 用异常表达错误:throw 抛出,try/catch 捕获,本质是一条平行的控制流。函数可能在看不见的地方突然中断,沿调用栈一路回退,直到某个 catch 接住。错误处理在那里是一套独立于正常逻辑之外的语法机制。
Go 做了相反的选择:错误不是控制流,是数据。一个错误就是函数正常返回值之一,通常是最后一个,用最普通的 if 去判断,不需要任何特殊语法。所谓”错误是值”,最朴素的含义就是 Go 没有为错误处理新增机制,而是用语言里本就存在的”值”把错误表达出来。
起点是一个极简的接口:
1 | type error interface { |
任何实现了 Error() string 的类型都是 error,没有特殊关键字,没有特殊基类。于是错误处理退化成三件普通的事:传、存、比。
1 | f, err := os.Open("config.yaml") |
err 是值,所以能对它做任何对值能做的事:存进结构体、塞进 channel、放进 slice、作为参数传递、和别的错误比较、包装出新错误。它没有任何”魔法”属性。
这样设计有三个理由。
其一,控制流完全可见。 异常的代价是隐蔽,看一个调用无法判断它会不会抛、从哪抛、被谁接。if err != nil 虽然啰嗦,但每个可能失败的点都明明白白写在代码里。Go 用冗长换来了诚实。
其二,错误可以被”编程”。 这是 Rob Pike 那篇《Errors are values》真正想说的,比口号本身更重要。值能被组合、抽象、传递,所以错误也能。被重复的 if err != nil 困扰时,往往不是语言的问题,是还没把错误当成值来设计。经典例子是把错误”吸收”进结构体,让中间步骤不必逐个判断:
1 | type errWriter struct { |
其三,与 panic/recover 划清边界。 Go 并非没有类似异常的机制,但 panic 被刻意限定在”真正不该发生”的场景:数组越界、空指针、程序员逻辑错误这类不可恢复的状况。可预期的失败,比如文件不存在、网络超时、参数非法,一律走 error 返回值。异常用于 bug,返回值用于业务上预期内的失败,把两者混为一谈正是很多语言里错误处理变乱的根源。
后面所有演进都在追问同一个问题:这个”值”能不能更聪明一点。但它始终是个值,整条演进路径没有动过任何语法,全部是在这个地基上用普通库代码搭出来的。
三、外围生长的五个阶段
阶段一·哨兵错误(检视侧)
哨兵错误(sentinel error)指预先定义好的、可导出的错误变量,调用方通过判断返回的 error 是不是这个特定变量,来识别发生了哪种错误。这个词来自更一般的编程概念 sentinel value(哨兵值),即用一个特殊的、预先约定的值代表某种情况,比如用 -1 表示”没找到”。
1 | var ErrNotFound = errors.New("not found") |
标准库例子:io.EOF、sql.ErrNoRows、os.ErrNotExist、context.Canceled。
关键点:哨兵比较的是身份(identity),不是内容。 err == io.EOF 成立,不是因为两者错误文字相同,而是因为它们指向同一个对象。
这里有个容易误解的地方:errors.New 并不保证同名错误相等,它保证的恰恰相反,每次调用都返回一个全新的、互不相等的对象。身份唯一性来自哨兵模式只调用它一次,把结果存进一个包级变量:
1 | var ErrNotFound = errors.New("not found") // 包初始化时只执行这一次 |
之后无论谁写 err == ErrNotFound,比的都是同一个变量里那个固定的指针值。如果在两个地方各写一次 errors.New("not found"),它们就是两个不同的哨兵,永远不相等。
配套的设计细节是 errorString 使用指针接收者 func (e *errorString) Error() string。这是故意的,它强制比较走地址身份。若改成值类型按内容比较,两个文案碰巧相同、语义却不同的错误就会意外相等。Go 把错误视为事件实例而非消息文本,指针接收者把”身份”和”内容”分开了。
哨兵的局限有三点:一个哨兵只能携带一句固定的话;带不出”哪个文件、哪一行、什么时候”这类上下文;调用方必须 import 定义包,产生导入耦合。
阶段二·自定义错误类型(表达侧)
让错误变成能装字段的结构体,自己实现 Error():
1 | type PathError struct { |
检视方式从比身份变成断言类型(type assertion / type switch):
1 | if pe, ok := err.(*PathError); ok { |
标准库例子:fs.PathError(os.PathError 自 Go 1.16 起已是它的别名,官方标注 Deprecated,新代码写 fs.PathError)、net.OpError。
遗留两个新麻烦:检视方式不统一,哨兵用 ==、自定义类型用类型断言,写法散乱;一旦错误被外层包裹,类型断言立刻失效。
阶段三·错误包裹(表达加检视,分水岭)
需求是既想层层添加上下文,又不想丢掉底层错误的身份。Go 1.13 一次引入了三个配套能力:
1 | // 包裹:%w 把底层错误接进新错误,保留可追溯链条 |
%w 会生成一个全新的外层对象,类型和地址都跟里面的哨兵不同。上面那个 err 的具体类型不再是 io.EOF 的 *errorString,而是 fmt 内部的 *fmt.wrapError,结构大致是:
1 | // 示意 |
于是错误从一个单点变成了一条链:
1 | *fmt.wrapError{msg: "读取配置失败: EOF", err: io.EOF} --Unwrap()--> io.EOF (*errorString) |
此时 err == io.EOF 的结果是 false。原因是 == 只看链条最顶上那个节点,顶上是 *fmt.wrapError,而 io.EOF 是 *errorString,动态类型就不一样,对象更不是同一个。哨兵被埋在下一层的 err 字段里,== 够不着它;包裹层数越多,埋得越深。
errors.Is 正是为此而生:它不只比顶层,而是顺着链一路往下走。先看 err 自己等不等于目标,不等就调 err.Unwrap() 拿到下一层,再比,循环直到命中或链走完。
心智模型转变:错误从单点变成一条链。 %w 往链上挂节点,errors.Is 和 errors.As 顺链遍历。检视方式也在此统一:判哨兵一律 errors.Is,取类型一律 errors.As,不再裸 == 或裸断言。
注意 %w 与 %v 的区别:%w 保留链条,%v 只拼字符串、切断链条。
阶段四·多错误聚合(表达侧)
Go 1.13 的链是单父的,一个错误只能包一个错误。现实里常一次蹦出多个独立错误,比如批量校验失败、清理多个资源都失败。Go 1.20 把链扩展成树:
1 | // 个数动态 |
errors.Is 和 errors.As 会自动遍历整棵树。
多错误不是高频需求,但它是线性链在结构上覆盖不到的那一类失败的唯一正确表达。用得不多,一旦用到,没有它就只能在丢信息和报假错之间二选一。
心智模型转变:错误从链(单父)变成树(多父)。
阶段五·泛型化检视(检视侧)
这一版不再是”带更多信息”,而是回头优化检视写法。errors.AsType 是 errors.As 的泛型版本:
1 | // Go 1.13 老写法:先声明空指针变量,纯为取地址 |
两点好处:一是避免反射、减少分配;二是消除 errors.As 的运行时陷阱,传非指针、传未实现 error 的类型会 panic,泛型版在编译期就挡掉。
有一个边界要说清:errors.AsType[E error] 的类型参数受 error 约束,所以它不能用于纯行为接口。像 interface{ Timeout() bool } 这种接口并不实现 error,编译期就通不过,这类场景仍然要用 errors.As。AsType 是对 As 的补充,不是全面替代。
官方态度:errors.As 未废弃,但新代码推荐 errors.AsType,工具链(GoLand 等)已经开始提示替换;判哨兵继续用 errors.Is。
四、一次被拒绝的演进:语法糖的七年长征
if err != nil 的重复,是”错误是值”这一设计最常被诟病的代价。
Go 团队从未对这一批评视而不见。相反,他们用七年时间、三轮正式提案试图消除它:2018 年 Go 2 草案提出 check / handle 关键字,2019 年提出 try 内建函数,2024 年借鉴 Rust 提出 ? 后缀运算符。官方方案之外,社区先后涌现数百个变体。
但没有任何一个方案凝聚出足够共识。2025 年 6 月,Go 团队在官方博客中正式表态:在可预见的未来,不再追求错误处理的语法层面改动,并将关闭所有主要涉及错误处理语法的提案。
这一决定的理由触及 Go 的设计底色。语法糖固然省去了 if,却让控制流变得隐晦:错误如何返回、由谁处理,本应是代码中清晰可见的逻辑,退化为语言背后的隐藏机制。在 Go 看来,语言的价值不在于写出最少的代码,而在于写出易读、易调试、运行稳定的代码。
这一取舍确有代价,需要诚实指出。其一,if err != nil 的重复是真实存在的样板负担。其二,还有一个独立的隐患,错误可以被 _ 静默丢弃,编译器并不强制处理它。这正是”错误是值”的另一面:它既能像普通值一样被传递、组合,也就能像普通值一样被无声忽略。Go 团队的立场是显式的啰嗦优于隐蔽的优雅,而 try 提案在社区被否,说明社区在简洁与显式之间同样更倾向后者。理解这个取舍本身,比单纯接受结论更有价值。
既然语法之门已经关上,”错误是值”在未来大概率仍将沿着扩充库、不动语法的路径演进。if err != nil 并非缺陷,而是经反复推敲后被有意保留的设计。
五、当前最佳实践(截至 Go 1.26)
先说一个常见误解:哨兵错误没有被淘汰,被淘汰的是”裸 == 比较哨兵”这个动作,不是哨兵本身。
哨兵和上下文回答的是两个不同的问题。哨兵回答”是哪一类错误”,这是一个需要调用方据此做分支决策的、可被程序判定的信号,io.EOF 就是典型,读到它不是要打印给人看,是要 break 出循环。上下文回答”具体怎么回事”,是给人看的诊断信息:哪个文件、哪个用户、底层是什么。正确做法是两者叠加,用 %w 把哨兵包进一条带上下文的链里,哨兵留作可判定的身份,上下文负责可读的细节。
在此之上,九条可直接执行的规则:
- 检视统一走 errors 包。 判哨兵用
errors.Is(err, ErrXxx);提取具体类型,新代码首选errors.AsType[*T](err),老代码errors.As仍合法。确知错误不会被包装时==仍然合法,但推荐统一用errors.Is,理由是可读性和一致性,而不是性能,errors.Is在未包装错误上只多一次比较,开销可忽略。 - 加上下文用
fmt.Errorf("...: %w", err)。 只在确定无人需要检视这个错误、且不希望暴露底层链条时才用%v。 - 一次多个错误用
errors.Join(...)或多个%w。 个数动态用前者,个数固定用后者。 - 跨公共边界反而要切断链条。 对外暴露的错误不要直接
%w包底层错误,否则实现细节被纳入公开契约,调用方能反查到数据库表名、内网地址等信息。 - 第三种契约是行为判定。 除哨兵(是哪一类)和自定义类型(读字段)外,还可以只问行为特征,用
errors.As配接口指针,如var to interface{ Timeout() bool }。适合”我只关心能不能重试”的场景。前面说过,这类接口不实现error,用不了AsType。 - 函数返回类型永远写
error,不写具体错误类型。func Do() *XError返回 nil 时,调用方的if err != nil仍然成立,这是典型的类型化 nil 陷阱。 - 错误契约写进函数文档注释,并配断言测试。 返回哪些哨兵、哪些类型可以提取、什么条件下返回,不写就等于没有契约。每个哨兵和类型契约配一条
errors.Is/errors.AsType的测试:%w误写成%v时错误消息文本一模一样,日志上看不出来,只有这条测试能发现。 - 判空只用
if err != nil。errors.Is(nil, nil)为 true,errors.As(nil, ...)为 false,不要拿它们判空。 - 面对
if err != nil样板:接受它,不要等语法糖。 日常写法就是fmt.Errorf("上下文: %w", err)往上加一层,身份在底层定义一次,聚合和切断链条是特殊场景才登场。
六、速查表
| 调用方要做什么 | 定义方怎么写 | 调用方怎么写 |
|---|---|---|
| 只记日志,不分支 | fmt.Errorf("ctx: %v", err) |
if err != nil |
| 按类分支 | var ErrX = errors.New("pkg: ..."),返回 fmt.Errorf("ctx: %w", ErrX) |
errors.Is(err, pkg.ErrX) |
| 读字段 | type XError struct{...},返回 &XError{...} |
errors.AsType[*pkg.XError](err) |
| 判行为 | 让错误类型实现 Timeout() bool 之类 |
var to interface{ Timeout() bool },errors.As(err, &to) |
| 多个错误,个数固定 | fmt.Errorf("a: %w; b: %w", e1, e2) |
errors.Is 逐个判 |
| 多个错误,个数动态 | errors.Join(errs...) |
errors.Is 逐个判,或断言 interface{ Unwrap() []error } 遍历 |
第一行用 %v 是有意的:确定这个错误只会被打印、不会被任何调用方检视时,切断链条可以避免实现细节外泄。其余场景一律用 %w。