假设你要复制一个文件。源文件打开了,目标目录却不存在,目标文件创建失败。你准备写下 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) 写在函数体里,等到退出时才会计算。
匿名函数后登记、先执行,所以较大的数字先打印。登记时计算参数、退出时按相反顺序执行,这两条规则在 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 的启发。提案作者也讨论了应该等函数结束,还是离开眼前这段代码就收尾。同一个词背后,这个选择会决定循环里打开的第一个文件,要不要等最后一个文件处理完才关。
参考与数据源
- Go 官方博客中的文件复制示例
- Go 官方 FAQ 中的项目历史
- Robert Griesemer 添加 defer 规范的提交
- Ken Thompson 实现 defer 的提交
- Rob Pike 对 defer 命名的回忆
- Linux 内核编码规范中的集中清理
- Bjarne Stroustrup 的 C++ FAQ
- Go 语言规范中的 defer 语句
- Go 标准库 time.Since 文档
- Go 垃圾回收指南
- Java 垃圾回收指南
- Java 语言规范中的 try-with-resources
- Rust 官方教程中的所有权
- Rust 标准库 File 文档
- C 标准委员会提案 N2895