[译]Python中for循环索引变量的作用域

栏目: Python · 发布时间: 5年前

内容简介:作者:我从一个小测试开始。这个函数做什么?

作者: 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 编译器内部如何在幕后使它工作。感谢阅读!

[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生成,相对清晰的递归访问代码。


以上所述就是小编给大家介绍的《[译]Python中for循环索引变量的作用域》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

数字乌托邦

数字乌托邦

尼古拉斯•卡尔 / 姜忠伟 / 中信前沿出版社 / 2018-5 / 69.00

当下,技术与我们的关系变得越来越紧密不可分割,特别是智能手机等设备的出现,带给整个人类社会一场彻底的变革。的确,智能手机上的各种应用程序让我们的工作生活无比便利:社交媒体让我们能够和他人实时保持联络并传输信息,不再受时间、地点的限制;搜索引擎通过精准的算法将我们所需要的信息整合推送至屏幕上,让我们毫不费力就看到自己想要的;地图软件为我们的出行提供了更多路线选择,甚至可以使用语音导航,帮助我们顺利到......一起来看看 《数字乌托邦》 这本书的介绍吧!

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器

html转js在线工具
html转js在线工具

html转js在线工具