三种错误
Go 代码使用三种形式的错误,从最简单到信息最丰富:
- 临时错误:当场用
errors.New("...")或fmt.Errorf("...")创建。调用方只能读取它的信息。 - 哨兵错误:包级别的变量,比如
io.EOF。调用方可以用errors.Is检查它。 - 错误类型:带有
Error()方法、携带字段的结构体。调用方可以用errors.As取出它并读取字段。
选择能满足调用方需求的最简单的那一种。
哨兵错误
哨兵错误是只声明一次、按同一性比较的错误值。按照惯例,名字以 Err 开头。
输出:
mug bought
cap is out of stock, notify me later
buy "hat": not found
两次调用 errors.New 即使文本相同,得到的也是不同的错误:errors.New("x") == errors.New("x") 的结果是 false。这就是为什么哨兵错误必须是一个共享的变量。
哨兵错误会成为包 API 的一部分。一旦调用方开始检查 ErrNotFound,你就无法在不破坏他们代码的情况下停止返回它。只导出调用方真正需要据此做分支判断的那些。
自定义错误类型
当调用方需要详细信息(哪个字段、哪个状态码、多久后重试)时,就定义一个类型。
输出:
register: age: must be between 0 and 150
bad field: age
方法使用指针接收者,函数返回 &ValidationError{...},所以 errors.As 的目标是 *ValidationError,而你传入的是它的地址(&ve,类型为 **ValidationError)。把这一层搞错是使用 errors.As 的经典错误:如果用值接收者,就应该返回 ValidationError{...},并声明 var ve ValidationError。go vet 能发现最常见的失误,即传了 ve 而不是 &ve:second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type。
用 %w 包装
带 %w 的 fmt.Errorf 返回的错误会记住被它包装的那个错误。每一层都添加上下文,同时保持错误链完整。
输出:
start: connect db: timeout
*fmt.wrapError: start: connect db: timeout
*fmt.wrapError: connect db: timeout
*errors.errorString: timeout
true
false
使用 %v 时信息相同,但错误链被切断,所以 errors.Is 返回 false。希望调用方能看到原因时用 %w;原因属于你不希望调用方依赖的实现细节时用 %v。
从 Go 1.20 开始,一次调用可以包装多个错误:fmt.Errorf("%w; %w", err1, err2)。这时 errors.Is 匹配其中任意一个都可以。
errors.Join:一次处理多个错误
校验和清理工作经常会产生不止一个错误。errors.Join(Go 1.20)可以把它们打包在一起。
输出:
name: required
email: required
age: negative
---
true
true
所有参数都为 nil 时,errors.Join 返回 nil,所以上面的函数不需要为“没有错误”单独处理。
Unwrap 和 Is 方法
errors.Is 和 errors.As 通过调用 Unwrap 方法来找到被包装的错误。持有原因的自定义类型应该把它暴露出来:
包装多个错误的类型则实现 Unwrap() []error。
类型还可以定义 Is(target error) bool,自己决定相等的规则,例如让所有状态码相同的 *HTTPError 都能匹配。你很少需要它;包装一个哨兵错误并从 Unwrap 返回它,通常就够了。
有类型的 nil 陷阱
绝不要用具体类型声明错误变量,再通过 error 返回它:
func check() error {
var err *ValidationError // nil pointer
// ... no problem found
return err // non-nil error! Its type is *ValidationError
}
调用方的 if err != nil 会成立,因为持有有类型 nil 指针的接口并不是 nil。成功时请直接返回字面量 nil,并把局部错误变量的类型声明为 error。原因见 接口 页面。
该选哪一种
| 调用方需要 | 提供 |
|---|---|
| 只记录或显示失败 | fmt.Errorf("...: %w", err) |
| 针对某个特定情况做分支 | 哨兵错误 var ErrX = errors.New(...) |
| 读取失败的详细信息 | 带字段的错误类型 |
| 看到多个相互独立的失败 | errors.Join |
常见错误
- 用
==比较被包装的错误。 请使用errors.Is。 - 给
errors.As传入非指针。 它需要一个指向目标类型变量的指针。 - 在函数内部创建“哨兵错误”。
return errors.New("not found")每次调用都会创建一个新值;调用方无法与它比较。 - 导出所有错误。 每个导出的哨兵错误或类型都是一项 API 承诺。
- 根据
err.Error()做匹配。 字符串是给人看的。
常见问题
Go 中如何创建自定义错误?
对于固定的错误情况,声明一个包级别的哨兵错误:var ErrNotFound = errors.New("not found")。对于需要携带数据的错误,定义一个带有 Error() string 方法的类型:type ValidationError struct { Field string } 和 func (e *ValidationError) Error() string { return e.Field + " is invalid" }。
Go 中如何包装错误?
使用带 %w 动词的 fmt.Errorf:return fmt.Errorf("load user %d: %w", id, err)。新错误的信息包含旧错误的信息,并且 errors.Unwrap、errors.Is 和 errors.As 都能找到原始错误。从 Go 1.20 开始,一次 Errorf 调用可以用多个 %w 动词包装多个错误。
errors.Is 和 errors.As 有什么区别?
errors.Is(err, target) 回答的是“这个特定的错误值是否出现在错误链中”,适用于 io.EOF 这样的哨兵错误。errors.As(err, &target) 回答的是“错误链中是否有这个类型的错误”,如果有,就把它存入 target,让你可以读取它的字段。
errors.Join 有什么作用?
errors.Join(errs...)(Go 1.20)把多个错误合并成一个。它的信息是各个错误信息用换行符连接起来的结果,nil 错误会被丢弃,如果全部为 nil 则返回 nil。只要合并的错误中有一个匹配,errors.Is 和 errors.As 就会匹配成功。