第21条 了解如何在闭包里面使用外围作用域中的变量
有时,我们要给列表中的元素排序,而且要优先把某个群组之中的元素放在其他元素的前面。例如,渲染用户界面时,可能就需要这样做,因为关键的消息和特殊的事件应该优先显示在其他信息之前。
实现这种做法的一种常见方案,是把辅助函数通过key
参数传给列表的sort
方法(参见第14条),让这个方法根据辅助函数所返回的值来决定元素在列表中的先后顺序。辅助函数先判断当前元素是否处在重要群组里,如果在,就把返回值的第一项写成0,让它能够排在不属于这个组的那些元素之前。
这个函数可以处理比较简单的输入数据。
它为什么能够实现这个功能呢?这要分三个原因来讲:
- Python支持闭包(closure),这让定义在大函数里面的小函数也能引用大函数之中的变量。具体到这个例子,
sort_priority
函数里面的那个helper
函数也能够引用前者的group
参数。 - 函数在Python里是头等对象(first-class object),所以你可以像操作其他对象那样,直接引用它们、把它们赋给变量、将它们当成参数传给其他函数,或是在
in
表达式与if
语句里面对它做比较,等等。闭包函数也是函数,所以,同样可以传给sort
方法的key
参数。 - Python在判断两个序列(包括元组)的大小时,有自己的一套规则。它首先比较0号位置的那两个元素,如果相等,那就比较1号位置的那两个元素;如果还相等,那就比较2号位置的那两个元素;依此类推,直到得出结论为止。所以,我们可以利用这套规则让
helper
这个闭包函数返回一个元组,并把关键指标写为元组的首个元素以表示当前排序的值是否属于重要群组(0表示属于,1表示不属于)。
如果这个sort_priority
函数还能告诉我们,列表里面有没有位于重要群组之中的元素,那就更好了,因为这样可以让用户界面开发者更方便地做出相应处理。添加这样一个功能似乎相当简单,因为闭包函数本身就需要判断当前值是否处在重要群组之中,既然这样,那么不妨让它在发现这种值时,顺便把标志变量翻转过来。最后,让闭包外的大函数(即sort_priority
函数)返回这个标志变量,如果闭包函数当时遇到过这样的值,那么这个标志肯定是True
。
下面,试着用最直接的写法来实现。
我们还是用刚才的输入数据来运行这个新函数。
排序结果没有问题,可以看到:在排过序的numbers
里面,重要群组group
里的那些元素(2、3、5、7),确实出现在了其他元素前面。既然这样,那表示函数返回值的found
变量就应该是True
,但我们看到的却是False
,这是为什么?
在表达式中引用某个变量时,Python解释器会按照下面的顺序,在各个作用域(scope)里面查找这个变量,以解析(resolve)这次引用[1]。
1)当前函数的作用域。
2)外围作用域(例如包含当前函数的其他函数所对应的作用域)。
3)包含当前代码的那个模块所对应的作用域(也叫全局作用域,global scope)。
4)内置作用域(built-in scope,也就是包含len
与str
等函数的那个作用域)。
如果这些作用域中都没有定义名称相符的变量,那么程序就抛出NameError
异常。
刚才讲的是怎么引用变量,现在我们来讲怎么给变量赋值[2],这要分两种情况处理。如果变量已经定义在当前作用域中,那么直接把新值交给它就行了。如果当前作用域中不存在这个变量,那么即便外围作用域里有同名的变量,Python也还是会把这次的赋值操作当成变量的定义来处理,这会产生一个重要的效果,也就是说,Python会把包含赋值操作的这个函数当成新定义的这个变量的作用域。
这可以解释刚才那种写法错在何处。sort_priority2
函数里面的helper
闭包函数是把True
赋给了found
变量。当前作用域里没有这样一个叫作found
的变量,所以就算外围的sort_priority2
函数里面有found
变量,系统也还是会把这次赋值当成定义,也就是会在helper
里面定义一个新的found
变量,而不是把它当成给sort_priority2
已有的那个found
变量赋值。
这种问题有时也称作作用域bug(scoping bug),Python新手可能认为这样的赋值规则很奇怪,但实际上Python是故意这么设计的。因为这样可以防止函数中的局部变量污染外围模块。假如不这样做,那么函数里的每条赋值语句都有可能影响全局作用域的变量,这样不仅混乱,而且会让全局变量之间彼此交互影响,从而导致很多难以探查的bug。
Python有一种特殊的写法,可以把闭包里面的数据赋给闭包外面的变量。用nonlocal
语句描述变量,就可以让系统在处理针对这个变量的赋值操作时,去外围作用域查找。然而,nonlocal
有个限制,就是不能侵入模块级别的作用域(以防污染全局作用域)。
下面,用nonlocal
改写刚才那个函数。
nonlocal
语句清楚地表明,我们要把数据赋给闭包之外的变量。有一种跟它互补的语句,叫作global,用这种语句描述某个变量后,在给这个变量赋值时,系统会直接把它放到模块作用域(或者说全局作用域)中。
我们都知道全局变量不应该滥用,其实nonlocal
也这样。除比较简单的函数外,大家尽量不要用这个语句,因为它造成的副作用有时很难发现。尤其是在那种比较长的函数里,nonlocal
语句与其关联变量的赋值操作之间可能隔得很远。
如果nonlocal
的用法比较复杂,那最好是改用辅助类来封装状态。下面就定义了这样一个类,用来实现与刚才那种写法相同的效果。这样虽然稍微长一点,但看起来更清晰易读(__call__
这个特殊方法,请参见第38条)。
要点
- 闭包函数可以引用定义它们的那个外围作用域之中的变量。
- 按照默认的写法,在闭包里面给变量赋值并不会改变外围作用域中的同名变量。
- 先用
nonlocal
语句说明,然后赋值,可以修改外围作用域中的变量。 - 除特别简单的函数外,尽量少用
nonlocal
语句。
[1]详情参见:https://docs.python.org/3/reference/executionmodel.html#resolution-of-names。——译者注
[2]刚才讲的是变量出现在赋值符号(=)右边时,该怎么认定。现在要讲变量出现在赋值符号左边时,该怎么处理。——译者注