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