问题复现

最近在 Code Review 时发现了一个有趣的案例。代码逻辑清晰,测试也覆盖了主要分支,但在生产环境中 errors.Is 的行为与预期不符:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type Error struct {
    ErrorCode int
    Message   string
}

func (e Error) Error() string {
    return e.Message
}

var ErrTest = Error{ErrorCode: 100301, Message: "请求失败"}

func getErr() error {
    return ErrTest
}

func main() {
    err := getErr()
    if errors.Is(err, ErrTest) {
        fmt.Println("ok")
    } else {
        fmt.Println("failed")  // 实际输出
    }
}

预期输出 “ok”,实际却输出 “failed”。这个问题暴露了 Go 错误处理机制中一个常被忽视的细节。

根因分析

errors.Is 的比较语义

errors.Is 的实现遵循以下优先级:

  1. 引用相等性检查: 使用 - 运算符比较 errtarget
  2. 自定义相等性: 如果 err 实现了 Is(error) bool 方法,委托给该方法
  3. 错误链遍历: 如果 err 实现了 Unwrap() error,递归检查包装的错误

关键在于第一步的 - 比较。对于接口类型,- 运算符的语义是:

  • 动态类型必须相同
  • 动态值必须可比较且相等

值类型与接口的交互

问题的根源在于 值类型赋值给接口时的复制语义:

1
2
3
4
5
var ErrTest = Error{...}  // 值类型

func getErr() error {
    return ErrTest  // 发生值复制,创建新实例
}

当值类型赋值给接口时:

  • Go 会创建该值的副本
  • 副本被封装在接口的动态值中
  • 虽然内容相同,但在内存中是两个不同的实例

因此 errors.Is(err, ErrTest) 比较的是:

  • err 中的副本
  • 全局变量 ErrTest 的原始值

由于它们是不同的实例,比较失败。

解决方案

方案一:使用指针类型(推荐)

1
2
3
4
5
var ErrTest = &Error{ErrorCode: 100301, Message: "请求失败"}

func getErr() error {
    return ErrTest  // 返回同一个指针
}

优势:

  • 指针赋值给接口时,动态值是指针本身,不会创建新实例
  • 多次返回同一个错误变量时,都指向同一个内存地址
  • 符合 Go 标准库的惯用模式(如 io.EOF, sql.ErrNoRows)

方案二:实现 Is 方法

1
2
3
4
5
6
7
func (e Error) Is(target error) bool {
    t, ok := target.(Error)
    if !ok {
        return false
    }
    return e.ErrorCode == t.ErrorCode
}

适用场景:

  • 需要自定义相等性语义(如只比较错误码)
  • 需要支持错误码的范围匹配
  • 向后兼容已有的值类型错误定义

注意事项:

  • 必须同时处理值类型和指针类型的 target
  • 需要明确定义"相等"的业务语义

errors.As 的正确使用

类型断言的语义

errors.As 的函数签名是:

1
func As(err error, target interface{}) bool

target 必须是指向错误类型的指针,即 *E*interface{}

正确用法

1
2
3
4
var customErr *Error
if errors.As(err, &customErr) {
    fmt.Printf("ErrorCode: %d\n", customErr.ErrorCode)
}

这里 &customErr 的类型是 **Error,Go 内部会:

  1. 遍历错误链,查找类型为 *Error 的值
  2. 将找到的值赋给 customErr

常见错误

1
2
3
4
5
6
7
// 错误用法 1: 不传递指针
var customErr Error
errors.As(err, customErr)  // 编译错误

// 错误用法 2: 类型不匹配
var customErr Error
errors.As(err, &customErr)  // 查找 Error 值类型,但链中只有 *Error

最佳实践

  1. 哨兵错误使用指针类型

    1
    
    var ErrNotFound = &NotFoundError{...}
    
  2. 错误类型实现 Is 方法时考虑值和指针两种情况

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    func (e *Error) Is(target error) bool {
        t, ok := target.(*Error)
        if !ok {
            // 尝试值类型
            if tv, ok := target.(Error); ok {
                t = &tv
            } else {
                return false
            }
        }
        return e.ErrorCode == t.ErrorCode
    }
    
  3. 使用 errors.As 时明确目标类型

    1
    2
    3
    4
    5
    
    // 清晰明确
    var netErr *net.OpError
    if errors.As(err, &netErr) {
        // 处理网络错误
    }
    
  4. 避免在接口方法中返回具体的错误值

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    // 不推荐
    func (s *Service) Process() error {
        return ErrorValue  // 每次返回都是新副本
    }
    
    // 推荐
    func (s *Service) Process() error {
        return ErrPointer  // 始终是同一个实例
    }
    

总结

Go 的错误处理机制看似简单,但涉及到接口、值语义、指针语义的交互时,容易产生反直觉的行为。理解 errors.Iserrors.As 的底层实现逻辑,能够帮助我们:

  • 正确设计错误类型
  • 避免错误比较失败的问题
  • 编写更健壮的错误处理代码

关键要记住:在 Go 中,哨兵错误应该用指针,错误比较不仅仅是值相等