0%

Go 重难点拆解|errors.Is、errors.As 与 errors.AsType 的分工

Go 重难点拆解|errors.Is、errors.As 与 errors.AsType 的分工

Go 1.13 引入了错误包装:用 fmt.Errorf("...: %w", err) 给底层错误加上下文,包装后的错误通过 Unwrap() 方法还能取回原来的错误。这个机制带来一个直接后果:错误一旦被包装,最外层就不再是原来的类型和值,err == ErrNotFounderr.(*MyErr) 这两种最常见的判断方式都会失效。

errors 包为此提供了三个函数:errors.Iserrors.As,以及 Go 1.26 新增的 errors.AsType。这篇文章按几个具体问题展开,说清它们各自解决什么问题、内部如何工作、常见的误用在哪里。

一、errors.Is 解决什么问题

问题:我定义了一个哨兵错误(sentinel error)ErrNotFound,下层返回它,中间层用 fmt.Errorf("query user: %w", err) 包了一层,上层用 err == ErrNotFound 判断,为什么永远是 false?

因为 == 比较的是接口值的动态类型和动态值。包装之后,err 的动态类型是 *fmt.wrapError,动态值是那个包装结构体,和 ErrNotFound(动态类型 *errors.errorString)不可能相等。原始错误还在,只是被放进了 Unwrap() 里,== 看不到。

这种情况应该用 errors.Is 来判断。先看它要交付什么:这个场景只需要知道”链里有没有 ErrNotFound 这个值”,一个 bool 就够了,不需要把任何东西交还给调用方。errors.Is 就是”沿着包装链逐层比较”的 ==

1
2
3
4
5
6
7
8
9
10
11
var ErrNotFound = errors.New("not found")

func find(id int) error {
return fmt.Errorf("find id=%d: %w", id, ErrNotFound)
}

func main() {
err := find(42)
fmt.Println(err == ErrNotFound) // false
fmt.Println(errors.Is(err, ErrNotFound)) // true
}

errors.Is(err, target)err 开始,每一层做两件事:先判断这一层是否与 target 相等(值可比较时用 ==,或者这一层实现了 Is(error) bool 方法且返回 true),不相等就调用 Unwrap() 取下一层,直到匹配或链到底。

两个补充:

  1. 自定义错误类型可以实现 Is(target error) bool 方法,让 errors.Is 按你定义的规则判定相等。典型用法是带字段的错误值和一个不带字段的哨兵之间做”类别相等”。
  2. Go 1.20 起 errors.Join%w 多参数会产生 Unwrap() []error 的树状结构,errors.Is 会深度优先遍历整棵树,不只是单链。

因为 errors.Is 只交付一个 bool,它的第二个参数是普通的 error 值,不需要取地址。记住”交付一样东西”这个说法,下面 errors.As 要交付两样东西,设计上的差别都来自这里。

二、errors.As 解决什么问题

问题:我定义了一个带字段的错误类型 *NetworkError,想在上层读它的 OpURL 字段。但 err 的静态类型是 error,接口上只有 Error() 方法,编译器不允许 err.Op。类型断言 err.(*NetworkError) 可以拿到字段,但它只看最外层,被包装后就失效了。怎么办?

errors.Is 不同,这个场景需要交付两样东西:

  1. 链里有没有 *NetworkError 这个类型,用 bool 回答;
  2. 有的话,那个具体的 *NetworkError 值是什么,交给调用方读字段。

只给第 1 样不够:你知道链里”有”,但手里没有静态类型为 *NetworkError 的变量,OpURL 照样读不了。errors.As 就是”沿着包装链逐层做类型断言”,并且把断言成功的那个值交给你:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type NetworkError struct {
Op, URL string
Err error
}

func (e *NetworkError) Error() string {
return fmt.Sprintf("%s %s: %v", e.Op, e.URL, e.Err)
}


func main() {
err := fmt.Errorf("fetch: %w", &NetworkError{
Op: "GET", URL: "https://example.com", Err: errors.New("connection refused"),
})

var netErr *NetworkError
if errors.As(err, &netErr) {
fmt.Println(netErr.Op, netErr.URL) // GET https://example.com
}
}

签名是 func As(err error, target any) bool。第一个参数 err 是被搜索的错误链;第 1 样东西通过返回值 bool 交付;第 2 样东西写进第二个参数 target 所指向的变量,也就是例子里的 netErr

三、errors.As 的第二个参数为什么要传 &netErr

这是初学时最容易绕晕的地方,拆成四个小问题。

问题 1:errors.As 为什么要往第二个参数里写值?

第二节说过,errors.As 要交付两样东西,返回值 bool 只能交付第一样。第二样,也就是找到的那个 *NetworkError 值,必须另有出口,这个出口就是第二个参数:errors.As 找到匹配项后执行 *target = 找到的错误,把值写进 netErr。第二个参数存在的意义,就是接收这第二样东西。

问题 2:既然要写值,为什么必须传地址?

因为 Go 是值传递。如果写成 errors.As(err, netErr),函数拿到的是 netErr 的一份拷贝(一个 nil 指针),它无论怎么赋值,main 里的 netErr 依然是 nil,后面 netErr.Op 直接空指针 panic。只有传 &netErr,函数拿到变量的地址,才能把结果写回 main 里的那个变量。json.Unmarshal(data, &v) 是同一个模式:任何”函数帮你填变量”的 API,在 Go 里都只能通过传地址实现。

问题 3:netErr 本身已经是指针了,为什么还要再取一次地址?

netErr 是指针,和”传地址”是两件独立的事。netErr 的类型是 *NetworkError,是因为 Error() 方法是指针接收者,实现 error 接口的类型是 *NetworkError,错误链里装的动态类型也是 *NetworkError,接收变量的类型必须和它一致。而 &netErr(类型 **NetworkError)是为了让 errors.As 能修改 netErr 这个变量本身。如果 Error() 是值接收者、链里装的是 NetworkError 值,那接收变量就声明成 var netErr NetworkError,传的仍然是 &netErr,只是类型变成 *NetworkError。取地址这一步不会因为接收变量是不是指针而改变。

问题 4:Unwrap 作用在哪个参数上?

作用在第一个参数 err 上,而且是只读遍历:errors.As 内部用局部变量沿着 err.Unwrap() 一层层走,main 里的 err 不会被改动,所以第一个参数按值传就够了。第二个参数 netErr 不参与解包,它是唯一被赋值的变量。”被遍历”和”被修改”是两件事,前者发生在第一个参数上,后者发生在第二个参数上。

回头看,errors.As 用起来确实繁琐:调用方要先声明一个变量,再把它的地址传进去,函数内部通过反射看这个地址指向什么类型,再把找到的值填进去。三步里有两步是调用方在替函数做准备工作。原因在于 errors.As 是 Go 1.13 加入的,当时没有泛型,一个库函数无法返回”由调用方指定的任意类型”,只能用这种输出参数加反射的方式绕过去。这个绕法带来两个代价:反射意味着类型检查推迟到运行时,也就是第四节的两个陷阱;繁琐的调用形式则要等到泛型出现才有替代方案,也就是第五节的 errors.AsType

四、errors.As 的两个运行时陷阱

errors.As 依赖反射,编译器不检查 target 的类型是否合理,下面两个要求只在运行时检查,不满足就 panic:

  1. target 必须是非 nil 指针。传 netErr 而不是 &netErr,panic 信息为 errors: target must be a non-nil pointer
  2. target 指向的类型必须实现 error 或者是接口类型。下面这种写法能编译通过,运行时 panic:
1
2
var ne NetworkError // 值类型;Error 方法是指针接收者,NetworkError 本身不实现 error
errors.As(err, &ne) // panic: errors: *target must be interface or implement error

五、Go 1.26 的 errors.AsType[E]

errors.AsType 交付的仍然是第二节说的两样东西,区别在交付方式:有了泛型,”返回调用方指定的类型”不再需要输出参数,两样东西用两个返回值 (E, bool) 一次交付。Go 1.26 新增:

1
func AsType[E error](err error) (E, bool)

用法:

1
2
3
if netErr, ok := errors.AsType[*NetworkError](err); ok {
fmt.Println(netErr.Op, netErr.URL)
}

errors.As 对照:

errors.As errors.AsType[E]
引入版本 Go 1.13 Go 1.26
目标类型如何指定 通过 target 指针的指向类型,运行时反射推断 类型参数 E,编译期确定
结果如何交付 写入 *target 作为第一个返回值返回
需要预先声明变量 是,且通常在 if 之外 否,可以写在 if 的初始化语句里
类型错误何时暴露 运行时 panic 编译期报错(E 不满足 error 约束时)
实现方式 反射 类型断言 err.(E),无反射,无额外内存分配

两点需要注意:

  1. Eerror 约束。errors.As 允许 target 指向任意接口类型,例如 var t interface{ Timeout() bool }errors.AsType 要求 E 本身实现 error,等价写法要改成 errors.AsType[interface{ error; Timeout() bool }](err)
  2. AsType[NetworkError](值类型)与 AsType[*NetworkError](指针类型)是两个不同的类型参数。如果 Error() 是指针接收者,AsType[NetworkError] 直接编译不过,这正是它比 errors.As 安全的地方;如果 Error() 是值接收者,两者都能编译,但链里装的是 *NetworkError 时,AsType[NetworkError] 返回 false,需要按实际装入的类型选择。

errors.As 没有被标记为废弃,标准库文档的表述是”多数场景优先使用 AsType“。项目 Go 版本在 1.26 及以上的新代码,建议直接用 errors.AsType;需要兼容旧版本的代码继续用 errors.As

六、三者如何选

判断依据是”你要从错误链里拿回什么”:

  • 只需要知道链里是否存在某个特定的错误值(哨兵),用 errors.Is
  • 需要拿到某个类型的错误值并读它的字段或调它的方法,Go 1.26 以上用 errors.AsType[E],更早版本用 errors.As
  • 比较的是哨兵错误,且确定这个错误没有经过任何包装(例如自己包内直接返回、直接判断),err == ErrNotFound 即可,不必引入 errors 包。

小结

errors.Is 是能穿透包装的 ==errors.As 是能穿透包装的类型断言。errors.As 的第二个参数传地址,是因为它除了返回”有没有”之外,还要把找到的具体值交给调用方,而 Go 值传递下只有传地址才能写回调用方的变量;Unwrap 作用在第一个参数上且是只读遍历,与第二个参数无关。errors.AsType[E] 用泛型把”输出参数”改回了”返回值”,去掉了反射和运行时类型检查,是 Go 1.26 之后的推荐写法。