go

Go 系列 闭包

闭包仅仅是锦上添花的东西,不是不可或缺的。

Posted by lichao modified on September 16, 2021

闭包是函数式语言中的概念。闭包是由函数和与其相关的引用环境组合而成的实体。(即:闭包=函数+引用环境)

1
2
3
4
5
6
7
8
9
10
11
func f(i int) func() int {
    return func() int {
        i++
        return i
    }
}

c1 := f(0)
c2 := f(0)
c1()    // reference to i, i = 0, return 1
c2()    // reference to another i, i = 0, return 1

函数与环境

函数 f 返回了一个函数,返回的这个函数就是一个闭包。这个函数中本身是没有定义变量 i 的,而是引用了它所在的环境(函数f)中的变量 i

c1c2 引用的是不同的环境,在调用 i++ 时修改的不是同一个 i,因此两次的输出都是 1。函数 f 每进入一次,就形成了一个新的环境,对应的闭包中,函数都是同一个函数,环境却是引用不同的环境。闭包其实不再封闭,全局可见的变量的修改,也会对闭包内的这个变量造成影响。

变量 i 是函数 f 中的局部变量,假设这个变量是在函数 f的栈中分配的,是不可以的。因为函数 f 返回以后,对应的栈就失效了, f 返回的那个函数中变量 i 就引用一个失效的位置了。所以闭包的环境中引用的变量不能够在栈上分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func foo1(i *int) func() {
 f:= func() {
  *i = *i + 1
  fmt.Printf("foo1 val = %d\n", *i)
 }
 return f
}

func foo2(i int) func() {
 return func() {
  i = i + 1
  fmt.Printf("foo2 val = %d\n", i)
 }
}

x := 133
f1 := foo1(&x)
f2 := foo2(x)
f1() // foo1 val = 134
f2() // foo2 val = 134
f1() // foo1 val = 135
f2() // foo2 val = 135

x = 233
f1() // foo1 val = 234
f2() // foo2 val = 136
f1() // foo1 val = 235
f2() // foo2 val = 137

foo1(&x)() // foo1 val = 236
foo2(x)() // foo2 val = 237
foo1(&x)() // foo1 val = 237
foo2(x)() // foo2 val = 238
foo2(x)() // foo2 val = 238

定义了x=133之后,获取得到了 f1=foo1(&x)f2=foo2(x)。这里f1\f2就是闭包的函数,也就是foo1()\foo2()的内部匿名函数。而闭包的环境即外部函数foo1()\foo2()的变量i

闭包的延迟绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func foo7(x int) []func() {
    var fs []func()
    values := []int{1, 2, 3, 5}
    for _, val := range values {
        fs = append(fs, func() {
            fmt.Printf("foo7 val = %d\n", x+val)
        })
    }
    return fs
}
f7s := foo7(11)
for _, f7 := range f7s {
    f7()
}

//foo7 val = 16
//foo7 val = 16
//foo7 val = 16
//foo7 val = 16

for-loop声明了一组闭包,其中所有闭包的外部环境是同一组(val,x)变量,在执行闭包(执行 f7())的时候寻找外部环境最新的值(很显然,val的最新的值是5,x的值为11),所以这组闭包的返回值都是 16。

1
2
3
4
5
6
7
8
9
10
func foo0() func() {
    x := 1
    f := func() {
        fmt.Printf("foo0 val = %d\n", x)
    }
    x = 11
    return f
}

foo0()() // 11

这就是闭包的神奇之处,闭包会保存外部引用环境,也就是说,val 这个变量在闭包内的生命周期得到了保证。

Go Routine的延迟绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func foo5() {
    values := []int{1, 2, 3, 5}
    for _, val := range values {
        go func() {
            fmt.Printf("foo5 val = %v\n", val)
        }()
    }
}

foo5()
//foo3 val = 5
//foo3 val = 5
//foo3 val = 5
//foo3 val = 5

其实这个问题的本质同闭包的延迟绑定,或者说,这段匿名函数的对象就是闭包。

逃逸分析 escape analyze

Go 语言能通过 escape analyze 识别出变量的作用域,自动将变量在堆上分配。将闭包环境变量在堆上分配是 Go 实现闭包的基础。

1
2
3
4
5
6
7
8
9
type Cursor struct {
 X int64
} 
func f() *Cursor {
    var c Cursor
    c.X = 500
    // ...
    return &c
}

Cursor 是一个结构体,这种写法在 C 语言中是不允许的,因为变量c是在栈上分配的,当函数f返回后c的空间就失效了。但是在 Go 语言规范中有说明,这种写法在 Go 语言中合法的。语言会自动地识别出这种情况并在堆上分配 c 的内存,而不是函数 f 的栈上。

为了验证这一点,可以观察函数f生成的汇编代码:

1
2
3
4
5
6
7
8
9
MOVQ    $type."".Cursor+0(SB),(SP)    // 取变量c的类型,也就是Cursor
PCDATA    $0,$16
PCDATA    $1,$0
CALL    ,runtime.new(SB)    // 调用new函数,相当于new(Cursor)
PCDATA    $0,$-1
MOVQ    8(SP),AX    // 取c.X的地址放到AX寄存器
MOVQ    $500,(AX)    // 将AX存放的内存地址的值赋为500
MOVQ    AX,"".~r0+24(FP)
ADDQ    $16,SP

识别出变量需要在堆上分配,是由编译器的一种叫 escape analyze 的技术实现的。如果输入命令:

1
go build --gcflags=-m main.go

可以看到输出:

1
2
./main.go:20: moved to heap: c
./main.go:23: &c escapes to heap

表示 c 逃逸了,被移到堆中。escape analyze 可以分析出变量的作用范围,这是对垃圾回收很重要的一项技术。

闭包结构体

返回闭包时并不是单纯返回一个函数,而是返回了一个结构体,记录下函数返回地址和引用的环境中的变量地址。

参考文献

[1] 闭包的实现