Go 错误处理 error panic recover — 显式返回与错误包装

Go 在错误处理上走了一条和多数语言截然不同的路:它没有 try/except 异常机制,而是把错误当成普通的返回值一路传递。这听起来麻烦,实际上让"哪里可能出错、出错后怎么处理"变得清晰可见——你能在每一行调用旁看到错误检查,程序的控制流不会被隐式的异常打断。Go 社区有句名言:"错误就是值",正因如此它才能被灵活地传递、包装、判断。

一、error 接口与 errors.New

Go 的 error 其实是一个极简的内置接口:type error interface { Error() string }。任何实现了 Error() string 方法的类型都能当错误用。最简单的创建方式是 errors.New("一段文字"),它返回一个装着这段文字的错误对象。函数惯例把 error 作为最后一个返回值,调用方用 if err != nil 检查——这一行检查在 Go 代码里出现频率极高,新手要尽快习惯。

package main

import (
    "errors"
    "fmt"
)

// error 是一个内置接口:type error interface { Error() string }
// 任何实现 Error() string 方法的类型都能当 error 用

func divide(a, b int) (int, error) {
    if b == 0 {
        // errors.New:用一段文字创建一个简单错误
        return 0, errors.New("除数不能为零")
    }
    return a / b, nil
}

func main() {
    // Go 惯例:错误作为最后一个返回值,调用方必须检查
    if r, err := divide(10, 0); err != nil {
        fmt.Println("出错:", err)   // 出错: 除数不能为零
    } else {
        fmt.Println("结果:", r)
    }
}

二、fmt.Errorf 与错误包装 %w

更常见的是 fmt.Errorf("格式串 %d", n),它像 fmt.Sprintf 一样格式化错误信息。Go 1.13 引入了一个关键动词 %w:它不仅把底层错误"塞进"新错误里,还保留了错误链——调用方可以用 errors.Is / errors.As 顺着链往上追溯。这解决了"错误层层包装后,外层再也无法判断根因"的老问题。注意 %v 只是把错误文本拼进去、不保留链,两者用途不同。

package main

import (
    "errors"
    "fmt"
)

// 哨兵错误:预定义的错误值,供调用方用 errors.Is 比对
var ErrNotFound = errors.New("记录不存在")

func findUser(id int) error {
    if id <= 0 {
        // %w 把底层错误"包装"起来,保留错误链
        return fmt.Errorf("查询用户 id=%d 失败: %w", id, ErrNotFound)
    }
    return nil
}

func main() {
    err := findUser(-1)

    // errors.Is:沿着包装链判断 err 是否(在某层)等于 target
    if errors.Is(err, ErrNotFound) {
        fmt.Println("命中 ErrNotFound")   // 命中 ErrNotFound
    }

    // 打印能看到完整错误链
    fmt.Println(err)
    // 查询用户 id=-1 失败: 记录不存在
}

三、errors.Is 与 errors.As

这两个函数是处理包装链的双子星:

package main

import (
    "errors"
    "fmt"
)

// 自定义错误类型:实现 error 接口的结构体,可携带任意字段
type ValidationError struct {
    Field string
    Msg   string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("字段 %q 校验失败: %s", e.Field, e.Msg)
}

func validate(name string) error {
    if len(name) < 3 {
        return &ValidationError{Field: "name", Msg: "至少 3 个字符"}
    }
    return nil
}

func main() {
    err := validate("ab")

    // errors.As:沿着包装链找一个类型匹配的错误,赋给 target
    var ve *ValidationError
    if errors.As(err, &ve) {
        fmt.Println("字段:", ve.Field)   // 字段: name
        fmt.Println("原因:", ve.Msg)     // 原因: 至少 3 个字符
    }
}

四、自定义错误类型

当你想给错误附带更多信息(哪个字段错了、错误码是多少),就定义一个实现 error 接口的结构体。上面的 ValidationError 就是典型——它带 FieldMsg 两个字段,调用方用 errors.As 提取出来就能精确响应(比如给前端返回"用户名太短"的提示)。哨兵错误适合表达"是哪一种错",自定义类型适合表达"错的具体细节",工程里两者经常配合使用。

五、panic 与 recover

error 处理的是可预见的失败(文件打不开、网络超时),而 panic 处理的是不可恢复的严重错误(数组越界、空指针、不变式被破坏)。panic 一旦发生,当前 goroutine 会沿着调用栈一层层展开,逐个执行 defer,最后程序崩溃退出。recover 是唯一的"救命稻草":它只能在 defer 里调用,能捕获当前 goroutine 的 panic,让程序继续往下走。注意它跨 goroutine 不生效——子协程的 panic 必须在子协程自己内部 recover。

package main

import "fmt"

func risky(n int) {
    // defer + recover 是捕获 panic 的唯一姿势
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("捕获到 panic:", r)
        }
    }()

    if n < 0 {
        // panic:不可恢复的严重错误,会沿调用栈展开
        panic("n 不能为负数: " + fmt.Sprint(n))
    }
    fmt.Println("正常执行,n =", n)
}

func main() {
    risky(10)    // 正常执行,n = 10
    risky(-1)    // 捕获到 panic: n 不能为负数: -1
    fmt.Println("main 继续往下走")  // 仍会执行,因为 panic 被兜住了
}

六、何时用 panic,何时返回 error

新手容易滥用 panic,这里给几条经验法则:

一个常见模式:在 main 顶层用 defer recover 兜底,把漏网的 panic 转成日志,避免整个服务因为一个意外直接挂掉。但这只是保险丝,日常错误处理还是要老老实实走 error

小结

上一篇 Go 接口 | 下一篇 Go 并发 Goroutine

✈️💬