讲解Python中for循环下的索引变量的作用域_python

我们从一个测试开始。下面这个函数的功能是什么?
 

def foo(lst):
  a = 0
  for i in lst:
    a += i
  b = 1
  for t in lst:
    b *= i
  return a, b

如果你觉得它的功能是“计算lst中所有元素的和与积”,不要沮丧。通常很难发现这里的错误。如果在大堆真实的代码中发现了这个错误就非常厉害了。——当你不知道这是一个测试时,很难发现这个错误。

这里的错误是在第二个循环体中使用了i而不是t。等下,这到底是怎么工作的?i在第一个循环外应该是不可见的? [1]哦,不。事实上,Python正式声明过,为for循环目标(loop target)定义的名称(更严格的正式名称为“索引变量”)能泄露到外围函数范围。因此下面的代码:
 

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,这个i就是所有的lambda所捕获的。
官方说明

Python参考文档中的for循环部分明确地记录了这种行为:

    for循环将变量赋值到目标列表中。……当循环结束时,赋值列表中的变量不会被删除,但如果序列是空的,它们将不会被赋值给所有的循环。

注意最后一句,让我们试试:
 

for i in []:
  pass
print(i)

的确,上面的代码抛出NameError异常。稍后,我们将看到这是Python虚拟机执行字节码方式的必然结果。
为什么会是这样

其实我问过Guido van Rossum有关这个执行行为的原因,他很慷慨地告诉了我其中的一些历史背景(感谢Guido!)。这样执行代码的动机是保持Python获得变量和作用域的简单性,而不诉诸于hacks(例如在循环完成后,删除定义在该循环中的所有变量——想想它可能引发的异常)或更复杂的作用域规则。

Python的作用域规则非常简单、优雅:模块、类以及函数的代码块可引入作用域。在函数体内,变量从它们定义到代码块结束(包括嵌套的代码块如嵌套函数)都是可见的。当然,对于局部变量、全局变量(以及其他nonlocal变量)其规则略有不同。不过,这和我们的讨论没有太多关系。

这里最重要的一点是:最内层的可能作用域是一个函数体。不是一个for循环体。不是一个with代码块。Python与其他编程语言不同(例如C及其后代语言),在函数水平下没有嵌套词法作用域。

因此,如果你只是基于Python实现,你的代码可能会以这样的执行行为结束。下面是另一段令人启发的代码片段:
 

for i in range(4):
  d = i * 2
print(d)

变量d 在for循环结束后是可见及可访问的,你对这样的发现感到惊奇吗?不,这正是Python的工作方式。那么,为什么索引变量的作用域被区别对待呢?

顺便说一句,列表推导式(list comprehension)中的索引变量也泄露到其封闭作用域,或者更准确的说,在Python 3之前可以泄露。

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]

多年来,许多用户都想保留这种特性。但即使对于开发者认定的有害特性,也很难引入重大更改了。当许多人认为该特性很有用,而且在真实世界的代码中大量使用时,就更不会除去这项特性了。
Under the hood

现在是最有趣的部分。让我们来看看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是字节码(opcode),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模块),上述明显的代码调用symtable_add_def与DEF_LOCAL 作为第三个参数。现在来浏览一下上面的AST,并注意到Name结点中i的ctx=Store部分。因此,它是在For结点的target部分存储着i的信息的AST。让我们看看这是如何实现的。

编译器中的AST构建部分越过了解析树(这是源代码中相当底层的表示——一些背景资料可以在这里获得),同时在其他事项中,在某些结点设置expr_context属性,其中最显著的是Name结点。想想看,这样一来,在下面的语句:
 

foo = bar + 1

for和bar这两个变量都将在Name结点中结束。但是bar只是被加载到这段代码中,而for实际上被存储到这段代码中。expr_context属性通过符号表代码被用来区分当前和未来使用[5] 。

回到我们for循环的索引变量。这些内容将在函数ast_for_for_stmt——for语句创建AST——中处理。下面是该函数的相关部分:
 

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中进行标记之后,在符号表和虚拟机中用于处理循环变量的代码与处理其他变量的代码是相同的。
结束语

本文讨论了Python中可能被认为是“疑难杂症”的某些特定行为。我希望这篇文章确实解释了Python的变量和作用域的代码执行行为,说明了为什么这些行为是有用的而且永远不太可能改变,以及Python编译器的内部如何使其正常工作。感谢您的阅读!

[1] 在这里,我很想开个Microsoft Visual C ++ 6的玩笑,但事实让人有些不安,因为在2015年这个博客的大部分读者不会懂这个笑话(这反映了我的年龄,而不是我的读者的能力)。

[2] 你可能会说,在执行到break之前时,dowithstuff(i)可以进入if中。但是,这并不总是很方便。此外,根据Guido的解释,这里对我们关注的问题做了一个很好的分离——循环被用于并只用于搜索。在搜索结束后,循环中的变量会发生什么已经不是循环关注的事情。我觉得这是非常好的一点。

[3]: 通常我的文章中的代码是基于Python 3。具体而言,我期待Python库中将要完成的下一个版本(3.5)的default分支。但是对于这个特定的主题,在3.x系列中的任何版本的源代码都应该是可以工作的。

[4] 函数分解中另一件很明显的事是,如果循环不执行,为什么i仍然是不可见的,GET_ITER和FOR_ITER这对字节码将我们的循环当做一个迭代器,然后调用其__next__方法。如果这个调用最后以抛出StopIteration异常结束,虚拟机捕捉到这个异常然后结束循环。只有实际值被返回,虚拟机才会继续对i执行STORE_FAST,因此让这个值存在,让后续代码可以引用。

[5] 这是一个奇怪的设计,我怀疑这个设计的实质是为了使用相对干净的递归访问AST中的代码,如符号表代码和CFG生成器。

以上是小编为您精心准备的的内容,在的博客、问答、公众号、人物、课程等栏目也有的相关内容,欢迎继续使用右上角搜索按钮进行搜索python
python for循环作用域、python for循环 索引、python 循环索引、python 变量作用域、python 作用域,以便于您获取更多的相关知识。

时间: 2024-09-20 00:33:54

讲解Python中for循环下的索引变量的作用域_python的相关文章

初步讲解Python中的元组概念

  这篇文章主要介绍了初步讲解Python中的元组概念,是Python入门中的基础知识,需要的朋友可以参考下 元组是不可变的Python对象序列.元组的序列就像列表.唯一的区别是,元组不能被改变,即元组是不可被修改.元组使用小括号,而列表使用方括号. 创建一个元组很简单,只要把不同的逗号分隔值,可以把括号中的这些逗号来分隔每个值.例如: ? 1 2 3 tup1 = ('physics', 'chemistry', 1997, 2000); tup2 = (1, 2, 3, 4, 5 ); tu

Python中for循环的使用方法

  这篇文章主要介绍了Python中for循环的使用方法,是Python入门中的基础知识,需要的朋友可以参考下 for循环在Python中有遍历所有序列的项目,如列表或一个字符串. 语法: for循环语法如下: ? 1 2 for iterating_var in sequence: statements(s) 如果一个序列包含一个表达式列表,计算第一个.然后,在序列中的第一项被分配给迭代变量iterating_var.接着,语句块被执行.列表中的每个项目分配给iterating_var,并且s

用Python中的字典来处理索引统计的方法

  这篇文章主要介绍了用Python中的字典来处理索引统计的方法,字典的使用是Python学习当中的基础知识,本文则是相关的一个小实践,需要的朋友可以参考下 最近折腾索引引擎以及数据统计方面的工作比较多, 与 Python 字典频繁打交道, 至此整理一份此方面 API 的用法与坑法备案. 索引引擎的基本工作原理便是倒排索引, 即将一个文档所包含的文字反过来映射至文档; 这方面算法并没有太多花样可言, 为了增加效率, 索引数据尽可往内存里面搬, 此法可效王献之习书法之势, 只要把十八台机器内存全部

讲解Python中面向对象编程的相关知识

  这篇文章主要介绍了深入讲解Python中面向对象编程的相关知识,是Python入门学习中的基础知识,需要的朋友可以参考下 Python从第一天开始就是面向对象的语言.正因为如此,创建和使用类和对象是非常地容易.本章将帮助您在使用Python面向对象编程的技术方面所有提高. 如果没有任何以往面向对象(OO)的编程的经验,那么可能要了解一些基本的入门课程就可以了,或者至少某种形式的教程,让你有了解基本概念. 但是,这里会比较少地介绍面向对象编程(OOP): OOP术语概述 类: 用户定义的原型对

Python中for循环控制语句用法实例

  本文实例讲述了Python中for循环控制语句用法.分享给大家供大家参考.具体分析如下: 第一个:求 50 - 100 之间的质数 ? 1 2 3 4 5 6 7 import math for i in range(50, 100 + 1): for j in range(2, int(math.sqrt(i)) + 1): if i % j == 0: break else: print i 输出如下: ? 1 2 3 4 5 6 7 8 9 10 53 59 61 67 71 73 7

讲解Python中fileno()方法的使用

  这篇文章主要介绍了讲解Python中fileno()方法的使用,是Python入门中的基础知识,需要的朋友可以参考下 fileno()方法返回所使用的底层实现,要求从操作系统I/O操作的整数文件描述符. 语法 以下是fileno()方法的语法: ? 1 fileObject.fileno(); 参数 NA 返回值 此方法返回整数文件描述符. 例子 下面的例子显示fileno()方法的使用. ? 1 2 3 4 5 6 7 8 9 10 11 #!/usr/bin/python   # Ope

讲解Python中if语句的嵌套用法

  这篇文章主要介绍了讲解Python中if语句的嵌套用法,是Python入门当中的基础知识,需要的朋友可以参考下 可能有这样一种情况,当你想检查其他条件后一个条件解析为真.在这种情况下,可以使用嵌套的if结构. 在嵌套的 if 语句结构,可以在一个 if... elif... else 结构里面可有另外一个 if... elif... else 结构. 语法: 嵌套 if...elif...else 结构的语法可以是: ? 1 2 3 4 5 6 7 8 9 10 11 12 if expre

python中while循环语句用法

  这篇文章主要介绍了python中while循环语句用法,以一个简单实例形式分析了Python使用while循环语句使用方法,需要的朋友可以参考下 ? 1 2 3 4 number = 1 while number < 20: print(number) number += 1 运行结果如下: ? 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 希望本

PYTHON中FOR循环的if ELSE的例子

if 语句是用来检查一个条件:如果条件为真(true),我们运行一个语句块(你为if块),否则(else),我们执行另一个语句块(称为else块).else子语句是可选的. 例如(保存为 if.py): number = 23 guess = int(input('请输入一个整数:'))      #等待输入整数 if guess == number:     print('恭喜,你猜对了.')    # 新块从这里开始     print('(但你没有获得任何奖品!)')    # 新块在这里