需要预先了解:


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 练习

  1. 定义 ErrPermission 和 ErrTimeout 两个哨兵错误,写一个函数随机返回其中一个,用 errors.Is 分别判断。
  2. 定义一个携带错误码的自定义错误类型,用 errors.As 从中取出错误码。
  3. 写一个 safeGo(f func()) 函数,用 defer + recover 包裹传给它的函数,并在子 goroutine 中调用,验证它确实能拦住子 goroutine 的 panic。