Python闭包和保存自由变量

1.闭包:用函数代替类

术语闭包(closure) 来自抽象代数。抽象代数里,一集元素称为在某个运算(操作)之下封闭,如果将该运算应用于这一集合中的元素,产出的仍然是该集合中的元素。然而在Python社区中还用术语“闭包”表述于此一个毫不相干的概念。注意我们文章中所称的闭包为Python中的闭包,而非抽象代数中的闭包。

Python中的闭包是一种特殊的被外层函数包围的内层函数,它能够在外层函数执行完毕后仍然能获取在外层函数范围中、但未绑定到内层函数作用域的自由变量(free variable))。因此闭包可以保存额外的变量环境,用于在函数调用时使用。比如下列代码:

def make_printer(msg):
    def printer():
        print(msg)
    return printer

printer = make_printer('Foo!')
printer() #Foo!

在对内层函数printer()进行调用时,外层函数make_printer()已经执行完毕,但printer()仍可获取自由变量msg。

注意,闭包必须要要有对自由变量获取,如果没有获取自由变量,则只是一个普通的嵌套函数,如下图所示:

def make_printer(msg):
    def printer():
         pass
    return printer
printer = make_printer('Foo!')

那么闭包有什么使用价值呢?

有时我们会定义只有一个方法(除了__init__()之外)的类,而这种类可以通过使用闭包(closure)来替代。考虑下面这个例子,这个类允许用户通过某种模板方案来获取URL。

from urllib.request import urlopen
class UrlTemplate:
     def __init__(self, template) -> None:
         self.template = template
     def open(self, **kwargs):
         return urlopen(self.template.format_map(kwargs))
    
 yahoo = UrlTemplate('http://finance.yahoo.com/d/quotes.csv?s={names}&f={fields}')
 for line in yahoo.open(names='IBM,AAPL,FB', fields = 'sllclv'):
     print(line.decode('utf-8'))

这个类可以用一个简单的函数来替代:

def urltempalte(template):
    # 后面用对象再次调用函数的时候传入kwargs
    def opener(**kwargs):
        return urlopen(template.format_map(kwargs))
    return opener

yahoo = urltempalte('http://finance.yahoo.com/d/quotes.csv?s={names}&f={fields}')
for line in yahoo(names='IBM,AAPL,FB', fields = 'sllclv'):
    print(line.decode('utf-8'))

在许多情况下,我们会使用只有单个方法的类的原因是需要保存额外的状态给类方法使用。我们上面提到的UrlTemplate类的唯一目的就是将template的值保存在某处,然后在open()方法中使用它。而使用闭包来解决该问题会更加简短和优雅,我们使用opener()函数记住参数template的值,然后再随后的调用中使用该值。

所以大家在编写代码中需要附加额外的状态给函数时,一定要考虑使用闭包。

2.访问已保存的自由变量

我们知道闭包函数可以获取到被额外保留的变量环境。但这些变量对闭包函数来说是自由变量,还未绑定到内层函数的作用域。如果我们尝试修改这些变量:

def sample():
    n = 0
    # 闭包函数
    def func():
        n += 1
        print("n =", n)
    return func

f = sample()
f()

就会抛出UnboundLocalError:local variable 'n' referenced before assignment。

此时我们就需要nonlocal关键字将自由变量与内层函数绑定,然后就可以对其进行修改了:

def sample():
    n = 0
    # 闭包函数
    def func():
        nonlocal n
        n += 1
        print("n =", n)
    return func

f = sample()
f() # n = 1
f() # n = 2
f2 = sample() # # another instance, with a different stack
f2() # n = 1

更进一步地,我们会通过函数来扩展闭包,使得保存的自由变量可以在外界被访问和修改。一般来说,保存的自由变量对外界来说是完全隔离的,如果想要访问和修改它们,需要编写存取函数(accessor function, 即getter/setter方法),并将它们做为函数属性附加到闭包上来提供对保存的自由变量的访问支持:

def sample():
    n = 0
    # 闭包函数
    def func():
        print("n =", n)
    
    # 存取函数(accessor function),即getter/setter方法
    def get_n():
        return n

    def set_n(value):
        # 必须要加nolocal才能修改保存的自由变量
        nonlocal n
        n = value

    # 做为函数属性附加
    func.get_n = get_n
    func.set_n = set_n
    return func

该算法测试运行结果如下:

f = sample()
f() # n = 0
f.set_n(10)
f() # n = 10
print(f.get_n()) # 10

可以看到,get_n()set_n()工作起来很像实例的方法。注意一定要将get_n()set_n()做为函数属性附加上去,否则在调用set_n()get_n()就会报错:'function' object has no attribute 'set_n'。
如果我们希望让闭包完全模拟成类实例,我们需要架构内层函数拷贝到一个实例的字典中然后将它返回。

示例如下:

import sys
class ClosureInstance:
    def __init__(self, locals=None) -> None:
        if locals is None:
            locals = sys._getframe(1).f_locals 
            
        # Update instance dictionary with callables
        self.__dict__.update(
            (key, value) for key, value in locals.items() if callable(value)
        )

    # Redirect special methods
    def __len__(self):
        return self.__dict__['__len__']()
    
# Example use
def Stack():
    items = []

    def push(item):
        items.append(item)
    
    def pop():
        return items.pop()
    
    def __len__():
        return len(items)
    
    return ClosureInstance()

下面展示了对应的测试结果:

s = Stack()
print(s) # <__main__.ClosureInstance object at 0x101efc280>
s.push(10)
s.push(20)
s.push('Hello')
print(len(s)) # 3
print(s.pop()) # Hello
print(s.pop()) # 20
print(s.pop()) # 10

用闭包模型类的功能比传统的类实现方法要快一些。比如我们用下面这个类做为测试对比。

#学习中遇到问题没人解答?小编创建了一个Python学习交流群:531509025

class Stack2:
    def __init__(self) -> None:
        self.items = []
    
    def push(self, item):
        self.items.append(item)

    def pop(self):
        return self.items.pop()
    
    def __len__(self):
        return len(self.items)

下面是我们的测试结果:

from timeit import timeit
s = Stack()
print(timeit('s.push(1);s.pop()', 'from __main__ import s'))
# 0.98746542
s = Stack2()
print(timeit('s.push(1);s.pop()', 'from __main__ import s'))
# 1.07070521

可以看到采用闭包的版本要快大约8%。因为对于实例而言,测试话费的大部分时间都在对实例变量的访问上,而闭包要更快一些,因为不用涉及额外的self变量。

不过这种奇技淫巧在代码中还是要谨慎使用,因为相比一个真正的类,这种方法其实是相当怪异的。像继承、属性、描述符或者类方法这样的特性在这种方法都是无法使用的。而且我们还需要一些花招才能让特殊方法正常工作(比如我们上面ClosureInstance中对__len__()的实现)。不过,这仍然是一个非常有学术价值的例子,它告诉我们对闭包内部提供访问机制能够实现怎样的功能。