本文主要讲解 Python 闭包相关知识点
文章参考参考: Python 闭包(Closure)详解

# 认识闭包

出于种种原因,我们有时候需要在函数外部得到函数内的局部变量。但是,由于 Python 中作用域的搜索顺序("链式作用域" 结构(chain scope):子对象会一级一级地向上寻找所有父对象的变量),这一点通常是无法实现的。

def f1 ():
    n=999;
print (n)
> NameError: name 'n' is not defined

但是有一种方法除外,那就是在函数的内部,再定义一个函数。

def f1 ():
    n=999
    def f2 ():
        print (n)

在上面的代码中,函数 f2 就被包括在函数 f1 内部,这时 f1 内部的所有局部变量,对 f2 都是可见的。但是反过来就不行,f2 内部的局部变量,对 f1 就是不可见的。这就是开头说到的,Python 语言特有的作用域搜索顺序。所以,父对象的所有变量,对子对象都是可见的,反之则不成立。

既然 f2 可以读取 f1 中的局部变量,那么只要把 f2 作为返回值,我们不就可以在 f1 外部读取它的内部变量了吗?

def f1 ():
    n=999
    def f2 ():
        print (n)
    return f2 # f1 返回 f2 函数对象
result = f1 ()   # 调用 f1,并将返回的 f2 函数对象赋值给变量 f2
result ()  # 现在可以像调用普通函数一样调用 f2

# 闭包的概念

上一部分代码中的 f2 函数,就是闭包。

在上面的实例中,有一个外层函数的局部变量 n,有一个内层函数 f2,f2 里面可以访问到 n 变量,那这 f2 就是一个闭包。

下面再看一下维基百科的严谨定义:

在一些语言中,在函数中可以(嵌套)定义另一个函数时,如果内部的函数引用了外部的函数的变量,则可能产生闭包。闭包可以用来在一个函数与一组 “私有” 变量之间创建关联关系。在给定函数被多次调用的过程中,这些私有变量能够保持其持久性。

上面这段话实际上解释了闭包的一个定义和两个作用:

定义:闭包就是能够读取外部函数内的变量的函数。

  • 作用 1:闭包是将外层函数内的局部变量和外层函数的外部连接起来的一座桥梁。

  • 作用 2:将外层函数的变量持久地保存在内存中。

支持将函数当成对象使用的编程语言,一般都支持闭包。比如 Python, JavaScript。

# 闭包的用途

闭包可以用在许多地方,维基百科的定义中已经提到的它的两个用处:

  • 可以读取函数内部的变量。
  • 让这些变量的值始终保持在内存中。

# 读取函数内部的变量

在前面,我们讲到,有时候会为了保证命名空间的干净而把一些变量隐藏到函数内部,作为局部变量。但是由于 Python 中作用域的搜索顺序,函数内的变量不会被函数外的代码读取到。

如果这时候想要函数外部的代码能够读取函数内部的变量,那么就可以使用闭包。

闭包存在的意义就是它夹带了外部变量(私货),如果它不夹带私货,它和普通的函数就没有任何区别。同一个的函数夹带了不同的私货,就实现了不同的功能。

其实你也可以这么理解,闭包和面向接口编程的概念很像,可以把闭包理解成轻量级的接口封装。

def tag (tag_name):
    def add_tag (content):
        return "<{0}>{1}</{0}>".format (tag_name, content)
    return add_tag
    
content = 'Hello'
add_tag = tag ('a')
print (add_tag (content))
# <a>Hello</a>
add_tag = tag ('b')
print (add_tag (content))
# <b>Hello</b>

在这个例子里,我们想要一个给 contenttag 的功能,但是具体的 tag_name 是什么样子的要根据实际需求来定,对外部调用的接口已经确定,就是 add_tag (content) 。如果按照面向接口方式实现,我们会先把 add_tag 写成接口,指定其参数和返回类型,然后分别去实现 abadd_tag

但是在闭包的概念中, add_tag 就是一个函数,它需要 tag_namecontent 两个参数,只不过 tag_name 这个参数是打包带走的。所以一开始时就可以告诉我怎么打包,然后带走就行。

当然,我们也可是使用面向对象实现上述功能:

class Tag ():
    def __init__(self, tag_name):
        self.tag_name = tag_name
    
    def add_tag (self, content=''):
        return "<{0}>{1}</{0}>".format (self.tag_name, content)
a = Tag ('a')
b = Tag ('b')
print (a.add_tag ('hello'))
print (b.add_tag ('hello'))

# 让函数内部的局部变量始终保持在内存中

一般来说,函数内部的局部变量在这个函数运行完以后,就会被 Python 的垃圾回收机制从内存中清除掉。如果我们希望这个局部变量能够长久的保存在内存中,那么就可以用闭包来实现这个功能。

以一个类似棋盘游戏的例子来说明:

假设棋盘大小为 50*50,左上角为坐标系原点 (0,0),我需要一个函数,接收 2 个参数,分别为方向 (direction),步长 (step),该函数控制棋子的运动。 这里需要说明的是,每次运动的起点都是上次运动结束的终点。

def create (pos=None):
    if pos is None:
        pos = [0,0]
    def go (direction, step):
        new_x = pos [0]+direction [0]*step
        new_y = pos [1]+direction [1]*step
        
        pos [0] = new_x
        pos [1] = new_y
        
        return pos
    
    
    return go
player = create ()
print (player ([1,0],10))
print (player ([0,1],20))
print (player ([-1,0],10))

在这段代码中,player 实际上就是闭包 go 函数的一个实例对象。

它一共运行了三次,第一次是沿 X 轴前进了 10 来到 [10,0],第二次是沿 Y 轴前进了 20 来到 [10, 20],,第三次是反方向沿 X 轴退了 10 来到 [0, 20]。

这证明了,函数 create 中的局部变量 pos 一直保存在内存中,并没有在 create 调用后被自动清除。

为什么会这样呢?原因就在于 create 是 go 的父函数,而 go 被赋给了一个全局变量,这导致 go 始终在内存中,而 go 的存在依赖于 create,因此 create 也始终在内存中,不会在调用结束后,被垃圾回收机制(garbage collection)回收。

这个时候,闭包使得函数的实例对象的内部变量,变得很像一个类的实例对象的属性,可以一直保存在内存中,并不断的对其进行运算。

# 总结

  • 局部变量无法共享和长久的保存,而全局变量可能造成变量污染,闭包既可以长久的保存变量又不会造成全局污染。

  • 闭包使得函数内局部变量的值始终保持在内存中,不会在外层函数调用后被自动清除。

  • 当外层函数返回了内层函数后,外层函数的局部变量还被内层函数引用。

  • 带参数的装饰器,那么一般都会生成闭包。

  • 闭包在爬虫以及 web 应用中都有很广泛的应用。

# 使用闭包的注意事项

# 内存消耗

由于闭包会使得函数中的变量都被保存在内存中,会增加内存消耗,所以不能滥用闭包,否则会造成程序的性能问题,可能导致内存泄露。

解决方法是,在退出函数之前,将不使用的局部变量全部删除。

# 使用场景

闭包的两个作用,“读取函数内部的变量” 和 “让函数内部的局部变量始终保持在内存中”,都可以被 Python 中现成的对象 “类” 很好地实现。我认为,“闭包” 在 Python 中确实是一个必要性不大的概念。

那么为什么还要在 Python 中引入 “闭包” 这个概念呢?

首先,我觉得最重要的理由是,理解清楚这个概念,对于理解 Python 中的一大利器 “装饰器” 有很大的帮助。因为装饰器本身就是闭包的一个应用。

其次,当我们要实现的功能比较简单的时候,可以用闭包。例如:

  • 当我们的代码中函数比较少的时候,可以使用闭包。(但是如果我们要实现很多功能,还是要使用类(OOP))

  • 如果我们的对象中只有一个方法时,使用闭包是会比用类来实现更优雅。

  • 这有点类似于,如果我们要实现比较简单的函数功能,通常使用 lambda 匿名函数比定义一个完整的 function 更加优雅,而且几乎不会损失可读性。类似的还有用列表解析式代替 for 循环。

# 闭包无法改变外部函数局部变量指向的内存地址

这个是什么意思呢?我们来看下面的例子。

def outer_fun ():
    x = 0
    def inner_fun ():
        x = 1
        print ('inner x:',x, 'at', id (x))
    
    
    print ('outer x before call inner:', x, 'at', id (x))
    inner_fun ()
    print ('outer x before call inner:', x, 'at', id (x))
    
outer_fun ()

如果 inner_fun () 可以修改 x 的的内存地址的话,那么 x 首先在 outer_fun () 中指向了一个储存着 0 的内存地址,后面又在 inner_fun () 中, x 会指向新的储存着 1 的内存地址(由于 int 是不可变类型),但结果是:

outer x before call inner: 0 at 140710425756424
inner x: 1 at 140710425756456
outer x before call inner: 0 at 140710425756424

inner_fun ()x 的值发生了改变,但是原因是重新创建了一个变量 x,指向了一个新的内存地址。而在 outer_fun ()x 的值以及内存地址并未发生变化。

会造成这一结果,是因为 Python 中作用域的搜索顺序。在 inner_fun 函数里面,有自己的命名空间,这个命名空间是独立于 outer_fun 的命名空间的。它里面的 x 是一个局部名称(local names),在执行 “x=1” 命令的时候,是重新在 inner_fun 自己的命名空间里创建了一个新的变量 x ,而无法覆盖掉 outer_fun 的命名空间的 x。

如果要让内层函数不仅可以访问,还要可以修改外层函数的变量,那么需要用到 nonlocal 声明,使得内层函数不要在自己的命名空间创建新的 x,而是操作外层函数命名空间的 x。

def outer_fun ():
    x = 0
    def inner_fun ():
        nonlocal x  # 使用 nonlocal
        x = 1
        print ('inner x:',x, 'at', id (x))
    
    
    print ('outer x before call inner:', x, 'at', id (x))
    inner_fun ()
    print ('outer x before call inner:', x, 'at', id (x))
    
outer_fun ()

我们可以发现,此时 inner_fun 改变了 outer_fun 中的变量的内存地址。

outer x before call inner: 0 at 140710425756424
inner x: 1 at 140710425756456
outer x before call inner: 1 at 140710425756456

同样地,在上文棋盘的例子中,外层函数的变量 pos 内的值虽然一直在改变,但是由于列表本身是可变类型的变量,虽然列表中的元素一直在变,但是列表本身的内存地址没有发生变化。

# 返回闭包时,返回函数不要引用任何循环变量,或者后续会发生变化的变量

在 Python 中,如果要返回一个函数,那么返回函数不要引用任何循环变量,或者后续会发生变化的变量。

因为,返回的函数并没有立刻执行,而是直到调用了 f () 才执行。我们来看一个例子:

def count ():
    fs = []
    for i in range (1, 4):
        def f ():
             return i*i
        fs.append (f)
    return fs
f1, f2, f3 = count ()

在上面的例子中,每次循环,都创建了一个新的函数,然后,把创建的 3 个函数都放在列表中,通过列表整体返回了。

你可能认为调用 f1 (),f2 () 和 f3 () 结果应该是 1,4,9,但实际结果是:

>>> f1 ()
9
>>> f2 ()
9
>>> f3 ()
9

因为在向列表中添加 func 的时候,i 的值没有固定到 f 的实例对象中,而仅是将计算公式固定到了实例对象中。等到了调用 f1 ()、f2 ()、f3 () 的时候才去取 i 的值,这时候循环已经结束,i 的值是 3,所以结果都是 9。

因此,返回闭包时牢记一点:返回函数不要引用任何循环变量,或者后续会发生变化的变量。

如果一定要引用循环变量怎么办?方法是再创建一个函数,用该函数的参数绑定循环变量当前的值,无论该循环变量后续如何更改,已绑定到函数参数的值不变。

def count ():
    def f (j):
        def g ():
            return j*j
        return g
    fs = []
    for i in range (1, 4):
        fs.append (f (i)) # f (i) 立刻被执行,因此 i 的当前值被传入 f ()
    return fs

# 判断一个函数是否是闭包

判断一个函数是不是闭包,可以查看它的 closure 属性。如果该函数是闭包,查看该属性将会返回一个 cell 对象组成的 tuple 。如果我们分别对每个 cell 对象查看其 cell_contents 属性,返回的内容就是闭包引用的自由变量的值。

下面通过一个例子展示:

def add (x,y):
    def f (z):
        return x+y+z
    return f
d = add (5,6)
print (d (9))
print (d (1))

在上述例子中,调用闭包的 __closure__ 方法,可以看出:闭包储存了外部函数的两个变量; cell 的内存地址;在 cell 里面储存的对象类型为 intint 储存的内存地址。

>>> d.__closure__
(<cell at 0x000002B5E615A9E0: int object at 0x00007FF9B10C83A8>, <cell at 0x000002B5E615A950: int object at 0x00007FF9B10C83C8>)

闭包的__closure__方法,可以查看每个 cell 对象的内容

for i in d.__closure__:
    print (i.cell_contents)
>>> 5
>>> 6

cell_contents 解释了局部变量在脱离函数后仍然可以在函数之外被访问的原因,因为变量被存储在 cell_contents 中了。