图 1:本文封面,panic 被 recover 后两种返回值的分叉。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料整理,与任何公司的当前招聘无关。题目很短:Go 函数返回值未命名,执行中发生 panic,但在函数内 recover 了,函数返回什么?这道题考的不是背结论,而是 defer、panic、recover 三条机制加上「返回值什么时候写入」的时机推演。
一句话结论:未命名返回值——返回零值;命名返回值——defer 里 recover 之后还能改写它,调用方拿到改写后的值。
先把机制摆齐。Go 官方博客《Defer, Panic, and Recover》给了 defer 三条规则:defer 注册时参数即刻求值;deferred 调用在外层函数返回后按后进先出顺序执行;deferred 函数可以读写外层函数的命名返回值。panic 的语义按语言规范「Handling panics」一节:函数中触发 panic 后,该函数的普通执行终止,它注册的 defer 照常执行,然后 panic 向调用者逐层上传,直到 goroutine 栈顶、程序崩溃——除非中途某个 deferred 函数里直接调用了 recover,把 panic 拦住、恢复正常执行。panic 不止来自显式调用,数组越界、nil 指针解引用这类运行时错误同样触发。注意「直接」两个字:recover 只有在 deferred 函数里直接调用才生效,正常流程里调它只会拿到 nil,什么也不发生。
关键在返回值写入的时机。带名字的返回值本质是函数的一个局部变量:return 语句先把结果写进这个变量,defer 再执行,函数最后才真正离场——所以 defer 有机会改写结果。而未命名返回值没有这样一块「署名」的存储:panic 发生时 return 语句根本没执行到,没有值被写入,recover 拦住 panic 后函数只能带着零值离场。同一行 recover,两种写法,两个结果。用代码验证(go1.27,go run main.go):
func unnamed() int {
defer func() {
if r := recover(); r != nil {
fmt.Println("unnamed 的 defer: recover 捕获 ->", r)
}
}()
panic("boom")
}
func named() (r int) {
defer func() {
if rec := recover(); rec != nil {
fmt.Println("named 的 defer: recover 捕获 ->", rec)
r = -1 // 改写命名返回值
}
}()
panic("boom")
}
实测输出:
unnamed 的 defer: recover 捕获 -> boom
unnamed() = 0
named 的 defer: recover 捕获 -> boom
named() = -1
正常流程里直接 recover: <nil>
unnamed() 的 panic 被成功 recover,但 return 没执行到,调用方只拿到 int 零值 0;named() 的 defer 捕获 panic 后把 r 改成 −1,调用方拿到 −1。顺带用三个 defer 验证了后进先出:按 3、2、1 注册的 defer 执行顺序是 1、2、3,且后执行的 defer 读到的是被改写后的返回值——完整时序是「return 写值 → defer 倒序执行 → 函数离场」。
图 2:panic 展开时的 defer 倒序执行与两种返回值结局。
两个常见的错误写法。 一是把 recover 写在普通代码路径里:
func wrong() int {
defer fmt.Println(recover()) // 不行:recover 被当成参数,注册时就已求值
panic("boom")
}
defer 注册时参数即刻求值,这行 recover 在 panic 发生之前就以「正常流程」的身份执行完了,只拿到 nil,panic 照样把程序打崩。二是 defer 一个具名函数、却在更外层调 recover——recover 只对「当前 goroutine 正在 panic」且由 deferred 函数直接调用才生效,包一层、早一拍,都会失效。笔试变体还经常追问执行顺序:多个 defer 时 panic 被哪一个 recover 拦住?答案是后注册的先执行,离 panic 点最近、最外层注册最晚的那个 deferred 函数先拿到 recover 的机会。
工程含义。 想用 recover 兜底并给调用方一个错误,返回值必须命名——func handler() (err error),defer 里 recover 后给 err 赋值,HTTP 框架的 recover 中间件正是这个写法;反过来,函数写着未命名返回值、又指望 recover 之后返回业务值,就是这道题埋的坑。还要注意 recover 拦下 panic 后,panic 点之后的代码不会继续执行,函数直接从 defer 走完离场,别把关键收尾逻辑放在 panic 点后面。一句话记住这道题:panic 时谁能影响返回值,取决于返回值有没有名字。
面试现场的答题结构。 先给出「未命名回零值、命名可被 defer 改写」的结论,再讲清时序——return 先写返回值、defer 后执行、函数再离场,命名返回值给了 defer 一个改写窗口;然后补上 recover 的两个约束(只在 deferred 函数里直接调用生效、正常流程返回 nil);最后落到工程建议收尾。你在线上用过 recover 兜底吗?有没有踩过返回值没命名的坑?欢迎聊聊。