首页 / Go 语言入门教程 / 哨兵错误与自定义错误

Go 语言入门教程

哨兵错误与自定义错误

本教程共 80 篇 · 第 53 篇 · 更新于 2026-07-27 · 约 8 分钟阅读

GoGo 入门教程错误处理哨兵错误自定义错误错误类型

53. 哨兵错误与自定义错误

本节目标:学会用哨兵错误模式预定义错误,实现自定义错误类型携带更多信息,掌握错误包装的最佳实践。

哨兵错误

哨兵错误就是预定义的全局错误变量,调用方可以用 errors.Is 检查:

package main

import (
    "errors"
    "fmt"
)

// 哨兵错误:预定义的全局变量
var (
    ErrNotFound   = errors.New("not found")
    ErrPermission = errors.New("permission denied")
    ErrTimeout    = errors.New("timeout")
)

func GetUser(id int) (string, error) {
    if id <= 0 {
        return "", ErrNotFound
    }
    return "Alice", nil
}

func main() {
    _, err := GetUser(-1)
    if errors.Is(err, ErrNotFound) {
        fmt.Println("用户不存在")
    } else if err != nil {
        fmt.Println("其他错误:", err)
    }
}

标准库里大量使用了哨兵错误。比如 os 包:

var (
    ErrNotExist  = errors.New("file does not exist")
    ErrPermission = errors.New("permission denied")
    ErrClosed    = errors.New("use of closed file connection")
)
Note

哨兵错误用 var 声明,不用 const。因为 error 是接口类型,不能用 const 声明。

哨兵错误的优点

  • 调用方可以精确判断错误类型
  • 错误信息集中管理,修改方便
  • 配合 errors.Is 能穿透错误包装

哨兵错误的注意点

Warning

哨兵错误的值本身不重要,重要的是它的身份(identity)。调用方用 errors.Is 比较,不看错误消息。所以不要靠字符串匹配来判断错误。

// 坏:靠字符串匹配
if err.Error() == "not found" { ... }

// 好:靠 errors.Is
if errors.Is(err, ErrNotFound) { ... }

自定义错误类型

哨兵错误只能表达「是什么错误」,不能携带额外信息。如果错误需要附带数据(比如错误码、出错位置等),用自定义错误类型:

package main

import (
    "errors"
    "fmt"
)

// 自定义错误类型
type ValidationError struct {
    Field   string // 哪个字段出错
    Value   any    // 出错的值
    Message string // 错误描述
}

// 实现 error 接口
func (e *ValidationError) Error() string {
    return fmt.Sprintf("验证失败 [%s]: %s (值: %v)", e.Field, e.Message, e.Value)
}

func ValidateAge(age int) error {
    if age < 0 {
        return &ValidationError{
            Field:   "age",
            Value:   age,
            Message: "年龄不能为负数",
        }
    }
    if age > 150 {
        return &ValidationError{
            Field:   "age",
            Value:   age,
            Message: "年龄不合理",
        }
    }
    return nil
}

func main() {
    err := ValidateAge(-5)

    // 用 errors.As 提取自定义错误
    var valErr *ValidationError
    if errors.As(err, &valErr) {
        fmt.Println("字段:", valErr.Field)   // age
        fmt.Println("值:", valErr.Value)      // -5
        fmt.Println("描述:", valErr.Message)  // 年龄不能为负数
    }
}

自定义错误类型的好处:

  1. 携带结构化信息(字段、值、错误码等)
  2. 调用方可以用 errors.As 提取详细信息
  3. 可以定义额外方法

带错误码的自定义错误

type AppError struct {
    Code    int    // 错误码
    Message string // 错误信息
}

func (e *AppError) Error() string {
    return fmt.Sprintf("[ERR-%d] %s", e.Code, e.Message)
}

var (
    ErrInvalidInput = &AppError{Code: 400, Message: "无效输入"}
    ErrUnauthorized = &AppError{Code: 401, Message: "未授权"}
    ErrInternal     = &AppError{Code: 500, Message: "内部错误"}
)

func DoSomething(token string) error {
    if token == "" {
        return ErrUnauthorized
    }
    return nil
}

包装自定义错误

自定义错误和 %w 包装可以配合使用:

package main

import (
    "errors"
    "fmt"
)

var ErrUserNotFound = errors.New("user not found")

type User struct {
    ID   int
    Name string
}

func FindUser(id int) (*User, error) {
    if id == 0 {
        return nil, fmt.Errorf("查询用户 %d: %w", id, ErrUserNotFound)
    }
    return &User{ID: id, Name: "Alice"}, nil
}

func main() {
    _, err := FindUser(0)

    // 穿透包装层检查哨兵错误
    if errors.Is(err, ErrUserNotFound) {
        fmt.Println("用户不存在,跳过处理")
        return
    }
    if err != nil {
        fmt.Println("其他错误:", err)
    }
}

错误包装最佳实践

1. 在哪一层包装

在每层函数调用时加上自己的上下文:

func LoadConfig() error {
    data, err := os.ReadFile("config.yaml")
    if err != nil {
        return fmt.Errorf("加载配置: %w", err)
    }
    // ...
}

func InitApp() error {
    if err := LoadConfig(); err != nil {
        return fmt.Errorf("初始化应用: %w", err) // 再包一层
    }
    return nil
}

// 最终错误:初始化应用: 加载配置: open config.yaml: no such file or directory

每一层加上自己这一层的语义,错误链就形成了一条调用路径,方便定位问题。

2. 包装时加什么信息

加「这一层在做什么」,不加「错误是什么」(错误本身已经有了):

// 好:加了上下文(在做什么)
return fmt.Errorf("解析JSON配置: %w", err)

// 不好:重复了错误信息
return fmt.Errorf("JSON解析失败: %v", err) // 用 %v 会丢错误链

3. 不要过度包装

不是每一行都要包装。只在有意义的边界处加上下文:

// 过度包装:太啰嗦
func Process() error {
    if err := step1(); err != nil {
        return fmt.Errorf("step1: %w", err)
    }
    if err := step2(); err != nil {
        return fmt.Errorf("step2: %w", err)
    }
    if err := step3(); err != nil {
        return fmt.Errorf("step3: %w", err)
    }
    return nil
}

// 适度包装:只在外层加
func Process() error {
    if err := step1(); err != nil {
        return err
    }
    if err := step2(); err != nil {
        return err
    }
    if err := step3(); err != nil {
        return fmt.Errorf("处理失败: %w", err) // 只在最后包装
    }
    return nil
}
Tip

错误包装的度需要经验。原则是:让错误链能帮你定位问题,但不要让信息爆炸。每个包装层加一句有意义的上下文就够了。

小结

哨兵错误和自定义错误类型:

  • 哨兵错误:全局 var 声明的预定义错误,用 errors.Is 检查
  • 自定义错误类型:实现 Error() string 方法,携带结构化信息
  • errors.As 提取自定义错误的字段
  • 包装时加「在做什么」的上下文,不过度包装
  • 哨兵错误和自定义类型可以配合 %w 一起用

下一章讲错误处理策略,搞懂什么时候用 error、什么时候用 panic。