内容简介:作者:我从一个小测试开始。这个函数做什么?
作者: Eli Bendersky
我从一个小测试开始。这个函数做什么?
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 编译器内部如何在幕后使它工作。感谢阅读!
这里我想讲一个Microsoft Visual C++ 6的笑话,但事实上在2015年,本文的大多数读者都不能领会它,有点尴尬(因为它反映了我的年龄,不是我读者的能力问题)。 |
你会争论在这里dowithstuff(i)应该在 break之前进入if。但这不总是便利的。另外,根据Guido的说法,这里有良好的关注点隔离——循环用于搜索,也只有这个目的。搜索完成后值发生什么变化,就不是循环关注的了。我认为这是一个非常好的观点。 |
如通常我关于 Python 内在的文章,这是关于Python 3的。特定地,我正在看Python代码库的default分支,上面进行着下一个发布(3.5)的工作。但对于这个特定的议题,3.x系列的任何源代码发布都适用。 |
从反汇编另一件显然的事情是,为什么如果循环不执行,i保持不可见。GET_ITER与FOR_ITER操作码对(pair)将我们循环遍历处理作一个迭代器,然后调用__next__方法。如果调用最终抛出StopIteration异常,VM捕捉它并退出循环。只有返回一个实际值时,VM才会着手对i执行STORE_FAST,因而后续代码可对它进行引用。 |
这是一个奇怪的设计,我猜它源于AST消费者里,比如符号表代码及CFG生成,相对清晰的递归访问代码。 |
以上所述就是小编给大家介绍的《[译]Python中for循环索引变量的作用域》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- 全局变量,静态全局变量,局部变量,静态局部变量
- python变量与变量作用域
- Python基础-类变量和实例变量
- python编程(类变量和实例变量)
- 03-Golang局部变量和全局变量
- MySQL索引使用说明(单列索引和多列索引)
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
C# 6.0本质论
[美] Mark Michaelis(马克·米凯利斯)、[美] Eric Lippert(埃里克·利珀特) / 周靖、庞燕 / 人民邮电出版社 / 2017-2-1 / 108
这是C#领域中一部广受好评的名作,作者用一种易于理解的方式详细介绍了C#语言的各个方面。全书共有21章和4个附录(其中哟2个附录从网上下载),介绍了C#语言的数据类型、操作符、方法、类、接口、异常处理等基本概念,深入讨论了泛型、迭代器、反射、线程和互操作性等高级主题,还介绍了LINQ技术,以及与其相关的扩展方法、分部方法、Lambda表达式、标准查询操作符和查询表达式等内容。每章开头的“思维导图”......一起来看看 《C# 6.0本质论》 这本书的介绍吧!