ELI5 · 说人话 · GO 底层
defer 能不能改结果,只有一个判据:
它改的那个变量,是不是返回槽本身。
怪现象全从这里推出来
参数当场就求值写下 defer 那一刻就把值抄走了,只有函数体被推迟
后写的先跑同一个函数里的 defer 是一摞盘子,LIFO
函数返回时才跑不是 for 或 if 这个块结束时
这三条 + 上面那张三格图,defer 就没有别的秘密了。
最核心的一张图
匿名返回值那边有两个盒子:defer 够得着左边那个,调用方读的是右边那个。具名返回值只有一个盒子,所以怎么改都算数。
四道题,都是同一条规则
func f1() int {
x := 1
defer func(){ x++ }()
// 改的是局部变量
return x
// 槽早抄成 1 了
}
func f2() (x int) {
x = 1
defer func(){ x++ }()
// x 就是返回槽
return x
// 槽=1 → x++ → 2
}
func f4() (x int) {
defer func(){ x++ }()
return 5
// 展开成:
// x = 5; defer; ret
}
func f5() []int {
s := []int{1, 2, 3}
defer func(){ s[0] = 100 }()
return s
// header 拷走了,
// 但数组是同一块
}
第四道最容易记反。对照这两行就懂了:s = append(s, 4) ✗ 改的是变量,header 已经拷走了;s[0] = 100 ✓ 改的是内容,两边指着同一块内存。
口诀:改内容有效,改变量无效。
五种情况,查表即可
匿名 + 值类型defer 动的是局部变量
具名返回值defer 动的就是返回槽本身
匿名,但改的是内容*p = … · s[i] = … · m[k] = …,底层数据是共享的
匿名,重新赋值那个变量s = … · p = …,只动到局部的那份
具名,但在 defer 里写 return那个 return 只结束匿名函数,什么都没覆盖
最后一行值得单独记:defer 里没有「返回一个新值」这种语法,只有「赋值给具名返回槽」这一条路。
最高频的坑
start := time.Now()
defer log.Printf("cost=%v",
time.Since(start))
// 这里当场就算完了
start := time.Now()
defer func() {
log.Printf("cost=%v",
time.Since(start))
}()
方法接收者同理:defer obj.Close() 里的 obj 当场求值,值接收者还会当场拷贝一份。想延迟到执行时再读,就包一层 defer func(){ obj.Close() }()。
作用域比你想的大
for _, p := range paths {
f, err := os.Open(p)
if err != nil { return err }
defer f.Close()
use(f)
}
return nil
func handle(p string) error {
f, err := os.Open(p)
if err != nil { return err }
defer f.Close()
return use(f)
}
// 循环里只调 handle(p)
同一个道理适用于 defer mu.Unlock():锁的持有时间 = 函数剩下的全部执行时间。函数一长就该拆临界区,而不是提前手写 mu.Unlock()——手写会在 panic 路径漏解锁。
recover 挑食
defer func() {
if r := recover(); r != nil {
// 就在 defer 的第一层
}
}()
defer func() {
func(){ recover() }()
// 隔了一层,接不住
}()
if r := recover(); …
// 不在 defer 里,永远 nil
panic 发生时,三格图里的第 ① 步根本没跑(没人给返回槽赋值),但 defer 照样跑。所以想把 panic 变成一个正常的 error,返回值必须具名——这是唯一的通道。
func do() (err error) { // ← 必须具名
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic recovered: %v", r)
}
}()
panic("boom")
}
别赌
returnruntime.Goexit()os.Exit(n)当场终止进程log.Fatal / log.Fatalf内部就是 os.Exit(1)log.Fatal 会跳过所有 defer,这是线上丢数据的经典原因——缓冲没 flush、事务没回滚,日志倒是打出来了。要收尾,就把 error 一路返回到 main 再退出。
只有这四类值得用
panic 转 error上一节那段。没有第二种办法能把 panic 变成正常返回。
事务收尾panic 就回滚再原样抛回去,出错就回滚,都没有才 commit——而 commit 自己的错误也要写回返回值。
defer func() {
if p := recover(); p != nil {
_ = t.Rollback()
panic(p) // 回滚后原样抛回去
}
if err != nil { _ = t.Rollback(); return }
err = t.Commit() // commit 的错误别吞
}()出口统一装饰函数有 8 个 return 分支时,这一招免去 8 次重复包装。
defer func() {
if err != nil {
err = fmt.Errorf("handle(%s): %w", id, err)
metrics.Errors.Inc()
}
}()别吞掉 close 的错写操作的 Close() 才是真正 flush 落盘的地方,吞掉它 = 吞掉写失败。只读可以忽略,只写 / 读写必须处理。
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr // 只在没有更早的错时才覆盖
}
}()除了这四类,别用 defer 偷偷改业务返回值——调用方读函数签名和 return 那一行,完全预判不到。
别再为性能躲它
最慢的年代defer 记录走堆分配,链表挂在 g 上。「defer 很慢」这个印象就是这时候留下的。
改栈分配defer 记录挪到栈上,明显变快。
open-coded defer调用直接内联到函数出口,配一个 bitmap 标记哪些该跑,开销接近手写调用。
open-coded 的启用条件:函数内 defer 不超过 8 个、不在循环体里、return 数 × defer 数 不过大、编译器优化没被关掉(-gcflags="-N -l" 调试时会退化)。
所以「循环里的 defer」是双重反模式:既有正确性问题,又让优化失效。
Code review 直接打回
defer句柄堆到函数末尾才释放,还顺手关掉了编译器优化。拆出一个小函数。
defer x.Close() 不看错误flush 失败就这么没了。具名返回值 + 在 defer 里回写 err。
log.Fatal 之后还能收尾它就是 os.Exit(1),一个 defer 都不跑。把 error 返回到 main 再退出。
recover() 隔了一层函数调用返回 nil,panic 继续往上抛,自己还以为接住了。必须写在 defer 的第一层。
新 panic 接管,旧的只剩 traceback 里一行 [recovered]。Close() 和指针解引用格外小心。
写 defer 时问三句:① 参数现在就求值了吗?② 它的作用域是整个函数,太长了吗?③ 它自己会不会 panic、会不会吞错误?
return 交的是卷,
defer 只能改署了名的那张。