defer、panic 与 recover
错误卷收尾。defer 是 Go 一个又小又妙的设计:把一句话推迟到函数返回前执行,专门治「记得关文件、记得解锁、记得收尾」。panic / recover 是 Go 那个「几乎不该用的异常」——它存在,但用它的场合极少。这一章把 defer 讲透(含两个人人踩过的坑),把 panic 的边界划清楚,附一台能单步看 defer 栈的引擎。
defer:把收尾推迟到函数结束
defer 后面跟一句函数调用,这句话不会立刻执行,而是被推迟到当前函数即将返回的那一刻才执行。它最经典的用途是「打开了就得关」:
func readFile(name string) error {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close() // 推迟:不管这个函数怎么返回,退出前都会关掉 f
// ... 中间可能有一堆 return、可能出错提前返回 ...
// 你再也不用在每个 return 前手动 f.Close() 了
return process(f)
}
妙在哪:defer f.Close() 紧挨着 os.Open 写,「打开」和「关闭」的意图在代码里贴在一起,一眼就知道这个资源会被释放。而且不管函数从哪个 return 出去、甚至中途 panic,defer 都保证会执行。这就治好了「函数有五个 return 分支,你得记得在每个分支前都关一遍文件」那种极易漏的痛。除了关文件,它还常用来解锁(defer mu.Unlock())、关连接、记录函数耗时。
多个 defer:后进先出
一个函数里可以有多个 defer,它们按后进先出(LIFO,像一叠盘子)的顺序执行 —— 最后 defer 的最先跑。下面这台 Demo 把 defer 栈画出来,单步走第一个例子看清顺序:
第一个例子:先 defer A 再 defer B,函数体打印 "body",最后返回时倒着弹栈 —— 先 B 后 A。所以输出是 body、B、A。
为什么是 LIFO?因为 defer 常对应「资源的获取顺序」:你先开数据库连接、再开事务、再开游标,收尾时就该反着关 —— 先关游标、再关事务、再关连接。后进先出天然匹配这种嵌套资源的释放顺序。
坑一:defer 的参数在「注册时」就求值了
这是必踩的坑。defer f(x) 里的参数 x,在执行到 defer 这一行时就被求值、拍下快照了,而不是等到函数返回、真正执行 f 时才求值。切 Demo 第二个例子看:
func f() {
i := 0
defer fmt.Println(i) // 此刻 i 是 0,这个 0 当场被拍下来
i = 10
// 函数返回,defer 执行,打印的是 0,不是 10!
}
defer fmt.Println(i) 在 i 还是 0 的时候就把 0 定住了,后面 i = 10 改的是变量,改不到已经拍好的快照。所以打印 0。如果你想要「返回时的最新值」,得用闭包包一层,让它推迟到执行时才读变量:
defer func() {
fmt.Println(i) // 闭包里读 i,等到执行时才读,打印 10
}()
这个区别(直接 defer f(i) 拍快照 vs defer func(){ ...i... }() 读最新)是新手最容易迷糊的地方,务必分清。
defer 是函数级的,不是块级 —— 它推迟到整个函数返回才执行,不是到本次循环结束。所以在一个大循环里 defer f.Close(),会攒下成千上万个没关的文件,直到函数结束才一起关,可能先耗尽文件句柄:
for _, name := range files {
f, _ := os.Open(name)
defer f.Close() // ❌ 全攒着,循环 10000 次就是 10000 个没关的
}
解法:把循环体抽成一个函数,让 defer 随每次调用及时收尾;或者干脆不用 defer、在循环里显式 f.Close()。记住:defer 的作用域是函数,不是循环。
panic:程序坏了的时候
panic 是 Go 版的「抛异常」——它会立刻中断当前函数的正常流程,开始沿调用栈往上退,一路执行沿途每一层的 defer,如果没人拦截,最终整个程序崩溃并打印栈追踪:
panic: runtime error: index out of range [5] with length 3
goroutine 1 [running]:
main.main()
/app/main.go:8 +0x1d
你会在两种情况见到 panic:一是运行时自己触发的(数组越界、解引用 nil 指针、除以零 —— 这些是程序 bug),二是你手动 panic("...")。第 14 章说过那个二分:panic 表示「程序进入了不该发生的状态」,是 bug,不是业务错误。
准则:库代码里几乎永远不要 panic,把错误作为 error 返回,让调用方决定怎么办。panic 会掀翻整个程序,你没资格替调用方做这个决定 —— 也许人家能优雅处理这个错误呢。
真正该 panic 的场合极少,基本只有:程序启动时的不可恢复错误(配置文件读不出来、根本没法跑,那就趁早 panic 崩掉,别带病运行);「这绝不可能发生」的断言(真发生了说明代码逻辑坏了,崩掉比继续跑出错误结果好)。除此之外,看到你想 panic,先问自己:这真的是 bug,还是一个我该 return error 的普通错误?九成是后者。
recover:唯一能接住 panic 的东西
recover 能在 panic 沿栈上退的过程中把它接住,让程序恢复正常。但它有个死规矩:recover 只有写在 defer 里才有效。切 Demo 第三个例子:
func safeRun() {
defer func() {
if r := recover(); r != nil { // 只有在 defer 里,recover 才拦得住
fmt.Println("接住了 panic:", r)
}
}()
fmt.Println("before")
panic("boom") // 这里炸了
fmt.Println("after") // 到不了这行
// 但因为上面 defer 里 recover 接住了,safeRun 正常返回,程序不崩
}
为什么必须在 defer 里?因为 panic 发生后,唯一还会被执行的就是 defer 链 —— recover 只有搭在这趟「退栈时执行 defer」的车上,才能在正确的时机拦截。这也解释了 defer / panic / recover 为什么是一套:defer 是退栈时的钩子,panic 触发退栈,recover 在钩子里拦截。
recover 的正当用途同样很少:最典型的是服务器的顶层保护 —— 一个 HTTP 服务器不希望某个请求处理函数里的 panic 掀翻整个进程,于是在每个请求的最外层 defer recover(),把 panic 变成一个 500 错误、记个日志、然后接着服务别的请求。它是「别让一个坏请求害死整个服务」的安全网,不是拿来当 try/catch 做日常错误处理的。
Kotlin 对照
finally 和 try-with-resources → defer:都是「保证收尾」。但 defer 更灵活也更贴 —— 它紧挨着资源的获取写(不用像 finally 那样把整块逻辑包进 try),而且是函数级的、能叠加、LIFO。你会很快爱上 defer mu.Unlock() 这种一行到位的写法。要小心的就是那两个坑:参数求值时机、别在循环里 defer。
Kotlin 的异常 → Go 的 panic:形似(都沿栈上抛、都能被接住),但用法哲学完全相反。Kotlin 把异常当常规错误机制,日常错误就靠 throw / catch。Go 明确把 panic 划为非常规手段 —— 日常错误走 error 返回值(第 14–15 章),panic 只留给真正的 bug 和进程级灾难。
迁移时最大的错,就是把 panic/recover 当 try/catch 用 —— 拿它做业务错误处理。这在 Go 里是明确的反模式,会被 code review 打回。记住这条分界:你在 Kotlin 里 throw 一个业务异常的地方,在 Go 里应该 return 一个 error;panic 只在「程序真的坏了」时出场。把这条守住,你的 Go 错误处理就是地道的。
这一章的一句话
defer 把收尾推迟到函数返回、多个 defer 后进先出,专治资源释放(但参数在注册时就求值、别在循环里 defer);panic 是「程序坏了」的信号、库里几乎不该用(日常错误 return error),recover 只在 defer 里有效、是服务器顶层的安全网而非日常 try/catch——这套的分界线就是「业务错误 vs 程序 bug」。
卷 IV 结束。你已经补齐了 Go 的错误观。现在,全书最激动人心的一卷来了 —— 卷 V:并发。这是 Go 删掉那么多东西之后,郑重塞回你手里的唯一一样大礼。