登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go errors.Join 全是 nil 时为什么返回 nil

来源:17golang原创

时间:2026-10-04 14:44:38 284浏览 收藏

errors.Join 全是 nil 时返回 nil,原因很直接:这个函数会丢弃所有 nil 参数,再统计剩余的非 nil 错误。若计数为 0,就表示没有任何错误需要汇总,因此返回代表“没有错误”的 nil,而不是创建一个内容为空但本身非空的聚合错误。

这项约定让调用方仍然可以使用最普通的 if err != nil 判断。官方文档明确写明:Join 丢弃 nil,当所有输入都是 nil 时返回 nil。可核对 errors 标准库文档 与 join.go 源码。

先看四类输入的结果矩阵

这里不需要用纳秒来衡量,决定返回值的核心指标只有一个:输入中非 nil 错误的数量 n。

调用非 nil 数量 n返回值判断结果
errors.Join()0nilerr == nil
errors.Join(nil, nil)0nilerr == nil
errors.Join(errA, nil)1非 nil 的聚合错误errors.Is(err, errA)
errors.Join(errA, errB)2非 nil 的聚合错误可匹配两个子错误
package main

import (
    "errors"
    "fmt"
)

func main() {
    errA := errors.New("A 失败")

    // 空参数和全 nil 输入都没有可汇总的错误。
    fmt.Println(errors.Join() == nil)           // true
    fmt.Println(errors.Join(nil, nil) == nil)   // true

    // 只要至少有一个非 nil 错误,结果就不再是 nil。
    joined := errors.Join(errA, nil)
    fmt.Println(joined == nil)                  // false
    fmt.Println(errors.Is(joined, errA))        // true
}
errors.Join 根据非 nil 错误数量选择返回 nil 或 joinError 的静态结构图
图1:输入集合、非 nil 计数与返回对象的静态说明图;它解释判断关系,不是运行截图或性能证据。

源码先计数,n 为 0 就立即返回

标准库实现先遍历一次参数,计算非 nil 数量。这个计数同时承担两个作用:决定是否直接返回 nil,以及在确实有错误时为切片预留准确容量。

func Join(errs ...error) error {
    n := 0
    for _, err := range errs {
        // 只有接口值本身不为 nil 才计入聚合结果。
        if err != nil {
            n++
        }
    }
    if n == 0 {
        // 没有真实错误时保留 Go 惯用的 nil 语义。
        return nil
    }

    e := &joinError{errs: make([]error, 0, n)}
    for _, err := range errs {
        // 第二次遍历只复制非 nil 错误。
        if err != nil {
            e.errs = append(e.errs, err)
        }
    }
    return e
}

因此,errors.Join(nil, nil) 不会产生一个“空的 joinError”。如果它返回非 nil,上层代码就会把“没有子错误”误判成“发生了错误”,还要额外引入空集合的特殊分支。直接返回 nil 让组合函数与普通 Go 函数的错误契约保持一致。

批量收集错误时可以直接 return errors.Join

这个设计最适合多个独立检查:每个检查成功时返回 nil,失败时返回具体错误,最后统一合并。调用者不需要提前统计错误数量,也不需要为“切片为空”单独写返回分支。

func validateAll(values []string) error {
    errs := make([]error, 0, len(values))

    for _, value := range values {
        if err := validate(value); err != nil {
            // 只收集真实错误,保留每个失败原因。
            errs = append(errs, err)
        }
    }

    // 所有检查成功时自然返回 nil;有失败时返回聚合错误。
    return errors.Join(errs...)
}

func validate(value string) error {
    if value == "" {
        return errors.New("值不能为空")
    }
    return nil
}

也可以把可能为 nil 的结果全部传入 Join,由它统一过滤。对于少量固定检查,这种写法尤其紧凑:

func checkConfig(cfg Config) error {
    // 每个检查独立返回 error;Join 负责过滤成功项。
    return errors.Join(
        checkAddress(cfg.Address),
        checkTimeout(cfg.Timeout),
        checkToken(cfg.Token),
    )
}

非 nil 结果是一棵可检查的错误树

当至少有一个非 nil 输入时,返回值实现 Unwrap() []error。errors.Is 和 errors.As 会检查错误树:先看当前错误,再按深度优先方式检查各个子错误。因此不应该依赖聚合错误与某个原始错误直接相等。

var ErrName = errors.New("名称无效")
var ErrPort = errors.New("端口无效")

func main() {
    joined := errors.Join(ErrName, ErrPort)

    // 聚合错误是包装对象,使用 errors.Is 检查子错误。
    fmt.Println(errors.Is(joined, ErrName)) // true
    fmt.Println(errors.Is(joined, ErrPort)) // true

    // 直接相等只比较当前接口值,不会遍历错误树。
    fmt.Println(joined == ErrName)          // false
}
errors.Join 聚合错误通过 Unwrap 返回子错误并由 errors.Is 和 errors.As 检查的静态关系图
图2:聚合错误、Unwrap 子错误切片与 errors.Is/errors.As 的静态关系说明图;连线表示包装和检查关系,不表示运行时顺序。

还有一个容易忽略的区别:errors.Unwrap 只调用形如 Unwrap() error 的方法,不会展开 Join 返回的 Unwrap() []error。要判断其中是否包含目标错误,直接使用 errors.Is 或 errors.As。

typed nil 不会被当成 nil 丢弃

Join 判断的是 error 接口值是否为 nil。如果接口里保存了一个类型信息非空、动态值为 nil 指针的错误,也就是常说的 typed nil,那么这个接口本身并不等于 nil,它会被当作一个非 nil 错误保留下来。

type MyError struct {
    message string
}

func (e *MyError) Error() string {
    // 真实项目应决定是否允许 nil 接收者,并安全处理。
    if e == nil {
        return "MyError(nil)"
    }
    return e.message
}

func main() {
    var p *MyError
    var err error = p

    // 接口包含动态类型 *MyError,所以接口值并不为 nil。
    fmt.Println(p == nil)                   // true
    fmt.Println(err == nil)                 // false
    fmt.Println(errors.Join(err) == nil)    // false
}

如果该类型的 Error 方法不能处理 nil 接收者,之后格式化聚合错误时还可能发生 panic。更稳妥的做法是在返回 error 前避免把 nil 指针装进接口,而不是期待 errors.Join 替你识别 typed nil。

几个常见边界

只有一个非 nil 错误时,会原样返回它吗?

不会保证原样返回。当前标准库源码仍会创建 joinError,只是在格式化时直接使用唯一子错误的文本。判断身份应使用 errors.Is,不要依赖 joined == original。

嵌套 Join 会自动拍平成一层吗?

不会自动把内层 Join 的所有孩子复制到外层切片;内层聚合错误仍是外层错误树的一个子节点。不过 errors.Is 和 errors.As 会继续遍历内层节点,所以通常不需要手动拍平。

为什么不是返回一个空错误对象?

因为 Go 用 nil 表示成功。空错误对象虽然没有消息或子错误,但接口值仍然非 nil,会触发所有常规错误分支。返回 nil 才能让“没有任何失败”与普通函数的成功语义一致。

聚合错误的文本是什么格式?

每个非 nil 子错误的 Error() 文本按输入顺序拼接,彼此之间加入换行。文本适合展示,但程序逻辑应依据 errors.Is、errors.As 或明确的错误类型,而不是解析字符串。

结论

errors.Join 的判断标准可以压缩成一句话:过滤 nil 后,若非 nil 错误数量 n=0,返回 nil;若 n>0,创建实现 Unwrap() []error 的聚合错误。这个设计既保留了 Go 的成功语义,又让多个失败原因能够被 errors.Is 和 errors.As 继续识别。实际使用时只需额外留意 typed nil、直接相等比较和 errors.Unwrap 不展开多错误这三个边界。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>