3. 哨兵错误:定义、使用场景与优缺点

哨兵错误(Sentinel Error)是 Go 语言中最基础、最直观的错误处理模式。它通过预定义的包级错误变量,为调用方提供明确的“信号”,使其能够通过 == 或 errors.Is 判断错误的具体类型,从而执行不同的业务逻辑。

3.1 哨兵错误的定义与核心模式

哨兵错误的核心模式是使用 var ErrXXX = errors.New("描述信息") 在包级别声明一个公开的错误变量。这个变量就像一个“哨兵”,当函数返回该变量时,调用方可以精确地知道发生了什么错误。

package db

import "errors"

// 哨兵错误:预定义的包级错误变量
var ErrNotFound = errors.New("record not found")
var ErrConflict  = errors.New("record already exists")
var ErrTimeout   = errors.New("operation timed out")

// GetUser 模拟数据库查询
func GetUser(id int) (*User, error) {
    // ... 查询逻辑
    if id <= 0 {
        return nil, ErrNotFound
    }
    // ...
    return &User{ID: id}, nil
}

调用方通过直接比较错误值来做出决策:

user, err := db.GetUser(42)
if err != nil {
    switch {
    case errors.Is(err, db.ErrNotFound):
        // 处理"未找到"逻辑:返回 404 或创建新记录
    case errors.Is(err, db.ErrConflict):
        // 处理"冲突"逻辑:提示用户重试
    default:
        // 处理未知错误
    }
}
最佳实践:在 Go 1.13 及之后版本中,推荐使用 errors.Is() 替代 == 进行比较。errors.Is() 会沿着错误链递归检查,即使哨兵错误被包装过也能正确匹配。

3.2 使用场景

哨兵错误最适合以下场景:

场景 说明 典型示例
资源查找 调用方需要区分“不存在”和“其他错误” ErrNotFound、ErrNoRows
状态冲突 操作因资源状态不满足前置条件而失败 ErrConflict、ErrAlreadyExists
边界条件 输入参数超出合法范围 ErrInvalidInput、ErrOutOfRange
流程控制 需要提前终止迭代或递归 ErrStopIteration(标准库 io.EOF 即为此类)
权限校验 调用方需要区分“未认证”和“无权限” ErrUnauthorized、ErrForbidden
标准库中的经典哨兵:io.EOF 是 Go 标准库中最著名的哨兵错误。它表示“输入流已结束”,调用方通过 errors.Is(err, io.EOF) 来判断是否正常读取完毕,从而区分“正常结束”和“读取异常”。

3.3 哨兵错误的优缺点

哨兵错误模式简单直接,但在大型项目中需要谨慎使用。下表总结了其核心优缺点:

维度 优点 缺点
可读性 错误语义清晰,ErrNotFound 一目了然 变量名可能过长,导致代码行拥挤
性能 比较操作是 O(1) 的指针比较,零开销 无
耦合度 调用方只需导入包即可使用,无需了解内部实现 调用方与包的错误变量形成硬依赖,包无法轻易修改错误变量
扩展性 新增哨兵错误只需添加一行 var 声明 哨兵错误数量膨胀后,调用方的 switch 分支会变得冗长
错误链 配合 errors.Is 可穿透包装层 如果调用方使用 == 比较,包装后无法匹配
信息量 错误信息固定,适合机器判断 无法携带动态上下文(如具体 ID、时间戳)

3.4 哨兵错误在错误处理体系中的位置

下面的 SVG 图展示了哨兵错误在 Go 错误处理体系中的定位,以及它与自定义错误类型、错误包装的关系。

哨兵错误在 Go 错误处理体系中的位置 Go 错误处理三大模式 哨兵错误 自定义错误类型 错误包装 (fmt.Errorf) var ErrXXX = errors.New(...) 调用方通过 errors.Is() 匹配 典型场景:EOF、NotFound、Conflict 核心特征 • 包级公开变量,全局唯一 • 错误信息固定,不携带动态数据 • 适合“是/否”类型的错误判断 • 与自定义类型配合可扩展上下文 • 过度使用会导致调用方代码耦合

3.5 使用哨兵错误的注意事项

  1. 不要滥用:每个包暴露的哨兵错误建议控制在 3~5 个以内。过多的哨兵错误会使调用方的错误处理逻辑变得臃肿。
  2. 命名规范:使用 Err 前缀,后跟驼峰命名,如 ErrInvalidArgument。避免使用 Error 后缀(与 error 接口混淆)。
  3. 文档化:每个导出的哨兵错误变量必须附带 Go doc 注释,说明其触发条件和调用方应如何处理。
  4. 避免在库内部使用哨兵错误作为流程控制:哨兵错误是 API 契约的一部分,内部逻辑应使用其他机制(如 bool 返回值)控制流程。
  5. 考虑与自定义错误类型结合:当需要携带动态上下文时,使用自定义错误类型包装哨兵错误,既保留可比较性,又扩展信息量。
常见陷阱:不要将哨兵错误变量声明为 const。Go 语言中 const 只能用于基本类型和字符串,而 errors.New 返回的是指针类型,必须使用 var 声明。如果误用 const,编译器会报错。

3.6 哨兵错误 vs 自定义错误类型

下表对比了哨兵错误与自定义错误类型在关键维度上的差异,帮助你在实际开发中做出正确选择:

对比维度 哨兵错误 自定义错误类型
声明方式 var ErrX = errors.New("msg") type XError struct{ ... } + 实现 Error()
比较方式 errors.Is(err, ErrX) errors.As(err, &target)
携带动态数据 ❌ 不支持 ✅ 支持(如 ID、时间戳)
类型安全 弱(所有哨兵都是 *errors.errorString) 强(每个类型独立)
适用场景 简单、固定的错误信号 需要结构化错误信息的复杂场景
API 稳定性 低(删除或修改变量会破坏调用方) 中(新增字段不影响类型匹配)

在实际项目中,哨兵错误和自定义错误类型并非互斥关系。常见的做法是:使用哨兵错误作为“错误类别”,使用自定义错误类型作为“错误详情”。例如:

var ErrNotFound = errors.New("not found")

type NotFoundError struct {
    Resource string
    ID       int64
    Err      error  // 包装哨兵错误
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s %d: %v", e.Resource, e.ID, e.Err)
}

func (e *NotFoundError) Unwrap() error {
    return e.Err
}

// 调用方可以同时使用 errors.Is 和 errors.As
if errors.Is(err, ErrNotFound) {
    var nfErr *NotFoundError
    if errors.As(err, &nfErr) {
        log.Printf("Resource %s with ID %d not found", nfErr.Resource, nfErr.ID)
    }
}

这种组合模式既保留了哨兵错误的可比较性,又通过自定义类型提供了丰富的上下文信息,是生产级 Go 项目中推荐的做法。