错误处理
需要预先了解:
1. 错误处理
Go 把"可预期的失败"当作普通的返回值处理,而不是语言级的异常机制。这套设计的核心是 error 接口加 if err != nil 惯例。与之相对,panic 只用于"程序不该继续运行"的情况。这一篇把 error 的包装与解包、defer 的完整机制、panic/recover 的边界都讲清楚。
1.1 error 接口
error 是标准库里的一个接口,只有一个方法:
type error interface {
Error() string
}
创建错误有两种常用方式:
err1 := errors.New("资源不存在")
err2 := fmt.Errorf("打开文件 %s 失败", "a.txt")
errors.New 返回一个固定文本的错误;fmt.Errorf 可以格式化文本。两者都满足 error 接口。
if err != nil 惯例
Go 的错误处理惯例是"检查并提前返回",而不是层层嵌套:
func find(id int) (string, error) {
if id != 1 {
return "", ErrNotFound
}
return "一撮灰", nil
}
v, err := find(2)
if err != nil {
fmt.Println("查找失败:", err)
return
}
fmt.Println("找到:", v)
把错误放在最后一个返回值,是 Go 的约定。调用方拿到错误后立刻判断、立刻返回,正常的代码路径保持在最左侧,不需要缩进。
注意:错误信息不要大写开头、不要以标点结尾(
errors.New("资源不存在")而不是errors.New("资源不存在。")),因为错误经常被拼接到更长的句子里。
1.2 自定义错误类型
需要携带上下文信息时,定义一个实现了 Error() 的结构体:
type PathError struct {
Path string
Err error
}
func (e *PathError) Error() string {
return e.Path + ": " + e.Err.Error()
}
err := &PathError{Path: "/etc/app.yaml", Err: ErrNotFound}
fmt.Println(err) // /etc/app.yaml: 资源不存在
命名建议以 Error 结尾,字段里保存原始错误和上下文数据。
1.3 错误包装(Go 1.13+)
包装的动机是:底层返回"资源不存在",上层需要补充"在加载配置时出错",但又不希望调用方失去判断底层错误的能力。
用 %w 包装:
func loadConfig() error {
return fmt.Errorf("加载配置失败: %w", &PathError{Path: "/etc/app.yaml", Err: ErrNotFound})
}
%w 与 %v 的区别是:%w 会把被包装的错误记录下来,形成一个链条。
err := loadConfig()
fmt.Println("包装后的错误:", err) // 加载配置失败: /etc/app.yaml: 资源不存在
errors.Is
errors.Is(err, target) 沿着包装链查找是否有等于 target 的错误:
fmt.Println("errors.Is(err, ErrNotFound):", errors.Is(err, ErrNotFound)) // true
判断错误时不要用 ==,因为它只能比较最外层。errors.Is 会逐层 Unwrap 直到找到匹配。
errors.As
errors.As 用来把包装链中的某个具体错误类型提取出来:
var pe *PathError
if errors.As(err, &pe) {
fmt.Println("提取到 PathError, Path =", pe.Path) // /etc/app.yaml
}
第二个参数必须是指向错误类型的指针,这样 errors.As 才能把值写进去。
errors.Unwrap
手动逐层拆开:
fmt.Println("第一层 Unwrap:", errors.Unwrap(err)) // /etc/app.yaml: 资源不存在
fmt.Println("第二层 Unwrap:", errors.Unwrap(errors.Unwrap(err))) // 资源不存在
一个类型只要实现了 Unwrap() error,就能参与这条链:
func (e *PathError) Unwrap() error {
return e.Err
}
errors.Join(Go 1.20+)
需要同时报告多个错误时:
joined := errors.Join(err1, err2)
fmt.Println(joined)
第一个错误
第二个错误
errors.Is 对 Join 出来的结果同样有效,任意一个匹配就返回 true。
1.4 defer 的完整机制
defer 的基本规则(LIFO、参数立即求值)在 函数 里讲过,这里补齐与错误处理相关的部分。
修改命名返回值
defer 在 return 赋值之后、函数真正返回之前执行,所以可以修改命名返回值:
func namedReturn() (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("捕获到 panic: %v", r)
result = -1
}
}()
panic("出问题了")
}
fmt.Println(namedReturn()) // -1 捕获到 panic: 出问题了
执行顺序是:panic → 运行 defer → recover 拦下 panic → defer 修改 err 和 result → 函数正常返回这两个值。
多个 defer 与 recover 的顺序
defer 按 LIFO 执行,recover 只对当前正在传播的 panic 生效。如果多个 defer 中都有 recover,只有最先执行的那个(也就是最后注册的)能拿到 panic 值:
defer func() { fmt.Println("第一个注册, 最后执行:", recover()) }()
defer func() { fmt.Println("第二个注册, 先执行:", recover()) }()
panic("boom")
输出中先执行的那个会打印 boom,后执行的拿到 nil(因为 panic 已经被处理)。
defer 里的闭包引用
defer func() { ... }() 捕获的是变量本身,因此看到的是函数返回前的最终值;defer f(x) 的参数在注册时就求值。想同时拿到"当前值"和"最终值"时,利用这个差异:
func f() {
x := 1
defer fmt.Println("注册时的 x:", x) // 1
defer func() { fmt.Println("返回时的 x:", x) }() // 100
x = 100
}
1.5 panic 与 recover
panic 中断当前函数的正常执行,开始逐层运行 defer,直到某个 defer 调用 recover 拦下它,或者整个程序崩溃。
func safeDiv(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("除法出错: %v", r)
}
}()
return a / b, nil
}
r, err := safeDiv(10, 2)
fmt.Println(r, err) // 5 <nil>
r, err = safeDiv(10, 0)
fmt.Println(r, err) // 0 除法出错: runtime error: integer divide by zero
recover 的两个限制
1. 必须在 defer 的函数里直接调用。 把 recover 包一层再调用就拿不到 panic 值:
defer func() {
recover() // 这样写没有效果,返回值被丢弃
}()
2. 只能捕获同一个 goroutine 的 panic。 主 goroutine 的 recover 无法拦住子 goroutine 里的 panic:
defer func() {
if r := recover(); r != nil {
fmt.Println("主 goroutine 的 recover:", r)
}
}()
go func() {
panic("子 goroutine 里的 panic")
}()
time.Sleep(200 * time.Millisecond)
panic: 子 goroutine 里的 panic
goroutine 7 [running]:
main.main.func2()
.../main.go:16 +0x25
整个进程直接退出。所以每个 goroutine 需要自己处理自己的 panic,通常在最外层包一个 defer + recover。
常见 panic 场景
try("数组越界", func() { a := [3]int{}; i := 5; _ = a[i] })
try("nil 指针解引用", func() { var p *int; _ = *p })
try("整数除零", func() { a, b := 1, 0; _ = a / b })
try("关闭已关闭 channel", func() { ch := make(chan int); close(ch); close(ch) })
try("向已关闭 channel 发送", func() { ch := make(chan int, 1); close(ch); ch <- 1 })
try("类型断言失败", func() { var i any = 1; _ = i.(string) })
try("切片越界写", func() { s := make([]int, 1); i := 5; s[i] = 1 })
数组越界 -> panic: runtime error: index out of range [5] with length 3
nil 指针解引用 -> panic: runtime error: invalid memory address or nil pointer dereference
整数除零 -> panic: runtime error: integer divide by zero
关闭已关闭 channel -> panic: close of closed channel
向已关闭 channel 发送 -> panic: send on closed channel
类型断言失败 -> panic: interface conversion: interface {} is int, not string
切片越界写 -> panic: runtime error: index out of range [5] with length 1
汇总成表:
| 场景 | panic 值 | 能否 recover |
|---|---|---|
| 数组/切片越界 | runtime error: index out of range |
可以 |
| nil 指针解引用 | runtime error: invalid memory address or nil pointer dereference |
可以 |
| 整数除零 | runtime error: integer divide by zero |
可以 |
| 关闭已关闭的 channel | close of closed channel |
可以 |
| 向已关闭的 channel 发送 | send on closed channel |
可以 |
| 类型断言失败 | interface conversion: ... |
可以 |
| nil map 写入 | assignment to entry in nil map |
可以 |
| 并发读写 map | fatal error: concurrent map writes |
不可以 |
| 栈溢出 | fatal error: stack overflow |
不可以 |
fatal error 是运行时直接终止进程的错误,recover 拦不住。
1.6 选型原则
| 情况 | 选择 |
|---|---|
| 可预期的失败(文件不存在、参数非法、网络超时) | error |
| 程序性 bug(不可能出现却出现了的状态) | panic |
| 库函数遇到调用方违反前置条件 | 可以 panic,并在文档里写清楚 |
| 主程序处理请求 | 最外层 recover 兜底,转成 500 响应并记录日志 |
库代码应尽量返回 error,把"要不要崩"的决定权交给调用方。只有在"继续运行下去状态已经不可信"时才用 panic。
re-panic
有时需要在 recover 后补充上下文再抛出去:
defer func() {
if r := recover(); r != nil {
// 记录日志或补充信息
panic(fmt.Errorf("处理请求时崩溃: %v", r))
}
}()
这样既留下了原始信息,又让上层的 recover 能识别出这是被包装过的 panic。注意包装后的 panic 值不再是原来的类型,上层如果依赖具体类型需要保留原始值。
1.7 练习
- 定义
ErrPermission和ErrTimeout两个哨兵错误,写一个函数随机返回其中一个,用errors.Is分别判断。 - 定义一个携带错误码的自定义错误类型,用
errors.As从中取出错误码。 - 写一个
safeGo(f func())函数,用defer + recover包裹传给它的函数,并在子 goroutine 中调用,验证它确实能拦住子 goroutine 的 panic。



