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

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

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]:  passprint(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 []:  passprint(i)

登录后复制

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

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

立即学习“Python免费学习笔记(深入)”;

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

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

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

for i in range(4):  d = i * 2print(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):   breakdostuffwith(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_ITER13 FOR_ITER        16 (to 32)16 STORE_FAST        2 (i) 19 LOAD_FAST        1 (a)22 LOAD_FAST        2 (i)25 INPLACE_ADD26 STORE_FAST        1 (a)29 JUMP_ABSOLUTE      1332 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_tyast_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生成器。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至253000106@qq.com举报,一经查实,本站将立刻删除。

发布者:PHP中文网,转转请注明出处:https://www.chuangxiangniao.com/p/2294847.html

(0)
上一篇 2025年2月28日 02:48:07
下一篇 2025年2月25日 15:12:50

AD推荐 黄金广告位招租... 更多推荐

相关推荐

  • python中xrange用法分析

    本文实例讲述了python中xrange用法。分享给大家供大家参考。具体如下: 先来看如下示例: >>> x=xrange(0,8)>>> print xxrange(8)>>> pri…

    编程技术 2025年2月28日
    200
  • 使用IPython下的Net-SNMP来管理类UNIX系统的教程

    引言 对于简单网络管理协议 (SNMP),大多数系统管理员都具有一定的使用经验,或者至少听说过它。如果您正在一个数据中心工作,那么您每天都可能采用某种方式与 SNMP 进行交互。有许多给人印象深刻的、同等规模的网络管理系统 (NMS) 或者…

    编程技术 2025年2月28日
    200
  • 对于Python编程中一些重用与缩减的建议

    返璞归真 许多流行的玩具都以这样一个概念为基础:简单的积木。这些简单的积木可通过多种方式组合在一起构造出全新的作品 —— 有时甚至完全令人出乎意料。这一概念同样适用于现实生活中的建筑领域,将基本原材料组合在一起,形成有用的建筑物。平凡无奇的…

    编程技术 2025年2月28日
    200
  • Python中为什么要用self探讨

    接触python以来,看到类里的函数要带个self参数,一直搞不懂啥麻子原因。晚上特别针对python的self查了一下,理理。 Python要self的理由 Python的类的方法和普通的函数有一个很明显的区别,在类的方法必须有个额外的第…

    编程技术 2025年2月28日
    200
  • Python中的特殊语法:filter、map、reduce、lambda介绍

    filter(function, sequence):对sequence中的item依次执行function(item),将执行结果为True的item组成一个List/String/Tuple(取决于sequence的类型)返回: 复制代…

    编程技术 2025年2月28日
    200
  • Python全局变量操作详解

    接触python时间不长,对有些知识点,掌握的不是很扎实,我个人比较崇尚不管学习什么东西,首先一定回去把基础打的非常扎实了,再往高处走。今天遇到了python中的全局变量的相关操作,遇到了问题,所以,在这里将自己遇到的问题,做个记录,以长记…

    编程技术 2025年2月28日
    200
  • Python中join和split用法实例

    join用来连接字符串,split恰好相反,拆分字符串的。不用多解释,看完代码,其意自现了。 复制代码 代码如下:>>>li = [‘my’,’name’,’is…

    编程技术 2025年2月28日
    200
  • 使用Python构建Hopfield网络的教程

     热的东西显然会变凉。房间会会人沮丧地变得凌乱。几乎同样,消息会失真。逆转这些情况的短期策略分别是重新加热、 做卫生和使用 Hopfield 网络。本文向您介绍了三者中的最后一个,它是一个只需要特定的参数就可以消除噪声的算法。net.py …

    2025年2月28日 编程技术
    200
  • 使用C语言扩展Python程序的简单入门指引

    一、简介 Python是一门功能强大的高级脚本语言,它的强大不仅表现在其自身的功能上,而且还表现在其良好的可扩展性上,正因如此,Python已经开始受到越来越多人的青睐,并且被屡屡成功地应用于各类大型软件系统的开发过程中。 与其它普通脚本语…

    编程技术 2025年2月28日
    200
  • Python线程的两种编程方式

    Python中如果要使用线程的话,python的lib中提供了两种方式。一种是函数式,一种是用类来包装的线程对象。举两个简单的例子希望起到抛砖引玉的作用,关于多线程编程的其他知识例如互斥、信号量、临界区等请参考python的文档及相关资料。…

    编程技术 2025年2月28日
    200

发表回复

登录后才能评论