bigywater.tech
Go · 35

errors.Join:多个错误合并后 Is 与 Error 字符串的行为

errors.Join 把多个错误合成一个,常用在“收集所有失败而不是第一个失败”的场景。这篇用真实输出验证它的文字格式、Is 与 As 的查找顺序、与 Errorf 多个 %w 的区别、nil 的处理,以及成本。

换行
Join 的 Error() 用换行把各个错误的文字连起来
深度优先
errors.As 在 Join 树里取到的是按顺序深度优先的第一个匹配
1 次分配
errors.Join 本身的分配次数(无论 2 个还是 10 个)

基本行为

实测 · 本机执行输出
  errors.Join(ErrA, ErrB):类型 *errors.joinError
  Error() = "A 出错\nB 出错"(用换行连接)
  errors.Is(j, ErrA) = true;errors.Is(j, ErrB) = true;errors.Is(j, io.EOF) = false
  errors.Unwrap(j) = <nil>(Join 的 Unwrap 返回 []error,errors.Unwrap 只处理单个)
  j.(interface{ Unwrap() []error }) 返回 2 个错误
  errors.Join(nil, nil) == nil → true;errors.Join(nil, ErrA, nil).Error() = "A 出错"
  errors.Join(ErrA) 只有一个错误:仍然是 *errors.joinError,== ErrA → false;Is → true
  %v = "A 出错\nB 出错";%q = "\"A 出错\\nB 出错\"";%+v = "A 出错\nB 出错"

Join 丢掉 nil,全是 nil 时返回 nil(所以收集错误的切片为空时可以直接 return errors.Join(errs...));只有一个错误也仍然包成 *errors.joinError,与原错误不相等,但 errors.Is 能找到。它的 Unwrap 返回 []error,所以 errors.Unwrap 对它得到 nil——遍历要用 Is、As,或自己断言那个接口。

查找顺序

实测 · 本机执行输出
  Join(&CodeErr{1}, 包装的 &CodeErr{2}, &CodeErr{3}):errors.As → true,取到 Code = 1
  Join(包装的 &CodeErr{2}, &CodeErr{3}):取到 Code = 2(深度优先:先走完第一个分支)
  嵌套 Join(Join(ErrA, io.EOF), ErrB):Is(io.EOF) = true;Error() = "A 出错\nEOF\nB 出错"
  对比 fmt.Errorf("%w; %w"):类型 *fmt.wrapErrors;Error() = "多个原因: A 出错; B 出错";Is(ErrA) = true;Is(ErrB) = true

errors.As 与 errors.Is 把 Join 与多个 %w 看成一棵树,先序深度优先:先把第一个子错误的整条分支走完再看第二个。因此 As 取到的是“按顺序最靠前的匹配”。Errorf 里的两个 %w 查找行为相同,区别只在文字格式:Join 是换行,Errorf 是你自己写的格式串。

收集错误

实测 · 本机执行输出
  收集到 2 个错误,Join 之后:
    第 0 个文件: open /no/a.txt: no such file or directory
    第 2 个文件: open /no/b.txt: no such file or directory
  errors.Is(all, fs.ErrNotExist) = true
  errors.Join() 没有参数:== nil → true;errors.Join(errs[:0]...) == nil → true

循环里把每次失败包装(带上下文,如文件序号)后追加到切片,最后 errors.Join,既保留了所有信息,errors.Is 也能判断“其中有没有某一类错误”。要注意多行的错误文字放进日志与响应时的格式——换行会让日志变成多行。

成本

实测 · 本机执行输出
  errors.Join 2 个错误:AllocsPerRun 1 次;Error() 的 AllocsPerRun 4 次
  errors.Join 10 个错误:AllocsPerRun 1 次;Error() 的 AllocsPerRun 6 次
  errors.Is(Join(ErrA, ErrB), ErrB)    中位 17.44 ns/op(最小 17.32 / 最大 17.71)
  errors.Join(ErrA, ErrB)              中位 14.54 ns/op(最小 13.82 / 最大 16.23)

Join 自己只分配一次(保存子错误的切片,没有拷贝文字);真正分配多的是调用 Error() 拼字符串(4 到 6 次)。所以热路径上不要频繁 Join 之后又立刻 Error()。

分步演示

errors.Join
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。所有输出来自同一个程序的真实运行;基准测试 7 轮取中位数;文件错误来自打开 /no/a.txt 与 /no/b.txt(不存在)
    • 没有验证:并发 goroutine 里收集错误的安全写法(见 errgroup 一篇)、Join 与 slog 等日志库的多行格式、错误数量很大时的内存占用、自定义 Unwrap() []error 类型在树里的遍历顺序以外的行为
    京ICP备2026067502号-1京公网安备11010502063487号