Golang 和 Python 中的闭包讨论

192 阅读10分钟

对于Golang和Python的新手来说,本文解释了什么是闭包,以及在使用这两种编程语言的闭包时需要注意的一些技巧。为了方便大家复制和在自己的服务器上运行,代码部分提供了包、导入和其他重复的部分。负面方面是,这导致了某些冗余,因此如果这给您带来不便,请理解。

什么是闭包?

闭包是存储函数及其环境的记录。

闭包的两个要素:函数和环境

  1. 函数:指的是在闭包实际实现时,通常通过调用外部函数返回其内部函数来完成。内部函数可以是内部具名函数、匿名函数或lambda表达式。
  2. 环境:在实践中,引用的环境是外部函数的环境,闭包在生成时保存/记录了外部函数的所有环境。

总结来说,闭包是一个扩展作用域的函数,它保留了定义时存在的自由变量(未在局部作用域中绑定的变量)的绑定,因此当调用该函数时,即使定义的作用域不再可用,这些绑定仍然可以使用。以下是闭包的常见用法:

// golang 示例
package main

import "fmt"

func outer() func(v int) {
    x := 1
    return func(v int) {
        y := x + v
        fmt.Println(x)
        fmt.Println(y)
    }
}

func main() {
    a := outer()
    a(1)
    a(2)
}

// 结果
1
2
1
3

在Golang示例中的逻辑中,“a”是一个闭包,闭包函数是内部的func(v int){}, 闭包环境是外部的“x”,由于外部环境被“捕获”,因此每次执行闭包时“x”都是1,最终的输出结果是1,2,1,3。

# python 示例
def outer():
    x = 1

    def inner(v):
        y = x + v
        print(x)
        print(y)
    return inner

a = outer()
a(1)
a(2)

# 结果
1
2
1
3

在Python示例中,相同的闭包函数是inner(v),闭包环境是“x”,因此每次执行闭包时“x”都是1(因为闭包环境被捕获),然后闭包内的逻辑是将“x”与闭包传递的参数相加,所以最终输出是1,2,1,3。

Golang中的闭包

匿名函数

由于在Golang中匿名函数使用非常频繁,我们从这里开始:匿名函数及其“捕获”的自由变量称为闭包,无论是正常使用、在for循环中使用,还是在defer中使用,它们都是闭合的:

package main

import "fmt"

func main() {
    x := 1
    a := func(v int) {
            y := x + v
            fmt.Println(x)
            fmt.Println(y)
    }
    a(2)
    a(3)
}

// 结果
1
3
1
4

在上面的例子中,a是一个闭包,闭包函数是匿名函数func(v int){}, 闭包环境是x,因此结果是1,3,1,4。

package main

import "fmt"

func main() {
    y := 2

    defer func() {
        fmt.Println(y)
    }()
}

// 结果
2

defer中的匿名函数同样如此,defer中的func(){}是匿名函数,闭包环境是“y”,所以输出为2。

修改闭包环境

首先,Golang中的所有引用传递都是值传递,如果有类似引用传递的情况(文章后面会提到,为了方便),实际上是传递了底层指针的“值”,从而实现了所谓的引用传递。

  1. 如果要在闭包内部(闭包函数内)修改闭包环境,Golang很容易做到。由于Golang是一种声明式语言,赋值和声明的写法不同(:=用于声明,=用于赋值);并且Golang闭包“捕获”闭包环境的本质是引用传递而不是值传递,所以可以直接修改,如下例所示:
package main

import "fmt"

func make_avg() func(v int) {
        count := 0
        total := 0
        return func(v int) {
                count += 1
                total += v
                fmt.Println(float32(total)/float32(count))
        }
}

func main() {
        a := make_avg()
        a(1)
        a(2)
        a(3)
}

// 结果
1
1.5
2

这个例子计算平均值,闭包是“a”,闭包函数是内部的func(v int){}匿名函数,闭包环境是“count”和“total”;count += 1,total += v是直接修改闭包环境的行为,并获得预期效果。

特别是,你可以利用Golang闭包“捕获”闭包环境的本质,即引用传递,在匿名函数内部(闭包函数内部)修改全局变量(闭包环境),如下例所示:

package main

import (
    "fmt"
)

var x int = 1

func main() {
    a := func() {
        x += 1
    }
    fmt.Println(x)
    a()
    fmt.Println(x)
}

// 结果
1
2
  1. 在闭包外部修改闭包环境

你可能会有疑问,“在闭包外部”可以修改闭包环境吗?实际上,在Golang中是可能的,请记住以下两句话:

  • 如果外部函数的所有变量都是局部的,即生命周期在外部函数结束时结束,那么闭包的环境也是封闭的。
  • 相反,如果闭包环境可以通过指针修改,那么可以从闭包外部修改闭包环境,请看以下示例:
package main

import "fmt"

func foo1(x *int) func() {
    return func() {
        *x = *x + 1
        fmt.Println(*x)
    }
}
func foo2(x int) func() {
    return func() {
        x = x + 1
        fmt.Println(x)
    }
}

func main() {
    x := 133
    f1 := foo1(&x)
    f2 := foo2(x)
    f1()
    f1()
    f2()
    f2()

    x = 233
    f1()
    f1()
    f2()
    f2()

    foo1(&x)()
    foo1(&x)()
    foo2(x)()
    foo2(x)()
}

两个闭包内部的逻辑需要分析,一个是指针变量的和,根据我们之前所说的“闭包环境可以通过指针修改”,每次执行闭包或在外部直接赋值都会真正改变变量的值,而不使用指针的“foo2”则是普通的闭包,即闭包环境仅在闭包内部;因此,前四组输出如下:

134
135
134
135

中间四组是由“f1”闭包在被外部强制修改为233后进行累加,而“f2”闭包则在其自身环境中进行累加,因此输出为:

234
235
136
137

最后四组生成了四个新的闭包,因此“foo1”部分仍然基于当前“x”值进行累加,累加值实际上作用于全局变量“x”;foo2中的累加仍然是在其自身闭包内进行,因此输出为:

236
237
238
238

通过这个例子,我们区分了从闭包内部和从闭包外部修改闭包环境。

闭包的延迟绑定

这个问题是每个Golang新手都会遇到的一个非常令人困惑的问题;请记住以下一句话:在执行闭包时,闭包环境声明周期得到保证,并且会去外部环境查找最新的闭包环境(值),以下示例中,在执行闭包时“i”是闭包环境,执行闭包时最新值已经是10,因此所有输出都是10。

package main

import "fmt"

func main() {
    var handlers []func()
    for i := 0; i < 10; i++ {
        handlers = append(handlers, func() {
                fmt

.Println(i)
        })
    }
    for _, handler := range handlers {
        handler()
    }
}

// 结果
10
10
10
10
10
10
10
10
10
10

解决方法是在for循环中复制一个不被闭包引用的环境变量,然后使用该值代替闭包环境,修改后的版本如下:

package main

import "fmt"

func main() {
   var handlers []func()
   for i := 0; i < 10; i++ {
      // a不是闭包环境,因为每次都会重新声明
      a := i
      handlers = append(handlers, func() {
              fmt.Println(a)
      })
   }
   for _, handler := range handlers {
      handler()
   }
}

// 结果
0
1
2
3
4
5
6
7
8
9

实际上,原理很清楚,这不是关于for循环的问题,正常使用、defer使用都会受到此原则的约束,执行闭包时,闭包环境声明周期得到保证,并且会去外部环境查找最新的闭包环境(值)

package main

import "fmt"

func main() {
    x, y := 1, 2

    defer func(a int) {
        fmt.Println(a, y)
    }(x)

    x += 100
    y += 100
}

// 输出, y 是闭包环境,所以执行闭包时会查找最新值,而 a 不是闭包环境,复制了 x 的值,所以不受影响
1 102

在 go 协程中使用匿名函数是一个常见的场景,并且会遇到这个问题,见下面的示例:

package main

import (
    "fmt"
    "time"
)

func show(val int) {
    fmt.Println(val)
}

func main() {
    values := []int{1, 2, 3, 5}
    for _, val := range values {
        go show(val)
    }
    time.Sleep(time.Second)
}

// 每次都会输出四个 1,2,3,5 虽然顺序不同,因为没有使用匿名函数,它不是闭包
5
1
3
2

每次都会输出四个1,2,3,5,虽然顺序不同,因为没有使用匿名函数,它不是闭包。

package main

import (
        "fmt"
        "time"
)

func main() {
    values := []int{1, 2, 3, 5}
    for _, val := range values {
        go func(){
            fmt.Println(val)
        }()
    }
    time.Sleep(time.Second)
}

// 输出
5
5
5
5

修改方法与for循环示例相同,使用传递变量来避免闭包环境。

package main

import (
        "fmt"
        "time"
)

func main() {
    values := []int{1, 2, 3, 5}
    for _, val := range values {
        go func(val int){
            fmt.Println(val)
        }(val)
    }
    time.Sleep(time.Second)
}

// 输出
1
5
3
2

Python中的闭包

修改闭包环境

  1. 从内部修改闭包环境

由于Python不是声明式语言,一个“=”涵盖所有,因此我们需要使用nonlocal参数显式声明变量为闭包环境,而不是局部变量,如下例所示,如果像Golang那样直接更改,有时会出现问题。

使用Python列表作为闭包环境

def make_avg(count, total):
    count = []

    def avg(v):
        count.append(v)
        total = sum(count)
        print(sum(count)/len(count))
    return avg


a = make_avg(0, 0)
a(1)
a(2)
a(3)


# 输出
1.0
1.5
2.0

使用Python字符作为闭包环境

def make_avg(count, total):
    count = 0
    total = 0

    def avg(v):
        count += 1
        total += v
        print(total/count)
    return avg


a = make_avg(0, 0)
a(1)
a(2)
a(3)


# 错误
Traceback (most recent call last):
  File "/root/code/linux/blog/aa.py", line 14, in <module>
    a(1)
  File "/root/code/linux/blog/aa.py", line 7, in avg
    count += 1
UnboundLocalError: local variable 'count' referenced before assignment

这是因为Python有两种数据类型,一种是可变数据类型,一种是不可变数据类型,传递变量数据类型时使用引用传递,这与Golang闭包直接修改特性相匹配,但不可变数据类型(如上面的字符串)会出现问题,因为Python会将其视为新生成的局部变量,因此解决方法是使用nonlocal告诉Python解释器,这个变量是闭包环境,这样就可以正常运行,正确的写法如下:

def make_avg(count, total):
    count = 0
    total = 0

    def avg(v):
        nonlocal count, total
        count += 1
        total += v
        print(total/count)
    return avg


a = make_avg(0, 0)
a(1)
a(2)
a(3)

# 输出
1.0
1.5
2.0

闭包和装饰器

我本来想谈谈Python装饰器,但由于篇幅原因,决定留待以后再说。

总结

上面有很多例子,读完每个例子后,理解为什么,然后闭包问题就会解决了;

请记住,由于闭包不常用,且实用性不高,如果工作中不必要使用闭包,你可以不使用闭包。否则可能会引发一些内存泄漏,这不是一个小问题。

  1. 闭包的两个主要元素:函数和环境
  2. 闭包是一个扩展作用域的函数,它会保留定义时存在的自由变量(未在局部作用域中绑定的变量)的绑定,因此当调用该函数时,定义的作用域不可用,但仍可以使用这些绑定
  3. 在Golang中使用匿名函数实际上就是一个闭包
  4. 通过指针可以从闭包外部修改闭包环境变量
  5. Golang 闭包延迟绑定问题:执行闭包时,闭包环境声明周期得到保证,并且会去外部环境查找最新的闭包环境(值)
  6. Python闭包使用nonlocal声明变量为闭包环境,而不是局部变量

上述分析是我对闭包的理解,希望Golang、Python新手在阅读后可以避免踩到必须踩的坑,如果对环境有疑问,请随时留言或发送电子邮件联系,谢谢。

参考链接

juejin.cn/post/684490…

www.jianshu.com/p/fa21e6fad…

zhuanlan.zhihu.com/p/92634505