天天看点

Go函数--匿名函数与闭包0 匿名函数概念 1 匿名函数2 闭包(Closure) — 引用了外部变量的匿名函数参考

0 匿名函数概念

Go语言提供两种函数:有名函数和匿名函数。所谓匿名函数就是没有函数名的函数。匿名函数没有函数名,只有函数体。它和有名函数的最大区别是:我们可以在函数内部定义匿名函数,形成类似嵌套的效果。

 匿名函数常用于实现回调函数、闭包等。

 1 匿名函数

 1.1 匿名函数的定义格式

func (参数列表) (返回值参数列表) {
    函数体
}
           

 <说明> 匿名函数除了没有函数名外,和普通函数完全相同。

1.2 匿名函数的特点

1、可以在定义匿名函数的同时直接调用执行。示例代码如下:

func main() {
    func(s string) {
        fmt.Println(s)
    }("hello, world")    //传入实参对匿名函数进行调用
}
           

 《代码说明》上面示例中,定义了匿名函数体后,直接传入实参调用匿名函数。

2、可以将匿名函数赋值给函数变量。示例代码如下:

func main(){
    add := func(x, y int) int {      //将匿名函数赋值给函数变量add
        return x + y
    }
    
    fmt.Printf("add type: %T\n", add)  //打印变量add的类型
    fmt.Printf("add(1, 2)= %d\n", add(1, 2))
}
           

运行结果:

add type: func(int, int) int
add(1, 2)= 3
           

 《代码说明》上面示例中,将匿名函数体赋值给一个函数变量add,然后通过这个函数变量来调用匿名函数。

<提示> 将匿名函数赋值给变量,与为普通函数提供函数名标识符有着根本的区别。当然,编译器会为匿名函数生成一个“随机”的符号名。

3、可以将匿名函数作为函数实参。其实这种代码设计方式就是将匿名函数作为回调函数来使用。示例代码如下:

//遍历切片的元素,通过给定函数访问切片元素
func visit(list []int, f func(int)) {
    for _, v := range list {
        f(v)
    }
}

func main() {
    //使用匿名函数打印切片内容
    visit([]int{1,2,3,4}, func(v int){
        fmt.Println(v)
    })
}
           

《代码说明》上面的代码中,使用匿名函数作为函数实参,传递给visit()函数的形参f,形参f是一个函数类型。在visit()函数中,就可以使用函数变量f 访问作为实参的匿名函数了。

<提示> 匿名函数作为回调函数来使用在Go语言的标准包中也是比较常见的。

4、匿名函数可以作为函数返回值使用。示例代码如下:

func test() func(int, int) int {
    return func(x, y int) int {
        return x + y
    }
}

func main() {
    add := test()
    
    fmt.Printf("add type: %T\n", add)       // add type: func(int, int) int
    fmt.Printf("add value: %v\n", add)      // add value: 0x49a800
    fmt.Printf("add(1,2)= %d\n", add(1,2))  // add(1,2)= 3
}
           

《代码说明》test()函数的返回值返回的是一个函数类型:func(int, int) int 的值。在test()函数体中,使用了匿名函数作为函数的返回值,然后在main()函数中,将test()函数的返回值赋值给一个函数变量add,它的类型和test()函数的返回值类型是一样的。从运行结果可以看出,test()函数返回的是匿名函数的入口地址,并赋值给函数变量add,然后通过这个函数变量add来调用匿名函数。

2 闭包(Closure) — 引用了外部变量的匿名函数

2.1 闭包的概念

闭包是引用了其外部作用域的变量的函数。这个函数在Go语言中一般是匿名函数。在《Go语言核心编程》一书中,是这样描述闭包概念的:闭包是由函数及其相关引用环境组合而成的实体,一般通过在匿名函数中引用外部函数的局部变量或包全局变量构成。

简单的说就是:闭包 = 函数 + 引用环境。

  • 函数:一般都是匿名函数。
  • 引用环境:匿名函数引用的其外部作用域的变量,称为环境变量。

示例1:闭包的使用。

package main

import (
    "fmt"
)

func adder() func(int) int {
    var x int
    return func(y int) int {  //匿名函数引用了其外部作用域变量x,而x是该匿名函数外围函数adder()的局部变量
        x += y
        return x
    }
}

func main() {
    var f = adder()    //
    fmt.Println(f(10)) //x=0,y=10 输出:10
    fmt.Println(f(20)) //x=10,y=30  输出:30
    fmt.Println(f(30)) //x=30,y=30  输出:60

    f1 := adder()
    fmt.Println(f1(40)) //x=0,y=40   输出:40
    fmt.Println(f1(50)) //x=40,y=50  输出:90
}
           

《代码说明》adder()函数返回的匿名函数中直接引用了上下文环境变量x,注意这个变量x不是在匿名函数中定义的,而是在adder()函数中定义的,它是adder()函数的局部变量。当adder()函数返回匿名函数,然后在main()函数中执行 f(10),它依然可以读取x的值,这种现象就称作闭包。

变量f 是一个函数变量并且它引用了其外部作用域中的变量x,此时 f 就是一个闭包。在闭包f 的生命周期内,被闭包引用的环境变量x也会一直存在,并且闭包拥有记忆效应,它能够保存上一次调用闭包f 时环境变量x被修改后的值。

此时,我们不禁要问:x不是adder()函数的局部变量吗,adder()函数都已经返回了,怎么它的局部变量x还能在main函数中被访问到呢?这个我们就需要知道闭包的底层实现原理是什么了,闭包的底层实现将会在下一篇博客中详细分析。简单描述就是:

(1)函数可以作为返回值。

(2)函数内部查找变量的顺序,先在自己内部找,找不到往外层找。这个外层变量就是闭包引用的环境变量。

示例2:

package main

import (
    "fmt"
)

func test(x int) func() {
    fmt.Printf("test.x: &x=%p, x=%d\n", &x, x)
    return func(){
        fmt.Println("closure.x:", &x, x)
    }
}

func main(){
    f := test(0x100)
    //fmt.Printf("f type: %T, f value: %v\n", f, f)
    f()
}
           

运行结果:go run demo.go

test.x: &x=0xc000014088, x=256
closure.x: 0xc000014088 256
           

《结果分析》通过输出变量x的地址,我们注意到在闭包中直接引用了原环境变量x。我们使用go build命令来查看一下:

$ go build -gcflags '-m -l' demo.go
# command-line-arguments
./demo.go:7:11: moved to heap: x
./demo.go:8:15: ... argument does not escape
./demo.go:8:41: x escapes to heap
./demo.go:9:12: func literal escapes to heap
./demo.go:10:20: ... argument does not escape
./demo.go:10:21: "closure.x:" escapes to heap
./demo.go:10:35: x escapes to heap
           

可以看到,在编译期, 变量x被移动到堆内存中了,这就能解释为什么x的生命期在test()函数返回后还能继续存在了。事实上,编译器在编译的时候,如果检测到闭包,就会将闭包引用的外部变量移动到堆上。

 2.2 闭包的使用

如果函数返回的闭包引用了该函数的局部变量(函数参数或函数内部变量),使用闭包的注意事项:

1、多次调用闭包的外围函数,返回的多个闭包所引用的外部环境变量是多个副本,原因是每次调用函数都会为局部变量分配内存。

2、调用一个闭包函数多次,如果该闭包修改了其引用的外部环境变量,则每一次调用该闭包都会对该闭包产生影响,因为闭包函数共享其外部引用。

示例3:

func fa(a int) func(int) int {
    return func(i int) int {
        fmt.Printf("&a=%p, a=%d\n", &a, a)
        a += i
        return a
    }
}

func main(){
    f := fa(1)  //f引用的外部的闭包环境包括本次函数调用的形参a的值1
    g := fa(1)  //g引用的外部的闭包环境包括本次函数调用的形参a的值1
	
    //此时f、g引用的闭包环境变量a的值并不是同一个,而是两次函数调用产生到副本
    
    fmt.Printf("f(1)=%d\n", f(1))
    //多次调用f引用的是同一个副本
    fmt.Printf("f(1)=%d\n", f(1))
    
    //g中的a的值仍然是1
    fmt.Printf("g(1)=%d\n", g(1))
    fmt.Printf("g(1)=%d\n", g(1))
}
           

运行结果:

&a=0xc000016090, a=1
f(1)=2
&a=0xc000016090, a=2
f(1)=3
&a=0xc000016098, a=1
g(1)=2
&a=0xc000016098, a=2
g(1)=3
           

《代码分析》从运行结果可以看到,f、g引用的闭包中的环境变量a是两个不同的副本,也就是说f、g引用的是两个不同的闭包。

3、如果一个函数调用返回的闭包引用的环境变量是全局变量,则每次调用都会影响全局变量。即使是多次调用外围函数返回的多个闭包引用的都是同一个环境变量。

示例4:修改示例3中的代码,将闭包中引用的局部变量改为全局变量。

var (
    a = 0
)

func fa() func(int) int {
    return func(i int) int {
        fmt.Printf("&a=%p, a=%d\n", &a, a)
        a += i
        return a
    }
}

func main(){
    f := fa()   //f引用的外部的闭包环境包括全局变量a
    g := fa()   //g引用的外部的闭包环境包括全局变量a
    
    //此时f、g引用的闭包环境变量a的值是同一个
    
    fmt.Printf("f(1)=%d\n", f(1))
    fmt.Printf("f(1)=%d\n", f(1))
    fmt.Printf("g(1)=%d\n", g(1))
    fmt.Printf("g(1)=%d\n", g(1))
}
           

运行结果:

&a=0x5868e0, a=0
f(1)=1
&a=0x5868e0, a=1
f(1)=2
&a=0x5868e0, a=2
g(1)=3
&a=0x5868e0, a=3
g(1)=4
           

<提示> 使用闭包的目的就是为了减少全局变量的使用,所以闭包引用全局变量不是好的编程方式。

4、同一个函数返回的多个闭包共享该函数的局部变量。

示例5:

func fa(base int) (func(int) int, func(int) int) {
    fmt.Printf("fa: &base=%p, base=%d\n", &base, base)
    
    add := func(i int) int {
        base += i            //匿名函数引用了fa的局部变量base
        fmt.Printf("add: &base=%p, base=%d\n", &base, base)
        return base
    }
    
    sub := func(i int) int {
        base -= i            //匿名函数引用了fa的局部变量base
        fmt.Printf("sub: &base=%p, base=%d\n", &base, base)
        return base
    }
    
    return add, sub
}

func main(){
    f,g := fa(0)  //f、g闭包引用的base是同一个,是fa函数调用传递的实参值
    
    s,k := fa(0)  //s、k闭包引用的base是同一个,是fa函数调用传递的实参值
    
    //f,g和s,k 这两组引用是不同的闭包变量,这是由于每次调用fa都要重新分配形参
    fmt.Printf("f(1)=%d, g(2)=%d\n", f(1), g(2))
    fmt.Printf("s(1)=%d, k(2)=%d\n", s(1), k(2))
}
           

运行结果:

fa: &base=0xc000016090, base=0
fa: &base=0xc000016098, base=0
add: &base=0xc000016090, base=1
sub: &base=0xc000016090, base=-1
f(1)=1, g(2)=-1
add: &base=0xc000016098, base=1
sub: &base=0xc000016098, base=-1
s(1)=1, k(2)=-1
           

《结果分析》fa()函数同时返回了两个闭包函数,这两个闭包函数共享了其引用环境变量base,这两个闭包其中任何一方对base的修改行为都会影响另一个闭包对base的取值,因此在并发环境下可能需要做同步处理。

【##】面试题。

func calc(base int) (func(int) int, func(int) int) {
    add := func(i int) int {
        base += i
        return base
    }

    sub := func(i int) int {
        base -= i
        return base
    }
    return add, sub
}

func main() {
    f1, f2 := calc(10)
    fmt.Println(f1(1), f2(2)) //11 9
    fmt.Println(f1(3), f2(4)) //12 8
    fmt.Println(f1(5), f2(6)) //13 7
}
           

《代码分析》calc()函数返回了两个闭包f1、f2,这两个闭包都引用了calc()函数的局部变量base,那么此时闭包f1、f2共享引用环境变量base。

2.3 闭包的注意事项

示例6:

func test() []func() {
    var s []func()

    for i := 0; i < 3; i++ {
        s = append(s, func() {  //将多个匿名函数添加到切片
            fmt.Println(&i, i)
        })
    }

    return s    //返回匿名函数切片
}
func main() {
    for _, f := range test() {  //执行所有匿名函数
        f()   
    }
}
           

运行结果:

0xc000016090 3
0xc000016090 3
0xc000016090 3
           

《代码分析》每次 

append

 操作仅仅是将匿名函数放入到列表中,但并未执行,并且引用的环境变量

都是同一变量i,

随着 

i

 的改变匿名函数中的 

i

 也在改变,所以当在main函数中执行这些函数时,他们读取的是环境变量 

i

 最后一次循环时的值=3。解决办法就是每次使用不同的环境变量,让各自闭包引用的环境变量各不相同。修改后的代码如下:

func test() []func() {
    var s []func()

    for i := 0; i < 3; i++ {
        x := i                  //x 每次循环都重新定义
        //fmt.Printf("&x=%p, x=%d\n", &x, x)
        s = append(s, func() {  //将多个匿名函数添加到切片
            fmt.Println(&x, x)
        })
    }

    return s    //返回匿名函数切片
}
func main() {
    for _, f := range test() {  //执行所有匿名函数
        f()   
    }
}
           

 运行结果:

0xc000016090 0
0xc000016098 1
0xc0000160a0 2
           

2.4 闭包的价值

闭包的最初目的是减少全局变量的使用,在函数调用过程中隐式地传递共享变量,有其有用的一面;但是这种隐秘的共享变量的方式带来的坏处是不够直接,不够清晰,除非是非常有价值的地方,一般不建议使用闭包。闭包让我们不用传递参数就可以读取或修改环境状态,当然这也需要付出额外的代价。对于性能要求较高的场合,须慎重使用。

【##】对象和闭包的区别

对象是附有行为的数据,而闭包是附有数据的行为。类在定义时就已经显式地集中定义了行为,但是闭包中的数据没有显式地集中声明的地方,这种数据和行为耦合的模型不是一种推荐的编程模型,闭包仅仅是锦上添花的东西,不是不可缺少的。

2.4 后记

我将在下一篇博客中详细分析Go语言闭包的底层实现原理。

参考

《Go语言从入门到进阶实战(视频教学版)》

《Go语言核心编程》

《Go语言学习笔记》

《Go语言编程》

Go语言基础之函数

GO 匿名函数和闭包