测试基础 GoLang 闭包,注意!这里有蹊跷

TesterHome小助手 · 2023年03月09日 · 3460 次阅读

来源:TesterHome 投稿
作者:邹靓

背景:

笔者前段时间的工作中,某业务因为一行闭包错误使用的代码,引起了一次 “血案”。闭包是一个函数及其相关的引用环境,可以捕获和记住函数定义时的状态信息并在函数执行时使用。

看一个例子:生活中我们在快餐店点餐时都会有点餐的号码,一般从 1 开始计数,每次顾客点餐时编号都会增加 1,当两个顾客同时点餐的时候为了避免他们可能会得到相同的编号,就可以使用到闭包。Go 语言提供了对闭包的支持,上述例子可以看到它在某些场景下是有作用的,但如果不小心,还是会踩坑的哦~

本文会从 golang 闭包的基础知识、常见踩坑指南等方向解析,如果你也遇到过相同的问题、为之困惑,欢迎阅读本文,跟我一起重新认识闭包正确使用姿势~

故障现场还原:

假设一个抽奖功能的设计实现:所有的抽奖配置项都放在 list 中,需要时循环取出使用,看这段伪代码,大家猜猜最终的运行结果会是什么?

func main() {
    // 保存函数闭包
    var s []func()

    for _, v := range []string{"a", "b", "c", "d", "e"} {
        s = append(s, func() {
            // 捕获v, 保存在闭包中
            fmt.Printf("value: %v\n", v)
        })
    }

    for _, f := range s {
        f()
    }
}

上述伪代码主要功能是想通过 for 循环 a、b、c、d、e 并保存在闭包中,后续使用中从闭包中取出,逻辑上似乎没毛病!不过,运行结果并不是 b、c、d、e,而是:

value: e
value: e
value: e
value: e
value: e

这样的结果是怎么产生的呢?

每次 append 操作仅将函数闭包放入到函数列表中,但并未执行,闭包中捕获的 v 不是"值", 而是"有地址的变量"(如图 1 所示),且创建闭包时,循环变量的值已经被确定,并与闭包关联。当闭包被调用时,它使用捕获的值,而不是当前值,解决的关键就在于重新声明变量,这样每个闭包都有自己的变量,能够正确地访问其所需的值(如图 2 所示)。


图 1


图 2

正确的使用姿势是什么呢?

需重新声明要捕获的变量 v,由于是单个循环的局部变量, 在其作用域结束前, 会进行 evaluate(求值)[1],而每次循环重新求值的变量 v 就会传到匿名函数中,这样就让闭包的每次的环境变量 v 不相同 [2]。

伪代码就可以修改如下,在闭包函数前重新声明变量 v(图中红框所示):

修改后的结果也成为了我们预期的结果:

value: a
value: b
value: c
value: d
value: e

闭包的使用是非常灵活的,不当的使用容易产生意外的 bug,在工作中如果真的发生了,首先检查闭包内变量的作用域范围,选择是否重新声明变量使每个闭包拥有单独变量作用域,问题或许就迎刃而解了!

其他拓展:

1、golang 闭包究竟是何方 “神圣”?

说闭包之前,首先要知道什么是匿名函数。

匿名函数(英语:Anonymous Function)在计算机编程中是指一类无需定义标识符(函数名)的函数或子程序,普遍存在于多种编程语言中 [3]。简单来说就是指不需要定义函数名的一种函数实现方式。 匿名函数是由一个不带函数名的函数声明和函数体组成。匿名函数的优越性在于可以直接使用函数内的变量,不必声明所以被广泛使用 [4]。

根据文字定义描述如果还没办法很形象的了解到具体什么,那么话不多说,上代码示例,如下面代码示例图中,红框即匿名函数,在一个函数内部可以定义另一个函数:

说了这么多匿名函数,到底跟闭包有什么关系呢?

关于闭包的定义存在以下广泛流传的公式:闭包=函数 + 引用环境。函数指的是匿名函数,引用环境指的是编译器发现闭包,直接将闭包引用的外部变量在堆上分配空间;当闭包引用了函数的内部变量(即局部变量)时,每次调用的外部变量数据都会跟随闭包的变化而变化,闭包函数和外部变量是共享的 [5]。显然,闭包只能通过匿名函数实现,我们可以把闭包看作是有状态的匿名函数,反过来,如果匿名函数引用了外部变量,就形成了一个闭包 [6]。

举个例子具体说明一下:

func main() {
     i := 0

     // 闭包: i是引用传递(有地址的变量)
     defer func() {
         fmt.Println("defer closure i:", i)
     }()

     // 非闭包: i是值传递
     defer fmt.Println("defer i:", i)

     // 修改i的值
     i = 100

     return
 }

 // 输出
 defer i: 0
 defer closure i: 100

从以上例子可以看出,闭包是引用传递,所以 Golang 中使用匿名函数的时候要特别注意区分清楚引用传递和值传递。根据实际需要,我们在不需要引用传递的地方通过匿名函数参数赋值的方式实现值传递 [7]。

2、为什么要使用闭包?

从上面例子中不难发现,使用普通函数和全局变量也可以实现和闭包相同的功能,那为什么不用全局变量呢?首先,全局变量的作用域是整个包,而闭包缩小变量作用域,减少对作用域的污染。同时,如果我要实现 n 个闭包功能,如果使用全局变量,就需要维护 n 个函数对应的全局变量,而使用闭包只需要维护对应函数闭包即可。

因此,闭包使用场景众多,可以满足不同场景使用,例如 [8]:

  • 隔离数据使用:假设想创建一个函数,该函数可以访问即使在函数退出后仍然存在的数据的时候使用。
  • 搭配 defer 使用:往 defer 里传入一个闭包,虽然是值传递,但是拷贝的是函数指针,可以解决一些使用 defer 会立刻拷贝函数中引用的外部参数引起的时机问题。
  • 保证局部变量的安全性:匿名函数内部声明的局部变量无法从外部修改,从而确保了安全性(类似类的私有属性)。
  • 将闭包作为函数参数使用:闭包除了可以赋值给普通变量外,还可以作为参数传递到函数中进行调用,就像普通数据类型一样。
  • 将闭包作为函数返回值使用:我们可以通过将函数返回值声明为闭包函数类型来实现业务逻辑的延迟执行,让执行时机完全掌握在开发者手中。

而在这些使用方式中,四类场景是必须使用闭包才可以实现的,例如:

  • 回调函数:闭包可以用作回调函数,在异步编程中,它可以捕获外部函数的上下文。
  • 私有数据:闭包可以捕获函数内部的数据,并且对外部不可见。这是一种创建私有数据的方法。
  • 延迟计算:闭包可以延迟计算,直到闭包被调用时才执行计算。
  • 高阶函数:闭包可以用作高阶函数的参数,并在调用时返回新的函数。

虽然闭包有很多方便使用的场景,但是在使用过程中还是会遇到很多 “坑”,下面会具体介绍常见的 “坑” 以及避坑指南。

3、闭包使用还有什么坑?应该如何有效规避?

在使用闭包过程中除了本文开头的使用函数闭包列表不当的场景,还有以下 2 种常见场景容易出坑 [9]:

  • for range 使用闭包不当:
s := []int{1, 2, 3}
for _, v := range s {
    go func() {
        fmt.Println(v) // 输出结果3 3 3
    }()
}
select {}

无法得到预期结果 1,2,3 的原因是在没有将变量 v 的拷贝值传进匿名函数之前,只能获取最后一次循环的值,是新手最容易遇到的坑之一。 有效规避方式为每次将变量 v 的拷贝传进函数:

s := []int{1, 2, 3}
 for _, v := range s {
     go func(v int) {
         fmt.Println(v) // 输出结果1,2,3
     }(v)
 }
 select {}
  • defer 调用闭包不当:
package main

import "fmt"

func main() {
    x, y := 1, 2

    defer func(a int) { 
        fmt.Printf("x:%d,y:%d\n", a, y)  // y 为闭包引用,最终结果为x1y102
    }(x)      // 复制 x 的值

    x += 100
    y += 100
}

无法得到期待的结果 x:1,y:2 的原因是:defer 调用会在当前函数执行结束前才被执行,这些调用被称为延迟调用,而 defer 中使用匿名函数是一个闭包,y 为闭包引用的外部变量会跟着闭包环境变化,当延迟调用时 y 已经变成 102,所以最终输出的 y 也不再是 2 了。

有效规避方式只需要去掉 defer 即可:

package main

import "fmt"

func main() {
    x, y := 1, 2

    func(a int) {
        fmt.Printf("x:%d,y:%d\n", a, y)  // y 为闭包引用,最终结果为x1y2
    }(x)      // 复制 x 的值

    x += 100
    y += 100
    fmt.Println(x, y)
}

总结:

经过本次闭包的踩坑学习了解到闭包虽然可以实现私有变量、访问外部作用域的变量和保存函数执行上下文等功能。但同时滥用闭包可能导致内存泄漏和性能问题等问题,所以使用闭包时需要注意理解、避免滥用、注意变量作用域和生命周期、避免在循环中使用、注意变量修改和适当使用。

参考资料:
[1]https://blog.syzh.fun/posts/syzh/golang-pitfall-for-closure/
[2]https://studygolang.com/articles/35691
[3]https://zh.wikipedia.org/wiki/%E5%8C%BF%E5%90%8D%E5%87%BD%E6%95%B0
[4]https://juejin.cn/post/6999107726773059591
[5]https://juejin.cn/post/7029743304895889421
[6]https://geekr.dev/posts/go-anonymous-function-and-closure
[7]https://zhuanlan.zhihu.com/p/351428978
[8]https://geekr.dev/posts/go-anonymous-function-and-closure
[9]https://www.jianshu.com/p/fa21e6fada70

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册