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

作者: 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捕捉到的全部(译注:因为lambdaprint语句里展开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_FASTSTORE_FASTPython用于访问仅在函数内使用的名字的操作码。因为Python编译器静态地(在编译时刻)知道在每个函数里存在多少这样的名字,可以使用静态数组偏移量(而不是哈希表)访问它们,这使得访问显著更快(因此有_FAST后缀)。但我不同意。这里真正重要的是ai被同等对待。它们都使用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,并注意iName节点的ctx=Store部分。它是AST,携带着i保存在For节点target目标里的信息。让我们看一下这是怎么形成的。

编译器的AST构建部分遍历解析树(这是源代码相当低级的层次表示——这里有一些背景知识),在其他因素中,在某些节点上设置expr_context属性,最主要是Name节点。在下面的语句里,这样来思考它:

foo = bar + 1

foobar都会终结在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)