Golang 闭包学习
引言
在平常的 Go 语言开发中,常常需要将一段函数逻辑封装起来,异步执行、作为回调传递,甚至保持某些运行时状态。此时,闭包成为一种非常自然的编程手段。它允许我们在函数内部“记住”外部作用域中的变量,从而实现变量状态与行为绑定的能力,是函数式编程思想在 Go 中的重要体现。
这里来介绍一点闭包的场景与相关内容
闭包的定义
闭包(Closure) 是一个 函数值,它"捕获"了其外部作用域中的变量,即使这个变量在函数定义时就已经离开了原来的作用域,仍然可以被访问和使用。
换句话说:
- 闭包 = 函数 + 外部变量的引用环境
- 闭包就是一个"记住了外部变量"的函数,它在自己作用域外面也能继续用这些变量。
闭包的使用案例与理解
闭包的示例
func main() {a := 10add := func(x int) int {return a + x // 闭包:引用了外部变量 a}fmt.Println(add(5)) // 输出 15
}
返回闭包的函数
func adder() func(int) int {sum := 0return func(x int) int {sum += x // 闭包引用了外部变量 sumreturn sum}
}func main() {f := adder()fmt.Println(f(1)) // 1fmt.Println(f(2)) // 3fmt.Println(f(3)) // 6
}
这里返回的函数是一个闭包,它记住了变量 sum 的状态,即使 adder() 函数已经返回,sum 仍然存在。
变量捕获机制
这里将介绍 Go 语法作用域的一个陷阱。
场景是:你被要求首先创建一些目录,再将目录删除。
下面是我们实际使用的案例
var rmdirs []func()
for _, d := range tempDirs() {os.MkdirAll(dir, 0755) rmdirs = append(rmdirs, func() {os.RemoveAll(dir)})
}
for _, rmdir := range rmdirs {rmdir()
}
- 其实上面的语句对于删除目录的操作是有问题的,问题的原因在于循环变量的作用域。在上面的程序中,for 循环语句引入了新的词法块,循环变量 dir 在这个词法块中被声明。在该循环中生成的所有函数值都共享相同的循环变量。需要注意,函数值中记录的是循环变量的内存地址,而不是循环变量某一时刻的值。
- 以 dir 为例,后续的迭代会不断更新dir的值,当删除操作执行时,for 循环已完成,dir 中存储的值等于最后一次迭代的值。这意味着,每次对 os.RemoveAll 的调用删除的都是相同的目录。
通常,为了解决这个问题,我们会引入一个与循环变量同名的局部变量,作为循环变量的副本。比如在循环中新增一个局部变量赋值,一般是很有用的
正确的代码如下:
var rmdirs []func()
for _, d := range tempDirs() {dir := dos.MkdirAll(dir, 0755) rmdirs = append(rmdirs, func() {os.RemoveAll(dir)})
}
for _, rmdir := range rmdirs {rmdir()
}
闭包与协程结合
下面结合这样一个问题去想想改怎么实现,假设我们要处理一组任务,每个任务有自己的参数,比如一个任务 ID 或文件名,我们希望:
- 同时并发执行(
goroutine
) - 每个任务携带自己的数据
- 任务逻辑是可配置的(函数式传入)
- 执行完后汇总处理结果
那么用闭包实现的代码就可以是这样的
package mainimport ("fmt""sync""time"
)
type Task func() string // 定义了一个任务类型,每个任务返回一串字符串func main() {// 模拟任务的输入数据:每个 id 代表一个任务。你可以理解为模拟一批任务 ID,例如用户 ID、订单号等。taskInputs := []int{101, 102, 103, 104} // 创建一个 WaitGroup,用于等待所有 goroutine 执行完成。var wg sync.WaitGroup//创建一个缓冲通道 results,用于收集每个任务的输出结果。缓冲区大小设为任务数量,避免阻塞。results := make(chan string, len(taskInputs)) // 收集结果for _, id := range taskInputs {// 创建一个闭包(返回值是 Task 类型),闭包捕获了当前 id,绑定了任务执行逻辑task := createTask(id)// 预登记一个新的 goroutine,要等它结束wg.Add(1)/*** 启动一个 goroutine,并把 task(闭包函数)作为参数传入* goroutine 内部:* 调用 t() 执行任务* 把结果 result 发送到 results 通道* defer wg.Done() 表示任务完成**/go func(t Task) {defer wg.Done()result := t()results <- result}(task) // 在匿名函数定义完的同时立即调用它(这叫 立即执行函数 / IIFE)}// 等待所有执行完成wg.Wait()close(results)// 汇总结果fmt.Println("All task results:")for r := range results {fmt.Println(" -", r)}
}// 创建任务闭包,捕获参数 id
func createTask(id int) Task {return func() string {time.Sleep(time.Duration(id%3+1) * 300 * time.Millisecond) // 模拟任务耗时return fmt.Sprintf("Task %d done at %v", id, time.Now().Format("15:04:05.000"))}
}
假设不使用闭包的话,对于 for _, id := range taskInputs {…} 这段代码需要这样写
for _, id := range taskInputs {task := createTask(id)wg.Add(1)go runTask(task, results, &wg)
}func runTask(task Task, results chan<- string, wg *sync.WaitGroup) {defer wg.Done()result := task()results <- result
}
当然这样写也有它自己的好处
- 没有闭包
- 逻辑分离清晰
- 所有变量传参,避免变量共享
缺点也是显而易见的
- 参数多了麻烦,每次都要写 task, results, &wg,可读性变差
- 不够灵活,不能随时在 goroutine 里“包”任意变量
- 不适合快速临时逻辑,适合结构清晰的项目封装,但不适合快速写 demo 或内联任务
不过这里并不是一味的推荐都使用闭包来做开发,还是视场景而定,跟随团队开发风格为主,如果闭包会带来不必要的麻烦的话,建议还是使用不闭包的写法会更好一点。
其他风险
内存逃逸
使用闭包开发的同时,闭包非常容易引起内存逃逸,因为闭包常常会捕获外部变量,而这些变量需要在闭包作用域之外继续存活,就会从栈逃逸到堆
什么是逃逸?
Go 编译器会将变量优先分配在栈上(更快),但如果变量在函数返回后仍然要使用,就必须分配在堆上 —— 这就叫内存逃逸。
当你创建一个闭包,并捕获了外部变量,这个变量就必须继续活着 —— 哪怕定义它的函数已经返回了,所以它不能在栈上,而要“逃逸”到堆上。
什么样的场景会容易出现内存逃逸呢?
- 闭包作为函数返回值:如 return func() {…}
- 闭包在 goroutine 中使用外部变量:捕获的变量生命周期超出函数
- 闭包长期保存在结构体、全局变量中:保持状态,变量不能销毁
- for 循环中错误引用外部变量:所有协程共享变量地址,且逃逸
实际场景
func getAdder() func(int) int {sum := 0return func(x int) int {sum += xreturn sum}
}
问题出在这个 sum 变量:
- 本来是 getAdder 函数的局部变量
- 但因为被闭包捕获了,并在函数返回后继续使用
- 所以必须逃逸到堆上
另一个角度看:
闭包变量的逃逸,在这种需要持久状态的函数中,能解决吗?
- 答案是无法“完全避免逃逸”,因为你本质上就是在保存状态,这个状态必须存活在栈之外,所以它必须分配到堆上。
但是我们可以做的事情是:
- 理解为什么逃逸是必要的,不是 bug
- 如果你对性能极度敏感,可以通过其他方式封装状态,让你有更清晰的生命周期管理
- 避免不必要的逃逸,而不是逃避闭包逃逸本身
替代方案
用结构体封装状态
type Adder struct {sum int
}func (a *Adder) Add(x int) int {a.sum += xreturn a.sum
}
总结
闭包与普通函数对比
对比项 | 普通函数 | 闭包 |
---|---|---|
是否有名字 | ✅ 有名字(具名函数) | ✅ 可以是匿名或具名 |
是否捕获外部变量 | ❌ 不会捕获 | ✅ 会捕获外部变量并“记住”它们 |
执行上下文 | 在调用时获取参数 | 在定义时就绑定了外部变量环境 |
是否引起逃逸 | ❌ 一般不会 | ✅ 捕获变量时容易引起逃逸 |
是否适合封装状态 | ❌ 不适合 | ✅ 闭包可用于保存运行时状态 |
常见使用场景 | 通用逻辑封装、方法实现 | 异步任务、回调函数、延迟执行、变量记忆 |
示例写法 | func add(x, y int) int | func() int { return x + y } |
其他
- 当你需要绑定执行时上下文(变量值)、或返回函数时,闭包非常合适;
- 如果逻辑独立、清晰、有明确输入输出,普通函数是更稳健的选择;
- 在协程中使用闭包时注意变量共享问题,通过参数传递或局部变量副本避免陷阱;
- 注意闭包可能会引起变量逃逸,影响性能,必要时使用结构体封装状态更可控。
总之闭包是一种功能强大但略带隐性的语法特性,它既能提升表达力,也可能隐藏 Bug。在团队协作中,应在理解其行为的基础上谨慎使用,权衡灵活性与可维护性。真正高质量的闭包用法,是能让代码保持优雅、职责清晰,同时避免"魔法"。