defer 的参数在写下那一刻就求值了,循环里这样写会踩坑

一次重构把循环里的显式调用改成了 defer,本意是「不管从哪条分支出去都能收尾」。上线后日志全乱了:一百条任务记录,打印出来的任务编号都是最后一个。

中间那九十九个编号一次都没出现。代码看着完全合理,defer 也确实执行了,只是它执行时用到的参数不是循环当前的那一份。

Go 官方博客对此有一句话讲得很清楚,只是写代码的时候很少有人会把它和「参数」两个字联系起来。

defer 到底在什么时候执行?

官方博客对 defer 的定义是:

A defer statement pushes a function call onto a list. The list of saved calls is executed after the surrounding function returns.

关键词有两个:push 和 list。defer 不是立刻执行,而是把这次调用压进一个列表,等外层函数返回之后再统一处理。

这个列表是按函数维度算的。同一个函数里写几个 defer,就攒几个调用。

正因为是压栈,取出来的时候自然是后进先出:

Deferred function calls are executed in Last In First Out order after the surrounding function returns.

后进先出不是实现细节,而是语言规定的语义。它带来的直接好处是资源释放顺序天然和申请顺序相反:先打开的后关闭,先加锁的后解锁。

go 复制代码
for i := 0; i < 4; i++ {
    defer fmt.Print(i)
}

按倒序执行,这段代码打印的是 3210,不是 0123。

规则 官方表述的落点 代码里看到的现象
执行时机 压入列表,函数返回后执行 函数没走到返回就不执行
执行顺序 后进先出 循环里的 defer 倒着跑
参数求值 在 defer 语句处求值 参数被固定在写下那一刻
命名返回值 可读也可赋值 defer 能改掉最终返回值

参数为什么在写下的那一刻就固定?

这是几条规则里最容易踩的一条:

A deferred function's arguments are evaluated when the defer statement is evaluated.

注意主语是 arguments,不是函数体。被延后的是「调用这个动作」,不是「算出这些参数」。

defer 语句执行到的那一瞬间,参数表达式就要算出结果并存起来,之后无论外部变量怎么变,存下来的那份都不动了。

官方博客给的最小例子:

go 复制代码
func trace() {
    i := 0
    defer fmt.Println(i)
    i++
    return
}

打印出来是 0。i++ 明明发生在返回之前,但 fmt.Println 拿到的实参在 defer 那一行就已经是 0 了。后面的自增只改了变量,改不了已经存好的实参。

回到开头那个循环。每写一次 defer fmt.Println(task.ID),压进列表的是「用当前 ID 调用」这个动作,但同时 task.ID 也被当场读出来存好了。

写法 实际输出 原因
defer fmt.Println(i) 之后再 i++ 0 参数在 defer 行就求值
循环里 defer fmt.Print(i) 3210 参数逐次固定,执行倒序
defer func() { i++ }() 配合命名返回值 返回值加一 闭包没传参,读到的是变量本身
循环里 defer func(id int) { ... }(task.ID) 每条各自的编号 参数在 defer 行求值,取的是当前这一条

第三行是另一个方向:不传参时,闭包体内引用的是变量本身而不是值的副本,读到的就是执行时刻的最新值。参数求值和闭包捕获是两套不同的机制,别混着记。

第四行是循环里的正解。task.ID 仍然在 defer 那一行求值,但求出来的是当前这一条,不是最后一条。

这套「规则 / 官方原文 / 代码现象」的对照表是我用 AI墨衍 整理成模板的,几个仓库共用一份,加新条目时往表里补一行就行。

命名返回值是怎么被 defer 改掉的?

第三条规则解释为什么有些 defer 能改返回值,有些不能:

Deferred functions may read and assign to the returning function's named return values.

两个动词值得注意:read 和 assign。defer 里的函数不只能读到命名返回值,还能给它重新赋值。

而这个赋值发生在返回值真正交给调用方之前,所以调用方看到的就是改过的值。

go 复制代码
func c() (i int) {
    defer func() { i++ }()
    return 1
}

返回值是 2 而不是 1。return 1 先把 1 赋给命名返回值 i,然后 defer 列表开始执行,i++ 把它变成 2,最后才真正返回。

这条规则生效的前提是返回值有名字。签名写成 (i int),i 就是一个普通变量,defer 改得动。签名写成光秃秃的 int,返回值没有名字,defer 里根本拿不到它。

recover 能生效也靠这条规则。捕获到 panic 之后想把函数改成正常返回,唯一的通道就是给命名返回值赋值。

panic 之后 recover 为什么只在 defer 里管用?

Recover is only useful inside deferred functions. During normal execution, a call to recover will return nil and have no other effect.

这句话把 recover 的适用范围限得很死:它必须处在 defer 调用的函数里,而且必须是在 panic 展开栈的过程中被执行。

普通执行路径上调它,返回值是 nil,除此之外什么都不会发生。

所以 recover 单独出现在业务函数第一行是没有意义的。它得包在 defer func() { ... }() 里,而且要保证这个 defer 在 panic 发生之前就已经压进了列表。

go 复制代码
defer func() {
    if r := recover(); r != nil {
        // 只有压在这里,recover 才有机会拿到值
    }
}()

这也解释了为什么 recover 经常和命名返回值一起出现:捕获之后要告诉调用方「这次没崩,但请当成错误处理」,通道只有命名返回值。

循环里还能放心用 defer 吗?

能,但要区分两种代价。第一种是参数被固定,那是语义问题,一定会发生;第二种是列表堆积,只在特定数据量下才有影响。

循环里每写一次 defer,就多一个待执行的调用压在列表里,直到函数返回才统一执行。如果循环跑十万次,那就是十万个待执行调用。

场景 推荐写法 理由
循环体内单个资源 抽成独立函数,函数内 defer 每次调用结束即可释放
短循环、少量迭代 直接 defer,参数显式传入 代码最直观,读起来没负担
长循环、十万级迭代 显式调用关闭,或收集后统一处理 避免堆积大量待执行调用
需要在循环中捕获 panic 闭包里 defer 加 recover 范围收敛在单次迭代内

第一行和第二行的区别只在代码组织,语义完全一样。第三行才是真正需要警惕的位置,它不是参数求值的问题。

这两件事经常被混为一谈。看到「循环里别用 defer」这种说法,先问一句是哪个原因:是参数取错了,还是列表撑大了。两种情况对应两种改法。

小结

三件事:defer 把调用压进列表、参数在写下那一刻求值、命名返回值可以被改。

第一条决定什么时候执行,第二条决定循环里为什么打出来的都是同一个值,第三条决定 recover 和返回值改写能不能成立。

它们都不靠经验,而是靠语义。写 defer 的时候多看一眼括号里的参数,大部分坑都不会发生。

相关推荐
苍何1 小时前
腾讯 Buddy One 来了,办公生态一键打通 WorkBuddy(附教程)
后端
Tokenfree1 小时前
把通义千问接进工单系统:三个版本够用,但有两个坑没人提前告诉我
后端
wei_shuo1 小时前
MongoDB迁移实战:基于SQL的文档数据开发与多模态融合实践
后端
小兔子2 小时前
os.path.join 遇到绝对路径会丢掉前面所有段,这条规则写在文档第二句
后端
小兔子2 小时前
Go 的接口是类型加值,所以赋了 nil 的 err 判断起来不等于 nil
后端
不一样的少年_2 小时前
不花一分额外 API 钱,用现成的 Claude Code / Codex / Cursor 搭建 AI 个人知识库
前端·后端·ai编程
用户6919026813392 小时前
Redis 1:简述Redis概念与Agent的联系
redis·后端·设计
艾思控驱动器2 小时前
手写一个电流环采样时序校核工具:为什么你的 FOC 电流总差几个百分点
后端
智驭未来掌门人2 小时前
桌面程序里的“1K 以内”:到底该限制哪一层像素(C++ Qt 版)
后端