Go 为什么需要 defer?从函数怎么收尾说起

假设你要复制一个文件。源文件打开了,目标目录却不存在,目标文件创建失败。你准备写下 return err。在这条返回路径上,还得关掉已经打开的源文件。

如果把关闭操作都写在函数末尾,这条提前返回的路径就绕过去了。以后再加一次检查,又得确认走到这里时有哪些文件已经打开。Go 官方博客用的就是这个例子。

把关闭操作写在打开文件的地方

下面这个函数把文件内容复制到 dst,也就是接收数据的地方。比如传入 os.Stdout,内容就会显示在终端里。

go 复制代码
func copyFromFile(path string, dst io.Writer) error {
	src, err := os.Open(path)
	if err != nil {
		return err
	}
	defer src.Close()

	_, err = io.Copy(dst, src)
	return err
}

在这个函数里,defer 放在确认文件打开成功之后、第一次使用文件之前 。先检查 err,确认拿到了一个需要关闭的文件。直接调用 src.Close() 会当场关闭它,后面的 io.Copy 就读不到了。写成 defer src.Close(),Go 会先记下这次调用,等 copyFromFile 要返回时再关闭。

如果把 defer 挪到上面示例的 io.Copy 和 return err 之间,复制返回错误时也能走到登记关闭的那一行。但如果后来在复制之后加了一条提前返回,情况就变了。

go 复制代码
_, err = io.Copy(dst, src)
if err != nil {
	return err // 复制出错时直接返回
}
defer src.Close() // 只有复制成功才会登记

决定位置时,先找这个函数从哪一行开始负责关闭文件 。这里就是 Open 成功之后。只要文件适合留到函数结束再关,就在这时登记。这样,从打开成功到函数返回,中间无论多出几条错误路径,都已经安排好了关闭操作。

Go 团队当时在解决什么?

2007 年,Robert Griesemer、Rob Pike 和 Ken Thompson 在 Google 一起开始设计 Go。Ian Lance Taylor 是早期贡献者。2008 年,他根据 Go 的规范草案,独立给 GCC 写了能读取 Go 源码的编译器前端。

Go 开源前,团队有一次开会讨论怎么让函数收尾。据参会的 Pike 后来回忆,defer 这个名字是 Taylor 在会上提出来的。代码仓库还保留了接下来的两步。2009 年 1 月,Griesemer 把 defer 的规则写进规范草案,Thompson 在紧接着的一个提交里把它加进了编译器和运行时。

C 和 C++ 早就碰到过同样的问题。Linux 内核的 C 编码规范介绍了一种做法,让几个出错的分支通过 goto 跳到函数末尾同一段清理代码。C++ 则可以用一个局部对象管理文件。对象离开它所在的代码块时,会自动运行负责清理的代码,这段代码叫析构函数。C++ 设计者 Bjarne Stroustrup 在自己的 FAQ 里,用的恰好也是关闭文件的例子。

这段历史能解释为什么 Go 让你写下 defer src.Close()。你直接指定收尾时要做的动作,再由 Go 把它接到这次函数调用的退出路径上。

用 100 毫秒看清 defer 推迟了什么

defer src.Close() 推迟的是关闭。要是写成 defer fmt.Println(time.Since(start)),计算耗时也会推迟吗?

time.Now() 记下开始时间。time.Since(start) 每次被调用时,都会计算从开始到这一刻经过了多久。下面用等待 100 毫秒来模拟函数中间的工作。把程序保存成 main.go,运行 go run main.go。

go 复制代码
package main

import (
	"fmt"
	"time"
)

func main() {
	start := time.Now()

	defer fmt.Println("登记时计算:", time.Since(start))

	defer func() {
		elapsed := time.Since(start)
		fmt.Println("退出时计算:", elapsed)
	}()

	time.Sleep(100 * time.Millisecond)
}

一次实际运行的输出是:

text 复制代码
退出时计算: 101.052083ms
登记时计算: 18.208µs

具体数字每次可能不同。两行都在函数退出时打印,只有第一行的数字算进了中间的等待。

直接传参的写法里,Go 执行到 defer 那一行时,就要先计算 fmt.Println 的参数。把这一行拆开,相当于先把刚算出的数字存下来:

go 复制代码
elapsed := time.Since(start)
defer fmt.Println("登记时计算:", elapsed)

另一个写法里的 func() { ... } 定义了一个没有名字的函数。紧跟在 func 后面的 () 是空的参数列表,最后的 () 表示调用这个函数。defer 推迟的是这次调用。因为 time.Since(start) 写在函数体里,等到退出时才会计算。

flowchart LR A[记下开始时间] --> B[计算并保存第一个时长] B --> C[等待 100 毫秒] C --> D[退出时重新计算并打印] D --> E[打印登记时保存的时长]

匿名函数后登记、先执行,所以较大的数字先打印。登记时计算参数、退出时按相反顺序执行,这两条规则在 2009 年的规范草案里就已经写下了。

有垃圾回收,为什么文件还得关闭?

程序里有一个代表文件的值,操作系统也要为这次打开保留一份记录。回收内存和关闭文件可以发生在不同的时刻。比如你只想读第一行,读完就可以关掉文件。垃圾回收器什么时候运行,是另一回事。

语言 内存怎么回收 文件什么时候关闭
Go 垃圾回收器回收不再使用的 Go 内存。 写下 defer file.Close() 后,当前函数退出时调用 Close()。
Java 同样由垃圾回收器回收不再使用的对象所占的内存。 把文件放进 try-with-resources,离开这段 try 代码时,Java 会自动调用 close()。读取出错也会关闭。
Rust 对 String 这类值,持有它的局部变量离开代码块时,Rust 会释放它占用的内存。 局部变量持有的 File 也按这套规则关闭。如果把文件交给另一个变量,关闭责任也跟着转移。

Rust 把「谁负责管理这个值」叫作所有权。在关闭局部文件这个例子里,它和前面的 C++ 写法相近,清理跟着持有文件的值走。回到 Go,defer 跟着的是当前函数。把它写在 if 里,走出 if 的花括号时,清理还没发生。

这个区别在循环里很实际。如果一个函数循环打开一百个文件,每次都写 defer file.Close(),关闭操作会等到整个函数退出才执行。想每处理完一个就关闭一个,可以把单个文件的处理放进 processFile(path),在那个函数里登记关闭。每次 processFile 返回,当前文件就会关闭。

十多年后,一份提交给 C 标准委员会的提案写明,它的 defer 受到了 Go 的启发。提案作者也讨论了应该等函数结束,还是离开眼前这段代码就收尾。同一个词背后,这个选择会决定循环里打开的第一个文件,要不要等最后一个文件处理完才关。

参考与数据源

相关推荐
苏三说技术32 分钟前
DeepSeek Harness入驻B站,评论区被干成了大型插件集市!
后端
橙序员小站1 小时前
我一句话让 AI 把公众号工作台搭完了:部署实测记录
后端
Patrick_Wilson1 小时前
从文本输出理解 Go 方法与接口
后端·go·编程语言
QCoding2 小时前
企业级Agent Evaluation实战:Agent能跑就能上线?AI Agent到底怎么测?
后端·spring
苍何2 小时前
让 WorkBuddy 操控浏览器,干活有点猛啊1
后端
IT_陈寒3 小时前
当Java的hashCode遇上Lombok,我的缓存被全清了
前端·人工智能·后端
XW3 小时前
常用提示词(Prompt)框架总结
后端
ServBay3 小时前
DeepSeek 再次崩了,DeepSeek V5正在路上?
后端·deepseek
IT规划师4 小时前
编程术语中的同步与异步:从"等不等"到工程取舍
后端·面试