Python的作用域与闭包浅析

以示例阐述Python中的作用域和闭包


Python的作用域

直接看示例代码:

>>> b = 6
>>> def f(a):
... print(a)
... print(b)
... b = 9
...
>>> f(3)
3
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 3, in f
UnboundLocalError: local variable 'b' referenced before assignment

示例说明:

输出了 3,这表明 print(a) 语句执行了。但是第二个语句 print(b) 执行不了。

事实是,Python 编译函数的定义体时,它判断 b 是局部变量,因为在函数中给它赋值了。生成的字节码证实了这种判断,Python 会尝试从本地环境获取 b。后面调用 f(3) 时, f 的定义体会获取并打印局部变量 a 的值,但是尝试获取局部变量 b 的值时,发现 b 没有绑定值。

这不是缺陷,而是设计选择:Python 不要求声明变量,但是假定在函数定义体中赋值的变量是局部变量。这比 JavaScript 的行为好多了, JavaScript 也不要求声明变量,但是如果忘记把变量声明为局部变量(使用 var),可能会在不知情的情况下获取全局变量。 (有道理,赞同Pyton这种设计取舍,墨菲定律,人都是不可靠的,只要可能犯错,那就一定会犯错,还不如在根基处做显式的限制,直接了当!)

如果在函数中赋值时想让解释器把 b 当成全局变量,要使用 global 声明:

>>> b = 6
>>> def f(a):
... global b
... print(a)
... print(b)
... b = 9
...
>>> f(3)
3
6
>>> b
9
>>> f(3)
3
9
>>> b
9

Python中的闭包

在博客圈,人们有时会把闭包和匿名函数弄混。这是有历史原因的:在函数内部定义函数不常见,直到开始使用匿名函数才会这样做。而且,只有涉及嵌套函数时才有闭包问题。因此,很多人是同时知道这两个概念的。

其实,闭包指延伸了作用域的函数,其中包含函数定义体中引用、但是不在定义体中定义的非全局变量。函数是不是匿名的没有关系,关键是它能访问定义体之外定义的非全局变量。 (关键)

在Python中创建一个闭包可以归结为以下三点:

  • 闭包函数必须有内嵌函数
  • 内嵌函数需要引用该嵌套函数上一级namespace中的变量
  • 闭包函数必须返回内嵌函数

通过这三点,就可以创建一个闭包,Python装饰器就是使用了闭包。

这个概念难以掌握,最好通过示例理解。

思考:怎么实现计算不断增加的一系列值的平均值?

解决方案一:

思路:实现一个计算系列值平均值的类,使其可调用(实现call),则其实例是可调用对象。

class Averager():
def __init__(self):
self.series = []
def __call__(self, new_value): # 实例是可调用对象
self.series.append(new_value)
total = sum(self.series)
return total / len(self.series)

测试用例:

In [2]: avg = Averager()
In [3]: avg(10)
Out[3]: 10.0
In [4]: avg(11)
Out[4]: 10.5
In [5]: avg(12)
Out[5]: 11.0

avg是Averager的实例,显然系列所有历史值保存在self.series中,每次调用avg都会保存历史值并返回均值。

解决方案二:

思路:用函数实现,利用闭包保存历史系列值

def make_averager():
series = []
def averager(new_value):
series.append(new_value)
total = sum(series)
return total / len(series)
return averager

测试用例:

In [12]: avg = make_averager()
In [13]: avg
Out[13]: <function make_averager.<locals>.averager>
In [14]: avg(10)
Out[14]: 10.0
In [15]: avg(11)
Out[15]: 10.5
In [16]: avg(12)
Out[16]: 11.0

调用 make_averager 时,返回一个 averager 函数对象。每次调用 averager 时,它会把参数添加到系列值中,然后计算当前平均值。
series 是 make_averager 函数的局部变量,因为那个函数的定义体中初始化了,调用完avg后series并没有被销毁,在averager 函数中,series 是自由变量(free variable)。averager 的闭包延伸到函数的作用域之外, 包含自由变量 series 的绑定。series 的绑定在返回的 avg 函数的 __closure__ 属性中。

综上,闭包是一种函数,它会保留定义函数时存在的自由变量的绑定,这样调用函数时,虽然定义作用域不可用了,但是仍能使用那些绑定。

注意,只有嵌套在其他函数中的函数才可能需要处理不在全局作用域中的外部变量。

解决方案二的改进:

前面实现 make_averager 函数的方法效率不高。我们把所有值存储在历史数列中,然后在每次调用 averager 时使用 sum 求和。更好的实现方式是,只存储目前的总值和元素个数,然后使用这两个数计算均值。

make_averager改进如下:

def make_averager():
count = 0
total = 0
def averager(new_value):
count += 1
total += new_value
return total / count
return averager

测试用例:

In [20]: avg = make_averager()
In [21]: avg(10)
/Users/molock/average.py in averager(new_value)
25
26 def averager(new_value):
---> 27 count += 1
28 total += new_value
29 return total / count
UnboundLocalError: local variable 'count' referenced before assignment

测试的时候发现出错了。原因分析:

当 count 是数字或任何不可变类型时,count += 1 语句的作用其实与 count = count + 1 一样。因此,我们在 averager 的定义 体中为 count 赋值了,这会把 count 变成局部变量。total 变量也受这个问题影响。

对数字、字符串、元组等不可变类型来说,只能读取,不能更新。 如果尝试重新绑定,例如 count = count + 1,其实会隐式创建局部变量 count。这样,count 就不是自由变量了,因此不会保存在闭包中。

为了解决这个问题,Python 3 引入了 nonlocal 声明。它的作用是把变量标记为自由变量,即使在函数中为变量赋予新值了,也会变成自由变量。如果为 nonlocal 声明的变量赋予新值,闭包中保存的绑定会更新

改进后代码如下:

def make_averager():
count = 0
total = 0
def averager(new_value):
nonlocal count, total # 使用nonlocal变成自由变量
count += 1
total += new_value
return total / count
return averager

测试用例:

In [26]: avg = make_averager()
In [27]: avg(10)
Out[27]: 10.0

测试案例正常通过。

Python2.x没有nonlocal,解决方案是把内部函数需要修改的变量(如 count 和 total)存储为可变对象(如字典或简单的实例)的元素或属性,并且把那个对象绑定给一个自由变量。

参考并整理自《流畅的Python》第7章相关内容。