作者: Eli Bendersky
原文链接:https://eli.thegreenplace.net/2015/the-scope-of-index-variables-in-pythons-for-loops/
我从一个小测试开始。这个函数做什么?
def foo(lst):
a = 0
for i in lst:
a += i
b = 1
for t in lst:
b *= i
return a, b
如果你认为“计算1st中项的和与积”,不要觉得自己太糟。这里的错误通常很难发现。如果你看到了,做得好——但埋藏在如山的真实代码里,在你不知道这是个测试时,发现这个错误要困难得多。
这里的错误是由于在第二个for循环体里使用i而不是t。但等一下,这怎么能工作?在第一个循环外i不是应该不可见吗?【1】好吧,不是的。事实上,Python正式承认定义为for循环目标的名字(“索引变量”更正式的名字)泄露进了围合(enclosing)的函数作用域。因此,这:
for i in [1, 2, 3]:
pass
print(i)
是有效的,并输出3。在这篇文章里我希望探究为什么会这样,为什么它不太可能改变,并使用它作为一颗示踪子弹深入挖掘CPython编译器某些有趣的部分。
顺便提一下,如果你不相信这种行为会导致真正的问题,考虑这个代码片段:
def foo():
lst = []
for i in range(4):
lst.append(lambda: i)
print([f() for f in lst])
如果你期望这会输出[0, 1, 2, 3],没有这样的好事。相反,代码将输出[3, 3, 3, 3],因为在foo的作用域中只有一个i,这是lambda捕捉到的全部(译注:因为lambda在print语句里展开lst时执行,这时只有i=3这个值可见)。
Python参考文档在for循环章节明确记录了这个行为:
For循环向目标列表里的变量赋值。[…]在该循环结束时,目标列表里的名字不会被删除,但如果该序列是空的,那么该循环完全没有向它们赋值。
注意最后一句——让我们尝试一下:
for i in []:
pass
print(i)
确实,抛出了一个NameError。稍后,我们将看到这是Python VM执行其字节码方式的一个自然的结果。
我问过Guido van Rossum关于这个行为,他亲切地回答了一些历史背景(感谢Guido)。动机是保持Python对名字与作用域的简单做法,无需求助于黑客手段(比如在循环结束后删除所有定义在该循环里的值——想一下异常等带来的复杂性)或者更复杂的作用域规则。
在Python中,作用域规则是相当简单、优雅的:一个代码块要么是模块、函数体或类主体。在一个函数体内,名字从定义点到块末尾可见(包括诸如嵌套函数的嵌套块)。当然,这是对于局部名字;全局名字(及其他非局部名字)有稍微不同的规则,但这与我们的讨论无关。
这里的重点是:最里层的可能作用域是一个函数体。不是for循环体。不是with块。在函数之下,Python没有嵌套的词法作用域,不像其他语言(例如C与其后裔)。
因此,如果你准备实现Python,你将很可能以这个行为结束。下面是另一个有启发性的代码片段:
for i in range(4):
d = i * 2
print(d)
发现在for循环结束后d可见、可访问,会让你吃惊吗?不,这是Python工作的方式。因此,为什么要不同对待索引变量呢?
顺便提一下,在Python 3出现前,列表推导(list comprehension)的索引变量也泄露进了围合(enclosing)的作用域。
Python 3修复了列表推导的泄露,连同其他破坏性的改变。别搞错,改变这样的行为主要破坏了后向兼容性。这是为什么我认为当前行为不能变的原因。
另外,许多人仍然发现这是Python一个有用的特性。考虑:
for i, item in enumerate(somegenerator()):
dostuffwith(i, item)
print('The loop executed {0} times!'.format(i+1))
如果你不知道somegenerator实际返回了多少项,有一个相当简洁的方式知道。否则,你需要保持一个独立的计数器。
下面是另一个例子:
for i in somegenerator():
if isinteresing(i):
break
dostuffwith(i)
这是在一个循环里找出东西,并在后面使用它们的一个有用的模式【2】。
多年来人们还想出了其他一些方法来证明保持这种行为是合理的。对核心开发人员认为有害的特性进行渐进突破性修改是非常困难的。在特性被许多人争论为有用,并且在现实世界中在大量代码里使用时,删除它的机会为零。
现在是有趣的部分。让我们看一下Python编译器与VM如何协力使这个行为成为可能。在这个特定的情形里,我认为表示事物最明晰的方式是从字节码回退。我希望这也可能作为如何在Python内部挖掘【3】以发现东西的一个有趣例子(真的,它非常有趣!)
让我们获取在本文开头展示函数的一部分并反汇编它:
def foo(lst):
a = 0
for i in lst:
a += i
return a
得到的字节码是:
0 LOAD_CONST 1 (0)
3 STORE_FAST 1 (a)
6 SETUP_LOOP 24 (to 33)
9 LOAD_FAST 0 (lst)
12 GET_ITER
13 FOR_ITER 16 (to 32)
16 STORE_FAST 2 (i)
19 LOAD_FAST 1 (a)
22 LOAD_FAST 2 (i)
25 INPLACE_ADD
26 STORE_FAST 1 (a)
29 JUMP_ABSOLUTE 13
32 POP_BLOCK
33 LOAD_FAST 1 (a)
36 RETURN_VALUE
作为提醒,LOAD_FAST与STORE_FAST是Python用于访问仅在函数内使用的名字的操作码。因为Python编译器静态地(在编译时刻)知道在每个函数里存在多少这样的名字,可以使用静态数组偏移量(而不是哈希表)访问它们,这使得访问显著更快(因此有_FAST后缀)。但我不同意。这里真正重要的是a与i被同等对待。它们都使用LOAD_FAST获取,使用STORE_FAST修改。绝对没有理由假定它们的可见性有差异【4】。
这是怎么来的呢?不知何故,编译器认为i只是foo中的另一个本地名字。在编译器遍历AST(译注:抽象语法树)创建稍后发布字节码的控制流图时,这个逻辑存在于符号表代码中;这个过程的更多细节在我关于符号表的博文里——因此,在这里我将仅保留概要。
符号表代码没有非常特殊地处理for语句。
在symtable_visit_stmt中,我们有:
case For_kind:
VISIT(st, expr, s->v.For.target);
VISIT(st, expr, s->v.For.iter);
VISIT_SEQ(st, stmt, s->v.For.body);
if (s->v.For.orelse)
VISIT_SEQ(st, stmt, s->v.For.orelse);
break;
就像其他表达式那样访问循环目标。因为这个代码访问AST,值得倾印它(译注:把节点内容打印出来),看一下for语句的节点是什么样的:
For(target=Name(id='i', ctx=Store()),
iter=Name(id='lst', ctx=Load()),
body=[AugAssign(target=Name(id='a', ctx=Store()),
op=Add(),
value=Name(id='i', ctx=Load()))],
orelse=[])
因此i存活在一个Name节点里。在符号表代码中,这些由symtable_visit_expr的以下分支处理:
case Name_kind:
if (!symtable_add_def(st, e->v.Name.id,
e->v.Name.ctx == Load ? USE : DEF_LOCAL))
VISIT_QUIT(st, 0);
/* ... */
因为名字i被标记为DEF_LOCAL(因为发布*_FAST操作码来访问它,如果使用symtable模块倾印符号表,这也很容易看到),上面的代码显然以DEF_LOCAL作为第三个实参调用symtable_add_def。是时候看一眼上面的AST,并注意i的Name节点的ctx=Store部分。它是AST,携带着i保存在For节点target目标里的信息。让我们看一下这是怎么形成的。
编译器的AST构建部分遍历解析树(这是源代码相当低级的层次表示——这里有一些背景知识),在其他因素中,在某些节点上设置expr_context属性,最主要是Name节点。在下面的语句里,这样来思考它:
foo = bar + 1
foo与bar都会终结在Name节点。但bar只是载(读)入,foo实际保存入这个节点。针对后面符号表代码的消耗,属性expr_context用于区分这些用途【5】。
回到我们的for循环目标。这些在为for语句创建一个AST的函数里处理——ast_for_for_stmt。下面是这个函数的相关部分:
static stmt_ty
ast_for_for_stmt(struct compiling *c, const node *n)
{
asdl_seq *_target, *seq = NULL, *suite_seq;
expr_ty expression;
expr_ty target, first;
/* ... */
node_target = CHILD(n, 1);
_target = ast_for_exprlist(c, node_target, Store);
if (!_target)
return NULL;
/* Check the # of children rather than the length of _target, since
for x, in ... has 1 element in _target, but still requires a Tuple. */
first = (expr_ty)asdl_seq_GET(_target, 0);
if (NCH(node_target) == 1)
target = first;
else
target = Tuple(_target, Store, first->lineno, first->col_offset, c->c_arena);
/* ... */
return For(target, expression, suite_seq, seq, LINENO(n), n->n_col_offset,
c->c_arena);
}
在对ast_for_exprlist的调用里创建了Store上下文,它为目标创建了节点(回忆for循环目标可能是元组拆包的一系列名字,不再是单个名字)。
在解释为什么for循环目标的处理类似于循环内其他名字的过程中,这个函数可能是最重要的部分。在AST中完成这个标记后,在符号表与VM中这样名字的处理与其他名字的处理无异。
本文讨论了Python的一种特殊行为,有些人认为这是一个“陷阱”。我希望本文能很好解释这个行为如何自然地从Python的命名与作用域语义里产生,为什么它是有用的,因而不可能改变,以及Python编译器内部如何在幕后使它工作。感谢阅读!
[1] |
这里我想讲一个Microsoft Visual C++ 6的笑话,但事实上在2015年,本文的大多数读者都不能领会它,有点尴尬(因为它反映了我的年龄,不是我读者的能力问题)。 |
[2] |
你会争论在这里dowithstuff(i)应该在 break之前进入if。但这不总是便利的。另外,根据Guido的说法,这里有良好的关注点隔离——循环用于搜索,也只有这个目的。搜索完成后值发生什么变化,就不是循环关注的了。我认为这是一个非常好的观点。 |
[3] |
如通常我关于Python内在的文章,这是关于Python 3的。特定地,我正在看Python代码库的default分支,上面进行着下一个发布(3.5)的工作。但对于这个特定的议题,3.x系列的任何源代码发布都适用。 |
[4] |
从反汇编另一件显然的事情是,为什么如果循环不执行,i保持不可见。GET_ITER与FOR_ITER操作码对(pair)将我们循环遍历处理作一个迭代器,然后调用__next__方法。如果调用最终抛出StopIteration异常,VM捕捉它并退出循环。只有返回一个实际值时,VM才会着手对i执行STORE_FAST,因而后续代码可对它进行引用。 |
[5] |
这是一个奇怪的设计,我猜它源于AST消费者里,比如符号表代码及CFG生成,相对清晰的递归访问代码。 |