【问题标题】:Evaluation order of augmented operators (delimiters) in pythonpython中增广运算符(定界符)的求值顺序
【发布时间】:2022-06-16 22:47:50
【问题描述】:

如果我在 python 中评估以下最小示例

a = [1, 2, 3]
a[-1] += a.pop()

我明白了

[1, 6]

所以这似乎被评估为

a[-1] = a[-1] + a.pop()

每个表达式/操作数将按顺序计算

third = first + second

所以左边的 a[-1] 是第二个元素,而右边是第三个。

a[1] = a[2] + a.pop()

有人可以向我解释如何从docs 中推断出这一点吗?显然 '+=' 在词法上是一个也执行操作的分隔符(请参阅here)。这对其评估顺序意味着什么?

编辑:

我试图在评论中澄清我的问题。我将其包含在此处以供参考。

我想了解增广运算符是否必须在 词法分析期间的特殊方式(即通过扩展它们),因为 您必须复制一个表达式并对其进行两次评估。 这在文档中并不清楚,我想知道这种行为在哪里 被指定。其他词法分隔符(例如 '}')表现不同。

【问题讨论】:

  • 是的,它被评估为a[-1] = a[-1] + a.pop(),并且右手尺寸总是在分配之前完全评估,所以你得到a[-1] = 6,它将6分配给列表的最后一项(第二个,因为a 此时仅包含 2 个项目)。你到底有什么不清楚的地方?
  • 我想了解在词法分析期间是否必须以特殊方式(即通过扩展它们)处理增广运算符,因为您必须复制一个表达式并对其进行两次评估。这在文档中并不清楚,我想知道在哪里指定了这种行为。其他词法分隔符(例如 '}')表现不同。
  • 是你自己写的还是你在什么地方看到的?看起来我可能已经在某处发布了一些东西:-)
  • 我想出了这个尝试使用反向波兰符号来实现一个小型计算器。要评估“2 3 +”,您将 2 和 3 放在堆栈上,然后执行 + 弹出 3 并将其添加到 2(就地),将 5 留在堆栈顶部。我试图在一行中完成它只是为了好玩:-)

标签: python operator-precedence lexical-analysis


【解决方案1】:

让我从你最后提出的问题开始:

我想了解在词法分析期间是否必须以特殊方式处理增广运算符(即通过扩展它们),

那个很简单;答案是不”。标记只是一个标记,词法分析器只是将输入划分为标记。就词法分析器而言,+= 只是一个标记,这就是它为它返回的内容。

顺便说一句,Python 文档对“运算符”和“标点符号”进行了区分,但这对于当前的词法分析器来说并不是真正的显着差异。在基于运算符优先级解析的解析器的某些先前化身中,这可能是有意义的,其中“运算符”是具有相关优先级和关联性的词位。但我不知道 Python 是否曾经使用过那个特定的解析算法;在当前的解析器中,“运算符”和“标点符号”都是在语法规则中出现的字面词位。如您所料,词法分析器更关心标记的长度(<=+= 都是两个字符的标记),而不是解析器中的最终使用。

“Desugaring”——将某些语言结构转换为更简单结构的源代码转换的技术术语——通常不在词法分析器或解析器中执行,尽管编译器的内部工作不受代码约束行为。一种语言是否有脱糖组件通常被认为是一个实现细节,并且可能不是特别明显; Python 确实如此。 Python 也不向其标记器公开接口。 tokenizer 模块是对纯 Python 的重新实现,它不会产生完全相同的行为(尽管它足够接近成为有用的探索工具)。但是解析器在ast 模块中公开,它提供了对 Python 自己的解析器的直接访问(至少在 CPython 实现中),让我们看到在构造 AST 之前没有进行脱糖(注意:indent 选项需要 Python3.9):

>>> import ast
>>> def showast(code):
...    print(ast.dump(ast.parse(code), indent=2))
...
>>> showast('a[-1] += a.pop()')
Module(
  body=[
    AugAssign(
      target=Subscript(
        value=Name(id='a', ctx=Load()),
        slice=UnaryOp(
          op=USub(),
          operand=Constant(value=1)),
        ctx=Store()),
      op=Add(),
      value=Call(
        func=Attribute(
          value=Name(id='a', ctx=Load()),
          attr='pop',
          ctx=Load()),
        args=[],
        keywords=[]))],
  type_ignores=[])

这会产生您期望的语法树,其中“增强赋值”语句表示为 assignment 中的特定产生式:

assignment:
    | single_target augassign ~ (yield_expr | star_expressions)

single_target 是一个可赋值的表达式(例如一个变量,或者,在这种情况下,一个下标数组); augassign 是增强的赋值运算符之一,其余的是赋值右侧的替代项。 (你可以忽略“栅栏”语法运算符~。)ast.dump 生成的解析树非常接近语法,并且完全没有脱糖:

                      
         --------------------------
         |         |              |
     Subscript    Add            Call
     ---------           -----------------
      |     |            |        |      |
      a    -1        Attribute   [ ]    [ ]
                      ---------
                       |     |
                       a   'pop'

奇迹发生在之后,我们也可以看到,因为 Python 标准库还包含一个反汇编器:

>>> import dis
>>> dis.dis(compile('a[-1] += a.pop()', '--', 'exec'))
  1           0 LOAD_NAME                0 (a)
              2 LOAD_CONST               0 (-1)
              4 DUP_TOP_TWO
              6 BINARY_SUBSCR
              8 LOAD_NAME                0 (a)
             10 LOAD_METHOD              1 (pop)
             12 CALL_METHOD              0
             14 INPLACE_ADD
             16 ROT_THREE
             18 STORE_SUBSCR
             20 LOAD_CONST               1 (None)
             22 RETURN_VALUE

可以看出,试图将增强赋值的评估顺序总结为“从左到右”只是一个近似值。如上面的虚拟机代码所示,这是实际发生的情况:

  1. “计算”目标聚合及其索引(第 0 行和第 2 行),然后复制这两个值(第 4 行)。 (重复意味着目标及其下标都不会被计算两次。)

  2. 然后使用重复的值来查找元素的值(第 6 行)。因此,此时会评估 a[-1] 的值。

  3. 然后计算右侧表达式 (a.pop())(第 8 行到第 12 行)。

  4. 这两个值(在本例中均为 3)与 INPLACE_ADD 组合,因为这是一个 ADD 扩充赋值。对于整数,INPLACE_ADDADD 之间没有区别,因为整数是不可变的值。但是编译器不知道第一个操作数是整数。 a[-1] 可以是任何东西,包括另一个列表。因此,它会发出一个操作数,该操作数将触发使用__iadd__ 方法而不是__add__,以防有区别。

  5. 原始目标和下标,从第 1 步开始就一直在堆栈上耐心等待,然后用于执行下标存储(第 16 和 18 行。下标仍然是在第 2 行计算的下标,-1。但此时a[-1] 指的是a 的不同元素。 需要旋转才能使参数为正确的顺序。因为赋值的正常求值顺序是先求右侧,所以虚拟机假定新值将位于堆栈的底部,然后是对象及其下标。

  6. 最后,None 作为语句的值返回。

Python 参考手册中记录了assignmentaugmented assignment 语句的精确工作原理。另一个重要的信息来源是description of the __iadd__ special method。增强赋值操作的求值(和求值顺序)非常令人困惑,以至于有一个专门用于它的 Programming FAQ,如果您想了解确切的机制,值得仔细阅读。

尽管这些信息很有趣,但值得补充的是,编写依赖于扩展赋值中评估顺序细节的程序不利于生成可读代码。在几乎所有情况下,都应避免依赖于过程的非显而易见细节的扩充赋值,包括诸如作为本问题目标的语句。

【讨论】:

  • 非常感谢您对我公认的含糊问题的详尽回答。我应该编辑问题吗?我的评论可能会被删除。
  • @tobias_t:是的,最好编辑问题。
【解决方案2】:

rici did a great job showing what's happening under the hood in the CPython reference interpreter,但语言规范中有一个更简单的“事实来源”,它保证 任何 Python 解释器(不仅仅是 CPython,但 PyPy、Jython、IronPython、Cython 等)。在语言规范中,在Chapter 6: Expressions, section 6.16, Evaluation Order 下,它指定:

Python 从左到右计算表达式。请注意,在评估分配时,右侧会先于左侧进行评估。

第二句话听起来是一般规则的例外,但事实并非如此;使用= 赋值(包括使用+= 或类似的扩展赋值)不是Python 中的表达式(the walrus operator introduced in 3.8 一个表达式,但它只能分配给裸名,所以有永远不要在左侧“评估”任何东西,它纯粹是存储在那里,从不读取),它是一个语句,以及赋值语句has its own rules for order of evaluation。这些分配规则指定:

赋值语句计算表达式列表(请记住,这可以是单个表达式或逗号分隔的列表,后者产生一个元组)并将单个结果对象从左到右分配给每个目标列表。

这证实了表达式评估令文档中的第二句话;首先评估表达式列表(要分配的东西),然后从那里开始对目标的分配。所以根据语言规范本身,a[-1] += a.pop()必须首先完全评估a.pop()(“表达式列表”),然后执行赋值。

这种行为是语言规范所要求的,并且已经有一段时间了,因此无论您使用什么 Python 解释器都可以依赖它。

也就是说,我建议不要使用依赖 Python 的这些保证的代码。一方面,当您切换到其他语言时,规则会有所不同(在某些情况下,例如 C 和 C++ 中的许多类似情况,因标准版本而异,没有“规则”,并且试图在表达式的多个部分会产生未定义的行为),因此越来越依赖 Python 的行为会妨碍您使用其他语言的能力。除此之外,它仍然会令人困惑,只需稍作更改即可避免混乱,例如,在您的情况下,更改:

a[-1] += a.pop()

只是:

x = a.pop()
a[-1] += x

虽然公认是双线,因此劣势!!!,但实现了相同的结果,但开销毫无意义,而且更清晰。

TL;DR:Python 语言规范保证+= 的右侧在扩展赋值操作开始之前被完全评估,并且左侧的任何代码评估。但为了代码清晰,任何依赖于该保证的代码都应该重构以避免上述依赖。

【讨论】:

    猜你喜欢
    • 2020-08-15
    • 1970-01-01
    • 2021-07-12
    • 2010-11-29
    • 2014-12-11
    • 1970-01-01
    • 2011-11-03
    • 2021-08-23
    • 2016-03-09
    相关资源
    最近更新 更多