3

Golang:“闭包(closure)”到底包了什么?

 2 years ago
source link: https://www.51cto.com/article/717884.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Golang:“闭包(closure)”到底包了什么?

2022-09-02 17:26:18
闭包是个常见的玩意儿,但是实际代码中不太建议使用,一不小心写了个内存泄漏查都查不到。特别是不要为了炫技故意写个闭包,实在没有必要。
b5a43e23342a92b43d5732258558594d8d0a4b.jpg

本文讲介绍以下几个内容:

  1.  引入用GoLang语言写的几个case;
  2.  介绍什么是闭包;
  3.  介绍什么是闭包的延迟绑定;
  4.  从闭包的延迟绑定讲到GoLang的Go Routine的延迟绑定问题;

1. 几个有趣的Case

开门见山,首先请各位看官们先看下面foo1()到foo7()一共7个函数,然后回答后面的问题。(一下子丢出7个函数,请见谅。不过,每个函数都非常简短,而本文接下来将围绕这7个函数展开,因此,请各位看官老爷们耐心且看看题,活动活动脑细胞~)

  •  case 1:
func foo1(x *int) func() {
 return func() {
   *x = *x + 1
   fmt.Printf("foo1 val = %d\n", *x)
 }
}
  • case 2:
func foo2(x int) func() {
 return func() {
   x = x + 1
   fmt.Printf("foo2 val = %d\n", x)
 }
}
  • case 3:
func foo3() {
 values := []int{1, 2, 3, 5}
 for _, val := range values {
   fmt.Printf("foo3 val = %d\n", val)
 }
}
  • case 4:
func show(v interface{}) {
 fmt.Printf("foo4 val = %v\n", v)
}
func foo4() {
 values := []int{1, 2, 3, 5}
 for _, val := range values {
   go show(val)
 }
}
  • case 5:
func foo5() {
 values := []int{1, 2, 3, 5}
 for _, val := range values {
   go func() {
     fmt.Printf("foo5 val = %v\n", val)
   }()
 }
}
  • case 6:
var foo6Chan = make(chan int, 10)
func foo6() {
 for val := range foo6Chan {
   go func() {
     fmt.Printf("foo6 val = %d\n", val)
   }()
 }
}
  • case 7:
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
}
  •  第一组实验:假设现在有变量x=133,并创建变量f1和f2分别为foo1(&x)和foo2(x)的返回值,请问多次调用f1()和f2()会打印什么?
  •  第二组实验:重新赋值变量x=233,请问此时多次调用f1()和f2()会打印什么?
  •  第三组实验:如果直接调用foo1(&x)()和foo2(x)()多次,请问每次都会打印什么?
  •  请问分别调用函数foo3(),foo4()和foo5(),分别会打印什么?
  •  第一组实验:如果“几乎同时”往channelfoo6Chan里面塞入一组数据"1,2,3,5",foo6会打印什么?
  •  第二组实验:如果以间隔纳秒(10^-9秒)的时间往channel里面塞入一组数据,此时foo6又会打印什么?
  •  第三组实验:如果是微秒(10^-6秒)呢?如果是毫秒(10^-3秒)呢?如果是秒呢?
  •  请问如果创建变量f7s=foo7(11),f7s是一个函数集合,遍历f7s会打印什么?

接下来,我们逐一来看这些问题和对应的foo函数。

2. case1~2:值传递(by value) vs. 引用传递(by reference)

子标题好难起 >0<...

看到case1和case2的两组函数foo1()和foo2(),相信各位看官就知道,其中一个知识点就是值传递和引用传递。

其实呢,Go是没有引用传递的,即使是foo1()在参数上加了*,内部实现机制仍旧是值传递,只不过传递的是指针的数值。但是为了称呼方便,下文会成为“引用传递”(为了区分正确的引用传递,这里特意加了引号)。

如下图所示,我们的目的是传递X变量,于是我们创建了一个传参地址(临时地址变量),它存放了X变量的地址值,调用函数的时候给它这个传参地址,函数呢,则会再创建一个入参地址,是传参地址的一份拷贝。函数拿到了这个地址值,可以通过寻址拿到这个X变量,此时函数如果直接修改X变量可以认为是“本地修改”或者“永久修改”了这个变量的数值。

「举个生活中的例子,比如一个叫做“函数”的人想寻找一个叫做“X”的人,函数跑过来问知道X的我,我拿出地址簿,给他出示了X这个人的家庭地址,函数记性不太好,所以拿了一本本子把X的地址抄在了他自己的本子上。」

这个例子中,我的那个记着X的家庭地址的地址簿,就是传参地址;函数抄录了X地址的本子,就是入参地址;X的家庭地址,就对应了X变量的地址值。(哎,为什么讲的这么细节了?)

c9c201f88b769cbada954079d68ff753072bf9.jpg

Golang的“引用传递”

话题似乎有点扯远了,拉回来,我们再来看看foo1()和foo2()。

foo1()和foo2()的区别确实在于值传递和引用传递,但是这个并不是本文介绍的中心。本文要介绍的已经在标题上写明了:闭包(closure)。

闭包(closure)

什么是闭包呢?摘用Wikipedia上的一句定义:

  •    a closure is a record storing a function together with an environment.
  •    闭包是由函数和与其相关的引用环境组合而成的实体 。

因此闭包的核心就是:函数和环境。其实这里就已经可以回答本文题目的问题:闭包究竟包了什么?答案是:函数和环境。但是相信部分看官们到这里依然不清楚:什么函数?什么环境?

函数,指的是在闭包实际实现的时候,往往通过调用一个外部函数返回其内部函数来实现的。内部函数可能是内部实名函数、匿名函数或者一段lambda表达式。用户得到一个闭包,也等同于得到了这个内部函数,每次执行这个闭包就等同于执行内部函数。

环境,Wikipedia上说是与其(函数)相关的引用环境,可以说解释地很精准了。

具体地说,在实际中引用环境是指外部函数的环境,闭包保存/记录了它产生时的外部函数的所有环境。但是这段话对于尚未理解闭包的同学来说依旧是不友好的,听完还是懵懂的。这里尝试做个更实用性的解释:

  •  如果外部函数的所有变量可见性都是local的,即生命周期在外部函数结束时也结束的,那么闭包的环境也是封闭的。
  •  反之,那么闭包其实不再封闭,全局可见的变量的修改,也会对闭包内的这个变量造成影响。

跳回foo1()和foo2()的例子,正好来解释闭包的函数和环境。

func foo1(x *int) func() {
   return func() {
       *x = *x + 1
       fmt.Printf("foo1 val = %d\n", *x)
   }
}
func foo2(x int) func() {
   return func() {
       x = x + 1
       fmt.Printf("foo1 val = %d\n", x)
   }
}
// Q1第一组实验
x := 133
f1 := foo1(&x)
f2 := foo2(x)
f1()  
f2()
f1()
f2()
// Q1第二组
x = 233
f1()
f2()
f1()
f2()
// Q1第三组
foo1(&x)()
foo2(x)()
foo1(&x)()
foo2(x)()

定义了x=133之后,我们获取得到了f1=foo1(&x)和f2=foo2(x)。这里f1\f2就是闭包的函数,也就是foo1()\foo2()的内部匿名函数;而闭包的环境即外部函数foo1()\foo2()的变量x(因为内部匿名函数引用到的相关变量只有x,因此这里简化为变量x)。

闭包的函数做的事情归纳为:1). 将环境的变量x自增1;2). 打印环境变量x。

闭包的环境则是其外部函数获取到的变量x。

因此Q1第一组实验的答案为:

f1() // foo1 val = 134
f2() // foo2 val = 134
f1() // foo1 val = 135
f2() // foo2 val = 135

这是因为闭包f1\f2都保存了x=133时的整个环境,每次调用闭包f1\f2都会执行一次自增+打印的内部匿名函数。因此第一次输出都是(133+1=)134,第二次输出都是(134+1=)135。

那么Q1第二组实验的答案呢?

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

有趣的事情发生了!f1的值居然发生了显著性的变化!通过这组实验,能够更好地解释其(函数)相关的引用环境其实就是产生这个闭包的时候的外部函数的环境,因此变量x的可见性和作用域也与外部函数相同,又因为foo1是“引用传递”,变量x的作用域不局限在foo1()中,因此当x发生变化的时候,闭包f1内部也变化了。这个也正好是"反之,那么闭包其实不再封闭,全局可见的变量的修改,也会对闭包内的这个变量造成影响"的证明。

Q1的第三组实验的答案:

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

因为foo1()返回的闭包都会修改变量x的数值,因此调用foo1()()之后,变量x必然增加1。而foo2()返回的闭包仅仅修改其内部环境的变量x而对调用外部的变量x不影响,且每次调用foo2()返回的闭包是独立的,和其他调用foo2()的闭包不相关,因此最后两次的调用,打印的数值都是相同的;第一次调用和第二次调用foo2()发现打印出来的数值增加了1,是因为两次调用之间传入的x的数值分别是236和237,而不是说第二次在第一次基础上增加了1,这点需要补充说明。

3. case7:闭包的延迟绑定

hhh,是不是以为我会接着讲case3,居然先提到了case7,意不意外惊不惊喜!

废话不多说,看官们来瞅瞅下面调用f7()的时候分别会打印什么?

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
}
// Q4实验:
f7s := foo7(11)
for _, f7 := range f7s {
   f7()
}
foo7 val = 16
foo7 val = 16
foo7 val = 16
foo7 val = 16

是的,你没有看错,会打印4行,且都是16!是不是很惊喜!

相信已经有很多同学在网上看到过类似的case,并且也早已知道结果了,不清楚的同学们现在也看到答案了。嗯,这就是大名鼎鼎的闭包延迟绑定问题。网上的解释其实有很多了,这里尝试用之前对于闭包的环境的定义来解释这个现象:

闭包是一段函数和相关的引用环境的实体。case7的问题中,函数是打印变量val的值,引用环境是变量val。仅仅是这样的话,遍历到val=1的时候,记录的不应该是val=1的环境吗?

上文在闭包解释最后,还有一句话:闭包保存/记录了它产生时的外部函数的所有环境。如同普通变量/函数的定义和实际赋值/调用或者说执行,是两个阶段。闭包也是一样,for-loop内部仅仅是声明了一个闭包,foo7()返回的也仅仅是一段闭包的函数定义,只有在外部执行了f7()时才真正执行了闭包,此时才闭包内部的变量才会进行赋值的操作。哎,如果这么说的话,岂不是应该抛出异常吗?因为val是一个比foo7()生命周期更短的变量啊?

这就是闭包的神奇之处,它会保存相关引用的环境,也就是说,val这个变量在闭包内的生命周期得到了保证。因此在执行这个闭包的时候,会去外部环境寻找最新的数值!你是不是不相信?来来来,我们马上写个临时的case执行下分分钟就明白了:

  •  临时的case:
func foo0() func() {
   x := 1
   f := func() {
       fmt.Printf("foo0 val = %d\n", x)
   }
   x = 11
   return f
}
foo0()() // 猜猜我会输出什么?

既然我说会在执行的时候去外部环境寻找最新的数值,那x的最新数值就是11呀,果然,最后输出的就是11。

以上就是我对于闭包的延迟绑定的通俗版本解释。:)

IV. case3~6:Go Routine的延迟绑定

case3、case4和case5不是闭包,case3只是遍历了内部的slice并且打印,case4是在遍历时通过协程调用了打印函数打印,case5也是在遍历slice时调用了内部匿名函数打印。

Q2的case3问题的答案先丢出来:

func foo3() {
   values := []int{1, 2, 3, 5}
   for _, val := range values {
       fmt.Printf("foo3 val = %d\n", val)
   }
}
foo3()
//foo3 val = 1a
//foo3 val = 2
//foo3 val = 3
//foo3 val = 5

中规中矩,遍历输出slice的内容:1,2,3,5。

Q2的case4问题的答案再丢出来:

func show(v interface{}) {
   fmt.Printf("foo4 val = %v\n", v)
}
func foo4() {
   values := []int{1, 2, 3, 5}
   for _, val := range values {
       go show(val)
   }
}
foo4()
//foo3 val = 2
//foo3 val = 3
//foo3 val = 1
//foo3 val = 5

嗯,因为Go Routine的执行顺序是随机并行的,因此执行多次foo4()输出的顺序不一行相同,但是一定打印了“1,2,3,5”各个元素。

最后是Q2的case5问题的答案:

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

居然都打印了5,惊不惊喜,意不意外?!相信看过子标题的你,一定不意外了(捂脸)。是的,接下来就要讲讲Go Routine的延迟绑定:

其实这个问题的本质同闭包的延迟绑定,或者说,这段匿名函数的对象就是闭包。在我们调用go func() { xxx }()的时候,只要没有真正开始执行这段代码,那它还只是一段函数声明。而在这段匿名函数被执行的时候,才是内部变量寻找真正赋值的时候。

在case5中,for-loop的遍历几乎是“瞬时”完成的,4个Go Routine真正被执行在其后。矛盾是不是产生了?这个时候for-loop结束了呀,val生命周期早已结束了,程序应该报错才对呀?

回忆上一章,是不是一个相同的情境?是的,这个匿名函数可不就是一个闭包吗?一切就解释通了:闭包真正被执行的时候,for-loop结束了,但是val的生命周期在闭包内部被延长了且被赋值到最新的数值5。

不知道各位看官是否好奇,既然说Go Routine执行的时候比for-loop慢,那如果我在遍历的时候增加sleep机制呢?于是设计了Q3实验:

var foo6Chan = make(chan int, 10)
func foo6() {
   for val := range foo6Chan {
       go func() {
           fmt.Printf("foo6 val = %d\n", val)
       }()
   }
}
// Q3第一组实验
go foo6()
foo6Chan <- 1
foo6Chan <- 2
foo6Chan <- 3
foo6Chan <- 5
// Q3第二组实验
foo6Chan <- 11
time.Sleep(time.Duration(1) * time.Nanosecond)
foo6Chan <- 12
time.Sleep(time.Duration(1) * time.Nanosecond)
foo6Chan <- 13
time.Sleep(time.Duration(1) * time.Nanosecond)
foo6Chan <- 15
// Q3第三组实验
// 微秒
foo6Chan <- 21
time.Sleep(time.Duration(1) * time.Microsecond)
foo6Chan <- 22
time.Sleep(time.Duration(1) * time.Microsecond)
foo6Chan <- 23
time.Sleep(time.Duration(1) * time.Microsecond)
foo6Chan <- 25
time.Sleep(time.Duration(10) * time.Second)
// 毫秒
foo6Chan <- 31
time.Sleep(time.Duration(1) * time.Millisecond)
foo6Chan <- 32
time.Sleep(time.Duration(1) * time.Millisecond)
foo6Chan <- 33
time.Sleep(time.Duration(1) * time.Millisecond)
foo6Chan <- 35
time.Sleep(time.Duration(10) * time.Second)
// 秒
foo6Chan <- 41
time.Sleep(time.Duration(1) * time.Second)
foo6Chan <- 42
time.Sleep(time.Duration(1) * time.Second)
foo6Chan <- 43
time.Sleep(time.Duration(1) * time.Second)
foo6Chan <- 45
time.Sleep(time.Duration(10) * time.Second)
// 实验完毕,最后记得关闭channel
close(foo6Chan)

尝试执行了多次,第一组答案如下:

foo6 val = 5/3
foo6 val = 5
foo6 val = 5
foo6 val = 5

绝大部分时候执行出来都是5。

第二组答案如下:

foo6 val = 15/13/11/12
foo6 val = 15/13
foo6 val = 15
foo6 val = 15

绝大部分时候执行得到的都是15。

第三组答案如下:

// 微秒
foo6 val = 23/21
foo6 val = 23/22
foo6 val = 25/23
foo6 val = 25
// 毫秒
foo6 val = 31
foo6 val = 32
foo6 val = 33
foo6 val = 35
// 秒
foo6 val = 41
foo6 val = 42
foo6 val = 43
foo6 val = 45

毫秒和秒的两组非常确定,顺序输出。但是微妙就不一定了,有时候是顺序输出,大部分时候是随机输出如“22,22,23,25”或者“21,22,25,25”之类的。

可见,Go Routine的匿名函数从定义到执行,耗时时间在微妙上下。于是又增加了一个临时的case测试了其真正的耗时大约是多少。

  •  又一个临时的case:
func foo8() {
   for i := 1; i < 10; i++ {
       curTime := time.Now().UnixNano()
       go func(t1 int64) {
           t2 := time.Now().UnixNano()
           fmt.Printf("foo8 ts = %d us \n", t2-t1)
       }(curTime)
   }
}
foo8()

执行下来发现耗时在5微秒~60微秒之间不等。

但是,以上的实验数据都是从我的iMac本子上得到的,该本子的CPU是i7-7700K 4.2GHz;我又放在笔记本上(CPU为i5-8250U 1.6GHz 1.8GHz)运行了下,发现居然耗时是0微秒!起初我怀疑是时间精度的问题,于是把t1和t2时间都打印出来,精度是可以达到纳秒的。抱着仍旧不信的想法,重新运行了第三组实验,每一个都是顺序输出的!

好吧,回头再说我的iMac的问题。现在只需要记住一点:Go Routine的匿名函数的延迟绑定本质就是闭包,实际生成中注意下这种写法~

最后,闭包是个常见的玩意儿,但是实际代码中不太建议使用,一不小心写了个内存泄漏查都查不到。特别是不要为了炫技故意写个闭包,实在没有必要。

责任编辑:庞桂玉 来源: 马哥Linux运维

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK