我没有考虑过这个问题
起因是微信群里面有一个群友,问了我一嘴这个问题,为啥 Go 实现了函数级的 defer 却没有实现 block defer?我一开始用少就是多搪塞过去了,感觉 Go 就是这么设计的。后面吃晚饭的时候又仔细的考虑了一下,对啊,为啥不这么设计?于是就有了这篇文章。
现有的 defer 机制
其实这个完全可以去看我之前的文章,这里我就再简单的概述一下吧。Go 语言里的 defer 关键字,是个很有特色也很实用的功能A。它能让函数调用延迟执行,常用在清理资源、解锁或者处理错误这些场景。带 defer 的语句,会在所在的函数返回前执行 —— 不管函数是正常结束,还是出了错停下来,它都会跑。这种设计挺好的,能让释放资源的代码和获取资源的代码挨得近,读起来更清楚,用起来也更安全。
但是,Go 语言的 defer 目前仅限于函数级别,即所有 d 语句 defer 都在函数返回时执行,而不是在代码块结束时执行。这就引出了一个有趣的问题:Go 语言是否有必要引入 block defer(块级 defer)机制,使得 defer 可以作用于特定的代码块而非整个函数?
核心特性
Go 的 defer 有下面这么几个特性:
- 执行的时机:defer 语句注册的函数会你包含在这个 defer 的函数返回前执行,这句意味着无论函数是通过正常的 return 返回还是 panic 退出,defer 都能被执行。
- LIFO:多个 defer 语句按照注册顺序会逆序执行
- 参数的预计算:defer 语句中的函数参数再 defer 注册的时候就会被计算,而不是在实际执行这个函数的时候计算
- 作用域:当 go 语言的 defer 作用域是函数级别的,也就是说一旦注册了,defer 语句就会在函数执行的时候返回,而不是在代码块结束的时候执行。
上面的几个特性让 defer 特别适合成双成对的操作,就和找对象一样。比如说打开了文件就得关闭,加了锁你就得解锁,连接了你就得断开,确保资源无论如何都能被正常的释放。
实现机制
go 的 defer 经历了很多个阶段:
- 堆分配:早期的 defer 采用的是堆分配_defer 结构体去管理延迟调用,有很明显的性能开销
- 栈分配:1.13 之后引入了栈分配实现,减少了分配的开销。
- 开放编码:到了 1.14 之后的版本引入了开放编码之后,有了质的飞跃:当满足特定的条件(比如说 defer 数量,不在循环里面去使用)的时候,编译器就直接把 defer 调用插入到函数的返回点,完全消除了运行时的管理开销。
为啥会有 block defer 这个概念?
block defer 指的是在特定的代码块结束的时候执行 defer 语句,而不是等到整个函数结束的时候执行,预期的行为大概是:
- 当程序执行离开某个代码块的时候,立刻就执行这个块里面注册的 defer 语句
- 多个 block defer 同样遵守 LIFO
- block defer 的作用域仅局限于所在的代码块,不会影响到外部的代码块的执行或者是函数的执行
假设我们有下面的这段代码:
func example() {
// 块A开始
{
defer fmt.Println("Block A defer")
// 块A中的代码
} // 块A结束,此时应执行"Block A defer"
// 块B开始
{
defer fmt.Println("Block B defer")
// 块B中的代码
} // 块B结束,此时应执行"Block B defer"
}
在当前的 go 语言里面,上面代码块中的两个 defer 语句都会在 example 函数返回的时候执行,顺序就是:Block B defer >>> Block A defer,如果这个时候我们引入 block defer 机制,输出的顺序就变成了 Bloack A defer >>> Block B defer,也就是再每个块结束的时候立马就执行这个块的 defer 语句。
可能的使用场景
block defer 可能会在下面这些个场景使用:
- 嵌套块的资源管理:比如说函数中有多个嵌套的代码块,而且每个块都需要管理独立的资源的时候,block defer 就能更精准的控制资源释放的时机
- 中间结果缓存:再某个代码块的中间生成的中间结果,可能需要在这个块结束的时候清理,而不是需要等到整个函数的结束
- 临时状态的管理:在块中国设置的临时状态,比如修改全局变量,设定特定的环境之类的,可以用 block defer 确保在块结束的时候恢复原状
- 性能优化:某些资源释放可能不需要等到整个函数结束,提前释放就可以提高内存的使用效率,减少锁竞争等等。
真的需要吗?
支持引入 block defer 的主要观点就是可维护性和可读性。站在这两个观点出发就不难理解为啥我们很需要 block defer 了:
- 资源管理更加精确:block defer 允许资源的时候与获取再代码结构上更加接近,增强了代码的局部性和内聚性。
- 减少嵌套层级:在当前机制下,为了实现块级资源管理,我们通常需要创建额外的嵌套函数,这增加了代码的层级深度。
- 逻辑清晰性:块级defer可以更直观地表达 “在这个块结束时做某事” 的意图,使代码逻辑更加清晰。
就比如下面的代码对比:
func example() {
// 块A开始
func() {
f, err := os.Open("file.txt")
if err != nil {
// 处理错误
}
defer f.Close()
// 块A中的代码
}() // 块A结束,f.Close()在此处执行
// 块B开始
func() {
conn, err := openDBConnection()
if err != nil {
// 处理错误
}
defer conn.Close()
// 块B中的代码
}() // 块B结束,conn.Close()在此处执行
}
使用 block defer 机制可以避免创建这些额外的匿名函数,使代码更加简洁:
func example() {
// 块A开始
{
f, err := os.Open("file.txt")
if err != nil {
// 处理错误
}
defer f.Close() // 块A结束时执行
// 块A中的代码
} // 块A结束,f.Close()在此处执行
// 块B开始
{
conn, err := openDBConnection()
if err != nil {
// 处理错误
}
defer conn.Close() // 块B结束时执行
// 块B中的代码
} // 块B结束,conn.Close()在此处执行
}
一致性与完整性
另一个支持引入 block defer 的观点是语言设计的一致性和完整性:
- 块级作用域的自然扩展:许多编程语言(比如 C++ 的 RAII 机制)都支持块级资源管理,Go 语言引入 block defer 可以使其在这方面更加完善。
- 与现有控制结构的一致性:Go 语言已经支持块级作用域的变量声明,引入 block defer 可以增强这种块级作用域的功能。
- 错误处理的一致性:随着 Go 语言错误处理机制的演进(如 2025 年引入的 try/catch 语法糖),block defer 可以与新的错误处理机制更好地配合。
现有替代方案真的很有效吗?
有效,但不够优雅。
- 嵌套函数替代方案:通过创建匿名函数并立即执行(IIFE,Immediately Invoked Function Expression),可以实现类似块级defer的效果。
- 性能足够:现代 Go 编译器对defer的优化已经使其性能足够高效,块级defer带来的性能提升可能微不足道。
- 现有工具足够:现有的defer机制加上良好的编程习惯,已经能够满足大多数块级资源管理需求。
比如前面提到的需求,就可以用嵌套函数去解决:
func example() {
// 块A开始
func() {
f, err := os.Open("file.txt")
if err != nil {
// 处理错误
}
defer f.Close()
// 块A中的代码
}() // 块A结束,f.Close()在此处执行
// 块B开始
func() {
conn, err := openDBConnection()
if err != nil {
// 处理错误
}
defer conn.Close()
// 块B中的代码
}() // 块B结束,conn.Close()在此处执行
}
这种方式虽然增加了一些代码量,但实现了与 block defer 类似的效果,并且不需要修改语言本身。
普通defer与block defer的实践
嵌套函数的解决方案
在当前 Go 语言中,实现块级资源管理的主要方法是使用嵌套函数:
func main() {
// 块A
{
// 资源获取
f, err := os.Open("file.txt")
if err != nil {
// 处理错误
}
// 使用匿名函数立即执行
func() {
defer f.Close()
// 块A中的代码
}()
// 块A结束,此时f已经被关闭
}
// 块B
{
// 资源获取
conn, err := database.Connect()
if err != nil {
// 处理错误
}
// 使用匿名函数立即执行
func() {
defer conn.Close()
// 块B中的代码
}()
// 块B结束,此时conn已经被关闭
}
}
这种方法的原理是:
- 在需要块级资源管理的位置创建一个匿名函数。
- 在匿名函数内部使用defer注册资源释放操作。
- 立即执行该匿名函数,确保defer在匿名函数返回时执行,即块结束时执行。
这种方法的优点是:
- 完全兼容现有语法:不需要任何语言扩展或新语法。
- 保持defer语义一致性:仍然使用函数级defer,保持了defer机制的一致性和可预测性。
- 工具链友好:现有工具(如 IDE、静态分析工具等)无需修改即可支持这种模式。
缺点是:
- 增加代码嵌套层级:每个需要块级defer的地方都需要额外的函数定义和调用,增加了代码的层级深度。
- 可能影响性能:额外的函数调用可能带来轻微的性能开销,虽然现代 Go 编译器对此有优化。
- 可读性挑战:过多的嵌套函数可能降低代码的可读性,特别是在嵌套层次较深的情况下。
其他方案
除了嵌套函数外,还有一些其他方法可以实现类似块级资源管理的效果:
- 手动管理资源:在块结束前显式调用清理函数,不需要依赖defer:
func main() {
// 块A
{
f, err := os.Open("file.txt")
if err != nil {
// 处理错误
}
// 块A中的代码
f.Close() // 手动关闭文件
}
}
这种方法的缺点是需要手动管理资源释放,容易遗漏,特别是在有多个返回点的情况下。
- 使用defer与条件判断结合:在某些情况下,可以通过条件判断来模拟块级defer的效果:
func main() {
// 块A
{
f, err := os.Open("file.txt")
if err != nil {
// 处理错误
}
defer func() {
if f != nil {
f.Close()
}
}()
// 块A中的代码
f = nil // 提前清理,避免在块结束时关闭
}
}
这种方法的缺点是需要额外的条件判断和变量管理,增加了代码的复杂性。
- 使用第三方库:一些第三方库(如errd)提供了更高级的资源管理功能,可以简化块级资源管理的代码。