0%

Go 错误处理演进:从 1.0 到 1.26

Go 错误处理演进:从 1.0 到 1.26

if err != nil 是 Go 最常被诟病的一段样板代码。但很少有人注意到,它恰恰是官方历经七年、三轮正式提案之后,仍然决定保留的部分。

Go 错误处理的十四年演进,内核始终没变:错误是值。生长只发生在两个维度,一是错误能承载多少信息(表达侧),二是调用方如何检视错误(检视侧)。

一、历史地图

阶段 版本 / 年份 核心能力 维度
内核 Go 1.0 / 2012 error 接口
哨兵错误 1.0 起 errors.New== 比身份 检视侧
自定义类型 很早 实现 Error(),用类型断言取字段 表达侧
错误包裹 1.13 / 2019 %werrors.Iserrors.As 表达加检视
多错误聚合 1.20 / 2023 errors.Join、多个 %w 表达侧
泛型检视 1.26 / 2026 errors.AsType[E] 检视侧

二、不变的内核:错误是值

Java、Python、C++ 用异常表达错误:throw 抛出,try/catch 捕获,本质是一条平行的控制流。函数可能在看不见的地方突然中断,沿调用栈一路回退,直到某个 catch 接住。错误处理在那里是一套独立于正常逻辑之外的语法机制。

Go 做了相反的选择:错误不是控制流,是数据。一个错误就是函数正常返回值之一,通常是最后一个,用最普通的 if 去判断,不需要任何特殊语法。所谓”错误是值”,最朴素的含义就是 Go 没有为错误处理新增机制,而是用语言里本就存在的”值”把错误表达出来。

起点是一个极简的接口:

1
2
3
type error interface {
Error() string
}

任何实现了 Error() string 的类型都是 error,没有特殊关键字,没有特殊基类。于是错误处理退化成三件普通的事:传、存、比。

1
2
3
4
5
f, err := os.Open("config.yaml")
if err != nil {
return err // 它就是个值,可以直接往上传
}
defer f.Close()

err 是值,所以能对它做任何对值能做的事:存进结构体、塞进 channel、放进 slice、作为参数传递、和别的错误比较、包装出新错误。它没有任何”魔法”属性。

这样设计有三个理由。

其一,控制流完全可见。 异常的代价是隐蔽,看一个调用无法判断它会不会抛、从哪抛、被谁接。if err != nil 虽然啰嗦,但每个可能失败的点都明明白白写在代码里。Go 用冗长换来了诚实。

其二,错误可以被”编程”。 这是 Rob Pike 那篇《Errors are values》真正想说的,比口号本身更重要。值能被组合、抽象、传递,所以错误也能。被重复的 if err != nil 困扰时,往往不是语言的问题,是还没把错误当成值来设计。经典例子是把错误”吸收”进结构体,让中间步骤不必逐个判断:

1
2
3
4
type errWriter struct {
w io.Writer
err error
}

其三,与 panic/recover 划清边界。 Go 并非没有类似异常的机制,但 panic 被刻意限定在”真正不该发生”的场景:数组越界、空指针、程序员逻辑错误这类不可恢复的状况。可预期的失败,比如文件不存在、网络超时、参数非法,一律走 error 返回值。异常用于 bug,返回值用于业务上预期内的失败,把两者混为一谈正是很多语言里错误处理变乱的根源。

后面所有演进都在追问同一个问题:这个”值”能不能更聪明一点。但它始终是个值,整条演进路径没有动过任何语法,全部是在这个地基上用普通库代码搭出来的。

三、外围生长的五个阶段

阶段一·哨兵错误(检视侧)

哨兵错误(sentinel error)指预先定义好的、可导出的错误变量,调用方通过判断返回的 error 是不是这个特定变量,来识别发生了哪种错误。这个词来自更一般的编程概念 sentinel value(哨兵值),即用一个特殊的、预先约定的值代表某种情况,比如用 -1 表示”没找到”。

1
2
3
var ErrNotFound = errors.New("not found")

if err == ErrNotFound { ... }

标准库例子:io.EOFsql.ErrNoRowsos.ErrNotExistcontext.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
2
3
4
5
6
7
8
9
type PathError struct {
Op string
Path string
Err error
}

func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}

检视方式从比身份变成断言类型(type assertion / type switch):

1
2
3
if pe, ok := err.(*PathError); ok {
fmt.Println("出问题的路径是:", pe.Path)
}

标准库例子:fs.PathErroros.PathError 自 Go 1.16 起已是它的别名,官方标注 Deprecated,新代码写 fs.PathError)、net.OpError

遗留两个新麻烦:检视方式不统一,哨兵用 ==、自定义类型用类型断言,写法散乱;一旦错误被外层包裹,类型断言立刻失效。

阶段三·错误包裹(表达加检视,分水岭)

需求是既想层层添加上下文,又不想丢掉底层错误的身份。Go 1.13 一次引入了三个配套能力:

1
2
3
4
5
6
7
8
9
// 包裹:%w 把底层错误接进新错误,保留可追溯链条
err := fmt.Errorf("读取配置失败: %w", io.EOF)

// 检视身份:errors.Is 顺链找哨兵
if errors.Is(err, io.EOF) { ... }

// 检视类型:errors.As 顺链取出具体类型
var pe *fs.PathError
if errors.As(err, &pe) { ... }

%w 会生成一个全新的外层对象,类型和地址都跟里面的哨兵不同。上面那个 err 的具体类型不再是 io.EOF*errorString,而是 fmt 内部的 *fmt.wrapError,结构大致是:

1
2
3
4
5
// 示意
wrapError{
msg: "读取配置失败: EOF", // 拼好的新消息
err: io.EOF, // 被包住的原错误,藏在字段里
}

于是错误从一个单点变成了一条链:

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.Iserrors.As 顺链遍历。检视方式也在此统一:判哨兵一律 errors.Is,取类型一律 errors.As,不再裸 == 或裸断言。

注意 %w%v 的区别:%w 保留链条,%v 只拼字符串、切断链条。

阶段四·多错误聚合(表达侧)

Go 1.13 的链是单父的,一个错误只能包一个错误。现实里常一次蹦出多个独立错误,比如批量校验失败、清理多个资源都失败。Go 1.20 把链扩展成树:

1
2
3
4
5
// 个数动态
err := errors.Join(err1, err2, err3)

// 个数固定,fmt.Errorf 也支持多个 %w
err = fmt.Errorf("校验失败: %w, %w", errA, errB)

errors.Iserrors.As 会自动遍历整棵树。

多错误不是高频需求,但它是线性链在结构上覆盖不到的那一类失败的唯一正确表达。用得不多,一旦用到,没有它就只能在丢信息和报假错之间二选一。

心智模型转变:错误从链(单父)变成树(多父)。

阶段五·泛型化检视(检视侧)

这一版不再是”带更多信息”,而是回头优化检视写法。errors.AsTypeerrors.As 的泛型版本:

1
2
3
4
5
6
7
8
9
10
// Go 1.13 老写法:先声明空指针变量,纯为取地址
var pe *fs.PathError
if errors.As(err, &pe) {
fmt.Println(pe.Path)
}

// Go 1.26 新写法:类型写进调用,直接返回
if pe, ok := errors.AsType[*fs.PathError](err); ok {
fmt.Println(pe.Path)
}

两点好处:一是避免反射、减少分配;二是消除 errors.As 的运行时陷阱,传非指针、传未实现 error 的类型会 panic,泛型版在编译期就挡掉。

有一个边界要说清:errors.AsType[E error] 的类型参数受 error 约束,所以它不能用于纯行为接口。像 interface{ Timeout() bool } 这种接口并不实现 error,编译期就通不过,这类场景仍然要用 errors.AsAsType 是对 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 把哨兵包进一条带上下文的链里,哨兵留作可判定的身份,上下文负责可读的细节。

在此之上,九条可直接执行的规则:

  1. 检视统一走 errors 包。 判哨兵用 errors.Is(err, ErrXxx);提取具体类型,新代码首选 errors.AsType[*T](err),老代码 errors.As 仍合法。确知错误不会被包装时 == 仍然合法,但推荐统一用 errors.Is,理由是可读性和一致性,而不是性能,errors.Is 在未包装错误上只多一次比较,开销可忽略。
  2. 加上下文用 fmt.Errorf("...: %w", err) 只在确定无人需要检视这个错误、且不希望暴露底层链条时才用 %v
  3. 一次多个错误用 errors.Join(...) 或多个 %w 个数动态用前者,个数固定用后者。
  4. 跨公共边界反而要切断链条。 对外暴露的错误不要直接 %w 包底层错误,否则实现细节被纳入公开契约,调用方能反查到数据库表名、内网地址等信息。
  5. 第三种契约是行为判定。 除哨兵(是哪一类)和自定义类型(读字段)外,还可以只问行为特征,用 errors.As 配接口指针,如 var to interface{ Timeout() bool }。适合”我只关心能不能重试”的场景。前面说过,这类接口不实现 error,用不了 AsType
  6. 函数返回类型永远写 error,不写具体错误类型。 func Do() *XError 返回 nil 时,调用方的 if err != nil 仍然成立,这是典型的类型化 nil 陷阱。
  7. 错误契约写进函数文档注释,并配断言测试。 返回哪些哨兵、哪些类型可以提取、什么条件下返回,不写就等于没有契约。每个哨兵和类型契约配一条 errors.Is / errors.AsType 的测试:%w 误写成 %v 时错误消息文本一模一样,日志上看不出来,只有这条测试能发现。
  8. 判空只用 if err != nil errors.Is(nil, nil) 为 true,errors.As(nil, ...) 为 false,不要拿它们判空。
  9. 面对 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