让我从你最后提出的问题开始:
我想了解在词法分析期间是否必须以特殊方式处理增广运算符(即通过扩展它们),
那个很简单;答案是不”。标记只是一个标记,词法分析器只是将输入划分为标记。就词法分析器而言,+= 只是一个标记,这就是它为它返回的内容。
顺便说一句,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
可以看出,试图将增强赋值的评估顺序总结为“从左到右”只是一个近似值。如上面的虚拟机代码所示,这是实际发生的情况:
-
“计算”目标聚合及其索引(第 0 行和第 2 行),然后复制这两个值(第 4 行)。 (重复意味着目标及其下标都不会被计算两次。)
-
然后使用重复的值来查找元素的值(第 6 行)。因此,此时会评估 a[-1] 的值。
-
然后计算右侧表达式 (a.pop())(第 8 行到第 12 行)。
-
这两个值(在本例中均为 3)与 INPLACE_ADD 组合,因为这是一个 ADD 扩充赋值。对于整数,INPLACE_ADD 和 ADD 之间没有区别,因为整数是不可变的值。但是编译器不知道第一个操作数是整数。 a[-1] 可以是任何东西,包括另一个列表。因此,它会发出一个操作数,该操作数将触发使用__iadd__ 方法而不是__add__,以防有区别。
-
原始目标和下标,从第 1 步开始就一直在堆栈上耐心等待,然后用于执行下标存储(第 16 和 18 行。下标仍然是在第 2 行计算的下标,-1。但此时a[-1] 指的是a 的不同元素。
需要旋转才能使参数为正确的顺序。因为赋值的正常求值顺序是先求右侧,所以虚拟机假定新值将位于堆栈的底部,然后是对象及其下标。
-
最后,None 作为语句的值返回。
Python 参考手册中记录了assignment 和augmented assignment 语句的精确工作原理。另一个重要的信息来源是description of the __iadd__ special method。增强赋值操作的求值(和求值顺序)非常令人困惑,以至于有一个专门用于它的 Programming FAQ,如果您想了解确切的机制,值得仔细阅读。
尽管这些信息很有趣,但值得补充的是,编写依赖于扩展赋值中评估顺序细节的程序不利于生成可读代码。在几乎所有情况下,都应避免依赖于过程的非显而易见细节的扩充赋值,包括诸如作为本问题目标的语句。