3、错误类型系统:哨兵错误、自定义错误类型、错误值比较的陷阱

错误类型系统,说白了就是 Go 语言里处理错误的「规矩」。

很多新手写 Go,遇到错误就 if err != nil,然后打印一下完事。但项目一复杂,这种写法就扛不住了。你需要区分「这个错误是数据库连不上」还是「用户没权限」,需要针对不同错误做不同处理。

我个人习惯把错误类型系统分成三层:哨兵错误、自定义错误类型、以及错误值比较。咱们一层层聊。

3.1 哨兵错误(Sentinel Errors)

哨兵错误,说白了就是「预定义好的错误值」。

Go 标准库里到处都是这种用法。比如 io.EOFsql.ErrNoRows。它们就是一些全局变量,类型是 error

package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("item not found")
var ErrPermissionDenied = errors.New("permission denied")

func findItem(id int) error {
    if id <= 0 {
        return ErrNotFound
    }
    return nil
}

func main() {
    err := findItem(-1)
    if err == ErrNotFound {
        fmt.Println("没找到,处理一下")
    }
}

嗯,这里要注意:哨兵错误用 == 比较。为什么?因为 errors.New 返回的是一个指针,指向同一个全局变量。所以 == 比较的是指针地址,不是内容。

我的经验:哨兵错误适合「全局唯一」的错误场景。比如「记录不存在」、「权限不足」。我在项目中遇到过有人把哨兵错误定义在函数内部,结果每次调用都返回不同的错误实例,== 永远不成立。这是个坑。

3.2 自定义错误类型

哨兵错误有个硬伤:它只能告诉你「出错了」,但说不出「为什么出错」。比如数据库查询超时,你不仅要知道超时了,还想知道超时了多久、是哪个查询超时了。

这时候就需要自定义错误类型。

type TimeoutError struct {
    Operation string
    Duration  time.Duration
}

func (e *TimeoutError) Error() string {
    return fmt.Sprintf("operation %s timed out after %v", e.Operation, e.Duration)
}

func queryDatabase(query string) error {
    // 模拟超时
    return &TimeoutError{
        Operation: query,
        Duration:  5 * time.Second,
    }
}

func main() {
    err := queryDatabase("SELECT * FROM users")
    if te, ok := err.(*TimeoutError); ok {
        fmt.Printf("操作 %s 超时了,耗时 %v\n", te.Operation, te.Duration)
    }
}

你看,用类型断言 err.(*TimeoutError) 就能拿到详细信息。这比哨兵错误灵活多了。

避坑指南:自定义错误类型一定要实现 Error() string 方法。我见过有人忘了实现,结果 fmt.Println(err) 打印出一串内存地址。嗯,调试的时候一脸懵。

3.3 错误值比较的陷阱

错误值比较,看着简单,其实坑不少。我一个个说。

陷阱一:用 == 比较自定义错误类型

哨兵错误用 == 没问题,因为它们是全局变量。但自定义错误类型用 == 就危险了。

err1 := &TimeoutError{Operation: "query1", Duration: 1 * time.Second}
err2 := &TimeoutError{Operation: "query1", Duration: 1 * time.Second}
fmt.Println(err1 == err2) // false,因为是指针比较

为什么会这样?因为 == 比较的是指针地址,不是结构体内容。两个结构体内容一样,但地址不同,结果就是 false

正确的做法是用类型断言,或者实现一个 Is(target error) bool 方法。

陷阱二:errors.Is 的误用

Go 1.13 引入了 errors.Iserrors.As。很多人以为 errors.Is 就是「比较错误值」,其实它做的是「沿着错误链查找」。

err := fmt.Errorf("wrapped: %w", ErrNotFound)
fmt.Println(errors.Is(err, ErrNotFound)) // true

但如果你自定义了 Is 方法,行为就可能出乎意料。

type MyError struct {
    Code int
}

func (e *MyError) Error() string {
    return fmt.Sprintf("code %d", e.Code)
}

func (e *MyError) Is(target error) bool {
    t, ok := target.(*MyError)
    if !ok {
        return false
    }
    return e.Code == t.Code
}

嗯,这里要注意:Is 方法返回 true 的条件是你自己定义的。我见过有人把 Is 写得过于宽松,导致两个完全不相关的错误被认为是同一个。调试起来那叫一个酸爽。

陷阱三:errors.As 的类型断言陷阱

errors.As 用来提取特定类型的错误。但它有个要求:第二个参数必须是目标类型的指针的指针。

var te *TimeoutError
if errors.As(err, &te) {
    // te 现在指向 TimeoutError 实例
}

如果你写成 errors.As(err, te),编译器不会报错,但运行时会 panic。为什么?因为 errors.As 需要修改传入的指针,让它指向找到的错误实例。你传一个 nil 指针进去,它没法改。

我曾经踩过的坑:有一次我写了一个函数,返回 fmt.Errorf("wrapped: %w", &TimeoutError{...})。然后在调用方用 errors.As 提取 TimeoutError。结果死活提取不到。后来发现,fmt.Errorf 包装错误时,如果被包装的错误是指针类型,errors.As 需要匹配指针类型。我传的是 &TimeoutError,但 errors.As 的第二个参数是 **TimeoutError,类型对不上。折腾了半天才找到原因。

3.4 最佳实践总结

说了这么多,我总结几条实用的建议:

  • 哨兵错误:用 errors.New 定义全局变量,用 == 比较。适合简单、全局唯一的错误场景。
  • 自定义错误类型:实现 Error() string 方法,用 errors.As 提取。适合需要携带上下文的错误场景。
  • 错误链:用 fmt.Errorf("...%w...", err) 包装错误,用 errors.Iserrors.As 解包。不要手动拼接错误字符串。
  • 避免用 == 比较自定义错误:除非你明确知道自己在做什么。否则一律用 errors.Iserrors.As
一个小技巧:如果你想让自定义错误类型支持 errors.Is,可以实现 Is(target error) bool 方法。这样 errors.Is 就会调用你的方法,而不是默认的指针比较。我一般在需要「按错误码匹配」的场景下用这个。

好了,错误类型系统就聊到这儿。下一章咱们聊聊错误包装与错误链,看看怎么把错误信息串起来,又不丢失原始信息。