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
这两个函数是处理包装链的双子星:
- errors.Is(err, target):沿着包装链判断
err(或它包装的任意一层)是否等于target。常用于和哨兵错误(sentinel error,如io.EOF、sql.ErrNoRows)比较。 - errors.As(err, &target):沿着包装链找一个类型匹配的错误,把它赋给
target。常用于从包装链里提取自定义错误类型的字段。
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 就是典型——它带 Field 和 Msg 两个字段,调用方用 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,这里给几条经验法则:
- 常规业务错误(用户名已存在、余额不足)→ 返回 error。
- 库的初始化失败(配置文件解析不了、必填依赖缺失)→ 可以 panic,因为继续跑也没意义。
- 程序员错误(传了不该传的 nil、不可能走到的分支)→ panic,相当于断言。
- 库代码面对调用方的错误 → 一定返回 error,不要替别人 panic。
一个常见模式:在 main 顶层用 defer recover 兜底,把漏网的 panic 转成日志,避免整个服务因为一个意外直接挂掉。但这只是保险丝,日常错误处理还是要老老实实走 error。
小结
- Go 没有 try/except,错误是普通返回值,
if err != nil是日常。 - 用
fmt.Errorf+%w包装错误,保留错误链。 - 用
errors.Is比对哨兵值,用errors.As提取自定义类型。 panic留给不可恢复的严重错误;defer recover兜底但别滥用。
上一篇 Go 接口 | 下一篇 Go 并发 Goroutine。