我们从一个测试开始。下面这个函数的功能是什么?
如果你觉得它的功能是“计算lst
中所有元素的和与积”,不要沮丧。通常很难发现这里的错误。如果在大堆真实的代码中发现了这个错误就非常厉害了。——当你不知道这是一个测试时,很难发现这个错误。
这里的错误是在第二个循环体中使用了i
而不是t
。等下,这到底是怎么工作的?i
在第一个循环外应该是不可见的? [1]哦,不。事实上,Python正式声明过,为for
循环目标(loop target)定义的名称(更严格的正式名称为“索引变量”)能泄露到外围函数范围。因此下面的代码:
这段代码是有效的,可以打印出3。在本文中,我想探讨一下为什么会这样,为什么它不太可能改变,以及将它作为一颗追踪子弹来挖掘CPython编辑器中一些有趣的部分。
顺便说一句,如果你不相信这种行为可能会导致真正的问题,考虑这个代码片断:
如果你期待上面的代码能打印出[0,1,2,3]
,你的期望会落空的,它会打印出[3,3,3,3]
;因为在foo
的作用域内只有一个i
,这个i
就是所有的lambda
所捕获的。
Python参考文档中的部分明确地记录了这种行为:
for循环将变量赋值到目标列表中。……当循环结束时,赋值列表中的变量不会被删除,但如果序列是空的,它们将不会被赋值给所有的循环。
注意最后一句,让我们试试:
的确,上面的代码抛出NameError
异常。稍后,我们将看到这是Python虚拟机执行字节码方式的必然结果。
其实我问过Guido van Rossum有关这个执行行为的原因,他很慷慨地告诉了我其中的一些历史背景(感谢Guido!)。这样执行代码的动机是保持Python获得变量和作用域的简单性,而不诉诸于hacks(例如在循环完成后,删除定义在该循环中的所有变量——想想它可能引发的异常)或更复杂的作用域规则。
Python的作用域规则非常简单、优雅:模块、类以及函数的代码块可引入作用域。在函数体内,变量从它们定义到代码块结束(包括嵌套的代码块如嵌套函数)都是可见的。当然,对于局部变量、全局变量(以及其他nonlocal变量)其规则略有不同。不过,这和我们的讨论没有太多关系。
这里最重要的一点是:最内层的可能作用域是一个函数体。不是一个for循环体。不是一个with
代码块。Python与其他编程语言不同(例如C及其后代语言),在函数水平下没有嵌套词法作用域。
因此,如果你只是基于Python实现,你的代码可能会以这样的执行行为结束。下面是另一段令人启发的代码片段:
变量d
在for
循环结束后是可见及可访问的,你对这样的发现感到惊奇吗?不,这正是Python的工作方式。那么,为什么索引变量的作用域被区别对待呢?
顺便说一句,列表推导式(list comprehension)中的索引变量也泄露到其封闭作用域,或者更准确的说,在Python 3之前可以泄露。
Python 3包含许多重大更改,其中也修复了列表推导式中的变量泄露问题。毫无疑问,这样破坏了向后兼容中性。这就是我认为当前的执行行为不会被改变的原因。
此外,许多人仍然发现这是Python中的一个有用的功能。考虑一下下面的代码:
如果不知道somegenerator
返回项的数目,可以使用这种简洁的方式。否则,你就必须有一个独立的计数器。
这里有一个其他的例子:
这种模式可以有效的在循环中查找某一项并在随后使用该项。[2]
多年来,许多用户都想保留这种特性。但即使对于开发者认定的有害特性,也很难引入重大更改了。当许多人认为该特性很有用,而且在真实世界的代码中大量使用时,就更不会除去这项特性了。
现在是最有趣的部分。让我们来看看Python编译器和VM是如何协同工作,让这种代码执行行为成为可能的。在这种特殊的情况下,我认为呈现这些的最清晰方式是从字节码开始逆向分析。我希望通过这个例子来介绍如何挖掘Python内部[3]的信息(这是如此充满乐趣!)。
让我们来看本文开篇提出的函数的一部分:
产生的字节码是:
作为提示,LOAD_FAST
和STORE_FAST
是字节码(opcode),Python用它来访问只在函数中使用的变量。由于Python编译器知道(编译时)在每个函数中有多少个这样的静态变量,它们可以通过静态数组偏移量而不是一个哈希表进行访问,这使得访问速度更快(因而是_FAST
后缀)。我有些离题了。这里真正重要的是变量a
和i
被平等对待。它们都通过LOAD_FAST
获取,并通过STORE_FAST
修改。绝对没有任何理由认为它们的可见性是不同的。[4]
那么,这种执行现象是怎么发生的?为什么编译器认为变量i
只是foo
中的一个局部变量。这个逻辑在符号表中的代码中,当编译器执行到AST开始创建一个控制流图,随后会产生字节码。这个过程的更多细节在我有关中的介绍——所以我只在这里提及其中的重点。
符号表代码并不认为for
语句很特别。在symtable_visit_stmt
中有如下代码:
索引变量如任何其他表达式一样被访问。由于该代码访问了AST,这值得去看看for
语句结点内部是怎样的:
所以i
在一个名为Name
的节点中。这些是由符号表代码通过symtable_visit_expr
中以下语句来处理的:
由于变量i
被清楚地标记为DEF_LOCAL
(因为* _FAST
字节码是可访问的,但是这也很容易观察到,如果符号表是不能用的则使用symtable
模块),上述明显的代码调用symtable_add_def
与DEF_LOCAL
作为第三个参数。现在来浏览一下上面的AST,并注意到Name
结点中i
的ctx=Store
部分。因此,它是在For
结点的target
部分存储着i
的信息的AST。让我们看看这是如何实现的。
编译器中的AST构建部分越过了解析树(这是源代码中相当底层的表示——一些背景资料可以在获得),同时在其他事项中,在某些结点设置expr_context
属性,其中最显著的是Name
结点。想想看,这样一来,在下面的语句:
for
和bar
这两个变量都将在Name
结点中结束。但是bar
只是被加载到这段代码中,而for
实际上被存储到这段代码中。expr_context
属性通过符号表代码被用来区分当前和未来使用[5] 。
回到我们for
循环的索引变量。这些内容将在函数ast_for_for_stmt
——for
语句创建AST——中处理。下面是该函数的相关部分:
在调用函数ast_for_exprlist
时创建了Store
上下文,该函数为索引变量创建了一个结点(注意,for
循环的索引变量还可能是一序列变量的元组,而不仅仅是一个变量)。
在介绍为什么for
循环变量和循环中的其他变量一视同仁的过程中,这个函数是最后总要的一部分。在AST中进行标记之后,在符号表和虚拟机中用于处理循环变量的代码与处理其他变量的代码是相同的。