【新人系列】Golang 入门(七):闭包详解

✍ 个人博客:https://blog.csdn.net/Newin2020?type=blog

📝 专栏地址:https://blog.csdn.net/newin2020/category_12898955.html

📣 专栏定位:为 0 基础刚入门 Golang 的小伙伴提供详细的讲解,也欢迎大佬们一起交流~

📚 专栏简介:在这个专栏,我将带着大家从 0 开始入门 Golang 的学习。在这个 Golang 的新人系列专栏下,将会总结 Golang 入门基础的一些知识点,并由浅入深的学习这些知识点,方便大家快速入门学习~

❤️ 如果有收获的话,欢迎点赞 👍 收藏 📁 关注,您的支持就是我创作的最大动力 💪

1. 快速了解

在 Go 语言中,闭包是一个函数值,它引用了其函数体之外的变量。闭包能够访问和操作在其定义的上下文中存在的自由变量,即使外部函数已经返回,闭包仍能记住并访问这些变量。

现在有个需求,需要每调用一次函数,返回的结果值就是增加一次之后的值。

go 复制代码
func autoIncrement() func() {
    local := 0
    return func() int {
        local += 1
        return local
    }
}

//调用函数
nextFunc := autoIncreament()
//1 2 3 4 5
for i := 0; i < 5; i++ {
    fmt.Println(nextFunc())
}

在上述示例中,autoIncrement 函数返回了一个匿名函数,这个匿名函数构成了闭包。它能够访问并修改外部函数中的变量 local ,每次调用 autoIncrement 函数时,local 的值都会增加并返回。

为了更好的理解闭包概念,我们接下来由浅入深的剖析一下调用闭包时,栈帧上会发生些什么。

2. function value

在 go 语言中函数是头等对象,它可以作为参数传递,可以作为函数返回值,也可以绑定到变量。而 go 语言会称这样的参数、返回值或变量为 function value。

go 复制代码
func A() {
    // ...
}

// 作为参数传递
func B(f func()) {
    // ...
}

// 作为函数返回值
func C() func() {
    return A
}

// 绑定到变量
var f func() = C()

函数的指令会在编译期间生成,而 function value 本质上是一个指针,但并不直接指向函数指令的入口。而是指向一个 runtime.funcval 结构体,这个结构体里只有一个地址,就是这个函数指令的入口地址。

为了更深刻的理解这个内容,我们来举一个完整的例子,假设现在函数 A 被赋值给了 f1 和 f2 两个变量。

go 复制代码
func A(i int) {
    i++
    fmt.Println(i)
}
func B() {
    f1 := A
    f1(1)
}
func C() {
    f2 := A
    f2(1)
}

上面这种情况编译器就会做出优化,会让 f1 和 f2 共用一个 funcval 结构体,我们假设函数 A 的指令在下图代码段的这个位置,其入口地址为 addr1。

那么在编译阶段,就会在只读数据段分配一个 funcval 结构体,fn 指向函数 A 的指令入口。

而 fn 本身的起始地址 addr2 就会在执行阶段赋值给 f1 和 f2,如果通过 f1 来执行函数,则会通过它存储的地址找到对应的 funcval 结构体拿到函数的入口地址,然后再跳转执行。

再来看代码中 f1 函数的执行,我们传入的参数 i 为 1,执行到 A 函数中 i 会自增 1,那么下一步就会输出 2,而 f2 的调用完全相同。

那么问题来了,既然只要有函数入口地址就能调用,为什么还要通过 funcval 结构体包装这个地址,然后使用一个二级指针来调用呢?

这里主要是为了处理闭包的情况,我们再具体来看看维基百科是如何定义闭包的。这里有两个关键点需要我们注意:

  • 必须要有在函数外部定义,但在函数内部引用的 "自由变量"
  • 即便脱离了闭包的上下文,闭包也能照常使用这些自由变量

3. 不修改捕获变量

我们还是来看个例子,函数 create 的返回值是一个函数,但这个函数内部使用了外部定义的变量 c,即使 create 执行结束了,通过 f1 和 f2 依然能够正常调用这个闭包函数,并使用定义在 create 函数内部的局部变量 c。

所以下面代码中 return func() 这里符合闭包的定义,通常我们称这个变量 c 为捕获变量。

简单来说就是编译器在触发闭包条件时,会把这些东西扔到内存堆中,不会在函数结束后就释放它们。

go 复制代码
func create() func(){
    c := 2
    return func() int{
        return c
    }
}
func main(){
    f1 := create()
    f2 := create()
    fmt.Println(f1())
    fmt.Println(f2())
}

闭包函数的指令自然也在编译阶段生成,但因为每个闭包对象都要保存自己的捕获变量,所以要到执行阶段才会创建对应的闭包对象。

我们还是来看看栈帧分布情况,到 main 函数的执行阶段,存在两个局部变量,会分别初始化为空值。接着是返回值空间,目前同样初始化为空值。然后到了 create 函数栈帧这里,存在一个局部变量且初始化为 2。

create 函数还会在堆上分配一个 funcval 结构体,fn 指向闭包函数入口地址 addr1。除此之外,还有一个捕获列表,这里只捕获了一个变量 c。

然后这个结构体的起始地址 addr2,就被作为返回值写入返回值空间,因此执行完 f1 := create() 这行代码后,f1 就会被赋值为 addr2。

下面 f2 := create() 再次调用了 create 函数,它就会再次创建一个 funcval 结构体,同样会捕获变量 c。然后这个 funcval 结构体的起始地址 addr3 就会作为返回值写入到返回值空间,并最终赋值给 f2。

通过 f1 和 f2 调用闭包函数,就会找到各自对应的 funcval 结构体,从而就会拿到同一个函数的入口。但是 f1 和 f2 在调用闭包函数时,使用的捕获变量会不一样,会使用各自 funcval 结构体中的捕获变量,这就是称闭包为有状态的函数的原因。

那么问题又来了,闭包函数究竟如何找到对应的捕获列表呢?

在 go 语言中,通过一个 function value 调用函数时,会把对应的 funcval 结构体地址存入特定的寄存器。例如,amd64 平台使用的是 DX 寄存器。

这样在闭包函数中,就可以通过寄存器取出 funcval 结构体的地址,然后加上相应的偏移来找到每一个被捕获的变量。

因此在 go 语言中,闭包就是有捕获列表的 function value,而没有捕获列表的 function value 就直接忽略掉这个特定寄存器的值就好。

4. 修改捕获变量

最后再来看看这个捕获列表,它可不是拷贝变量值这么简单,被闭包捕获的变量要在外层函数与闭包函数中表现一致,好像它们在使用同一个变量。为此,go 语言的编译器针对不同的情况做了不同的处理。

最简单的情况就像上面这个例子,被捕获的变量除了初始化赋值外,在任何地方都没有被修改过,所以直接拷贝到捕获列表中即可。

go 复制代码
func create() func(){
    c := 2
    // 除了初始化外,并没有修改被捕获的变量c
    return func() int{
        return c
    }
}
func main(){
    f1 := create()
    f2 := create()
    fmt.Println(f1())
    fmt.Println(f2())
}

但是如果除了初始化赋值外,还被修改过的话,那就要再做细分。我们再来看一个例子,在下面这个例子中,被捕获的是局部变量 i,并且除了初始化赋值外还被修改过,来看看这种情况会怎么处理。

go 复制代码
fun create() (fs[2]func()) {
    for i := 0; i < 2; i++ {
        fs[i] = func() {
            fmt.Println(i)
        }
    }
    return
}
func main() {
    fs := create()
    for i := 0; i < len(fs); i++ {
        fs[i]()
    }
}

假设闭包函数指令入口地址在代码段的 addrf 位置,main 函数栈帧中局部变量 fs 是一个长度为 2 的 functionvalue 类型数组,所以返回值也会分配 2 个位置,并且都会先初始化为空值。

再来看看 create 函数栈帧,由于被闭包捕获,局部变量 i 会改为堆分配,在栈上只会存一个地址。执行到 create 第一次 for 循环,就会在堆上创建 funcval 结构体,并捕获 i 的地址,这样闭包函数就和外层函数操作同一个变量了。

因此返回值就会得到该 funcval 结构体的起始地址 addr0,然后 i 自增 1,此时第一次 for 循环结束。

接下来第二次 for 循环开始,和上面的步骤一样,这里会再次到堆上面创建一个 funcval 结构体,并捕获变量 i 的地址,因此第二个返回值的位置就会存储该 funcval 的起始地址 addr1。然后第二次循环结束,i 再次自增 1。

此时就满足了循环退出的条件,create 函数执行结束,于是就会将返回值拷贝到局部变量 fs 中。

当 fs[0] 调用函数时,就会把 addr0 存入到特定的寄存器中,然后闭包函数会通过寄存器存储的地址并加上偏移找到捕获变量 i 的地址。

当 fs[1] 调用函数时同理,会把 addr1 存入到特定的寄存器中,闭包函数就会通过寄存器中的地址并加上偏移从而找到对应 funcval 中的捕获变量 i 的地址。

从上面两步可以看出,fs 每次调用函数,闭包函数最终得到的 i 的地址都是相同的,因此每次打印时输出都为 2。

闭包导致的局部变量堆分配,也是变量逃逸的一种场景。但如果修改并且被捕获的是参数,涉及到函数原型,就不能像局部变量那样处理了。

不过参数依然是通过调用者栈帧传入,但是编译器会把栈上的这个参数拷贝到堆上一份,然后外层函数和闭包函数都使用堆上分配的这一个。

如果被捕获的是返回值,处理方式就又有些不同,调用者栈帧上依然会分配返回值空间,不过闭包的外层函数也会在堆上分配一个,外层函数和闭包函数都使用堆上这一个,但是在外层函数返回前,需要把堆上的返回值拷贝到栈上的对应返回值空间。

处理方式虽然多样,但是目标只有一个,就是保持捕获变量在外层函数与闭包函数中的一致性。并且细致分析的话也可以发现,这几种处理方式起始也都比较类似,就是在堆上保存被捕获的值,防止在函数结束时被释放了。

本小节关键点:

  • go 语言中的 function value 本质上是指向 funcval 结构体的指针
  • go 语言中的闭包只是拥有捕获列表的 function value
  • 捕获变量在外层函数与闭包函数中要保持一致
相关推荐
欣然~2 分钟前
基于蒙特卡洛方法的网格世界求解
开发语言·python·信息可视化
海晨忆6 分钟前
JS—事件委托:3分钟掌握事件委托
开发语言·javascript·ecmascript·事件委托·事件冒泡
盖世英雄酱5813611 分钟前
JDK24 它来了,抗量子加密
java·后端
froxy25 分钟前
C++11 引入了的新特性与实例说明
开发语言·c++
珹洺27 分钟前
Java-servlet(七)详细讲解Servlet注解
java·服务器·开发语言·hive·servlet·html
珊瑚里的鱼31 分钟前
第一讲 | 解锁C++编程能力:基础语法解析
开发语言·c++·笔记·visualstudio·学习方法·visual studio
goTsHgo43 分钟前
完整的类在JVM中的生命周期详解
java·开发语言·算法
xhaoDream43 分钟前
NFS客户端与服务端用户不一致问题
linux·服务器·经验分享
Asthenia04121 小时前
无感刷新的秘密:Access Token 和 Refresh Token 的那些事儿
前端·后端
大模型铲屎官1 小时前
玩转C#函数:参数、返回值与游戏中的攻击逻辑封装
开发语言·游戏·c#·编程·参数·函数·返回值