Golang for...range和闭包Closure


原文链接: Golang for...range和闭包Closure

今天遇到了一个有趣的问题,涉及到了golang编程的三个知识点,for...range闭包goroutine

为了简化问题,写了一段示例代码来做分析:

package main

import "time"

func main() {
    ss := []string{
        "haha",
        "hehe",
        "xixi",
    }

    for _, s := range ss {
        go func() {
            print(s, "\n")
        }()
    }

    time.Sleep(1 * time.Second)
}

如果我们无意中写出这种代码,心里预期的输出可能是三个不同的字符串,当然,顺序可能是不固定的,这是goroutine的特性决定的,我们这里就不做说明了,但实际上着代码的输出会是

xixi
xixi
xixi

如果我们将代码修改为

package main

import "time"

func main() {
    ss := []string{
        "haha",
        "hehe",
        "xixi",
    }

    for _, s := range ss {
        go func(s1 string) {
            print(s1, "\n")
        }(s)
        // time.Sleep(1 * time.Microsecond)
    }

    time.Sleep(1 * time.Second)
}

则可以得到我们期望的输出

haha
hehe
xixi

原因在于for...range的赋值方式和闭包概念:

fro...range

The iteration variables may be declared by the "range" clause using a form of short 
variable declaration (:=). In this case their types are set to the types of the 
respective iteration values and their scope is the block of the "for" statement; they 
are re-used in each iteration. If the iteration variables are declared outside the "for" 
statement, after execution their values will be those of the last iteration.

这是goang官方对for...range中迭代变量--也就是我们代码中的s--的说明
也就是说

for _, s := range ss {
    //TODO:
}

var s string
for _, s = range ss {
    //TODO:
}

这两种写法是同样的效果,但是第二种写法相对来说就很好理解--for...range的迭代中不会重复分配迭代变量,而是会重复利用第一次生成的或者传入的迭代变量,for...range还有另外一个坑,在此不做赘述

闭包

简单点讲,引用了全局变量的函数就是典型的闭包1,这个函数在不同场景调用会产生不同的效果(即使收到了同样的输入参数),也可以称之为闭包在运行时可以有多个实例

所以我们第二段代码中,通过给匿名函数增加了一个输入变量,这样虽然我们仍在使用for...range,但是没有了闭包,出现一个传值调用的过程,最终得到我们期望的输出

闭包概述

闭包(Closure)是词法闭包(Lexical Closure)的简称,是为了解决"函数的引用环境可能发生变化"这一问题 而引入的特性。闭包的概念早在高级语言开始发展的年代就产生了,比较靠谱的定义,为: 闭包,是由函数和引用环境(即变量)组合而成的实体,是附有数据的行为1

不同的语言,对闭包的支持形式不尽相同,但基本思想都是一致的。当编程语言满足下述条件时,可以较好的支持闭包特性:

  • 函数是一阶值(First-class value),即函数可以作为变量、可以作为另一个函数的返回值或参数
  • 支持函数的嵌套
  • 引用环境和函数组合而成的实体,可以被调用
  • 支持匿名函数

闭包优雅的处理了一些棘手的问题,如提高了代码的抽象程度、精简代码等等。但由于引用环境的问题,闭包也会带来了很多的副作用,这正是下文的主题:-D

Golang的闭包

Golang支持匿名函数,这个功能很好的支持了闭包特性。举个例子,来说明Golang闭包的典型做法,如下,

package main
import "fmt"

func intSeq() func() int {
    i := 0
    return func() int {
        i += 1
        fmt.Printf("%d, %d\n", i, &i)
        return i
    }
}

func main() {
    nextInt := intSeq()
    nextInt()
    nextInt()
    nextInt()

    fmt.Println()
    newInts := intSeq()
    newInts()
}

运行结果,如下(将上述代码,保存为文件closures.go),

$ go run closures.go
1, 0xc20800a200
2, 0xc20800a200
3, 0xc20800a200

1, 0xc20800a288

函数intSeq,定义了一个匿名函数,并把它作为返回值。作为返回值的函数,捕获了一个内部引用变量i,从而构成了一个闭包。

main函数中,我们首先定义了一个函数变量nextIntnextInt会捕获变量i的取值现场(0)、另存为自己的私有数据(初始值为0),后续每次调用nextInt时 都会更新其私有数据内容(0 -> 1 -> 2 -> 3)、私有数据的地址不发生变化(始终为0xc20800a200)。

每次调用intSeq产生的闭包实例,其引用环境都是私有的、不受其他实例影响的。从newInts上面,可以验证上述结论:newInts的私有数据地址为0xc20800a288、取值不受nextInt影响。

Golang闭包的副作用

闭包带来了代码层面的灵活性,却也因为引用导致了一些副作用。特别的,当对引用环境理解不够、处理不当时,会产生意想不到的错误。下面,就是我采过的坑:-(

func pit() {
    for i := 0; i < 3; i++ {
        go func() {
            fmt.Printf("%d ", i)
        }()
    }
    // Output: 3 3 3
}

上面的代码,我们期盼的输出为0 1 2,结果却是3 3 3,这就是闭包带来的副作用。回避这个副作用的办法,有(1)通过函数的参数传值 或(2)在闭包内构造临时变量:

	// 方法1: 通过函数的参数传值
	func pitFix1() {
		for i := 0; i < 3; i++ {
			go func(num int) {
				fmt.Printf("%d ", num)
			}(i)
		}
		// Output: 0 1 2
	}

	// 方法2: 在闭包内构造临时变量num
	func pitFix2() {
		for i := 0; i < 3; i++ {
			num := i
			go func() {
				fmt.Printf("%d ", num)
			}()
		}
		// Output: 0 1 2
	}

小结

如"闭包"一样的语言特性,带来灵活性的同时 往往伴随着一个个的坑。没有完美的设计。

`