在 Python 中,链式赋值存在一个不易察觉(但众所周知)的陷阱。以下函数返回 True:
def example(): a = b = [] # <-- oops! a.append(1) # b gets modified as well return b == [1] # True我早就知道这种行为,但偶尔还是会被它蒙蔽。问题在于,在第 a = b = [] 行,Python 列表对象被创建一次并赋值给两个变量。当调用 a.append(1) 时,底层列表被修改,而 b 又指向了同一个底层列表。如果你的本意是让 a 和 b 指向不同的列表,那么这就是一个 bug。
最近一次我因为这个 bug 而自食其果时,我产生了好奇:我知道当我写 a = b = [] 时语义层面发生了什么,但字节码层面发生了什么呢?一番简单的谷歌搜索后,我找到了dis 模块[1],它可以让你检查反汇编的CPython[2]字节码。
让我们在新文件 chained-assignment-example.py 中使用 dis 模块编写一个简单的程序……
import disdef example(): a = b = []dis.dis(example)然后运行python chained-assignment-example.py。在我的机器上,运行的是Python 3.12.6,输出结果是:
3 0 RESUME 0 4 2 BUILD_LIST 0 4 COPY 1 6 STORE_FAST 0 (a) 8 STORE_FAST 1 (b) 10 RETURN_CONST 0 (None)至此,我们已经基本了解了链式赋值在字节码层面的实现方式。以下是一些我们需要了解的重要概念:
example 函数的反汇编字节码,所以我们的第一行号是 3,这与我们在源文件中写入的行号一致。4 行(我们在这里进行链式赋值)展开后会生成四条字节码指令。COPY 指令。所有字节偏移量都是偶数并非巧合。自 Python 3.6 起,每个 Python 指令都被赋予奇数个参数(即使它不需要任何参数),以确保字节偏移量始终为偶数。__code__ 属性,用于存储 Python 虚拟机执行该函数所需的所有信息。该代码对象包含一个 varnames 元组,其中包含函数中所有局部变量的名称。要访问它,可以使用表达式 example.__code__.co_varnames。在我们的示例中,该表达式的计算结果为 (a, b)。让我们逐一查看第4行 (a = b = []) 的说明。
BUILD_LIST 0BUILD_LIST N 从栈中弹出 N 个元素,将它们转换为列表,然后将指向该列表的C 指针压入求值栈(参见代码[3])。更准确地说,它创建一个 PyListObject,将其强制转换为 PyObject,然后返回它(参见代码[4])。在本例中,我们有 BUILD_LIST 0,它创建一个空列表并将其压入栈中。
COPY 0COPY N 将栈中倒数第 N 个元素复制并压入栈中。在本例中,我们有 COPY 1,这意味着我们将倒数第 1 个元素(即栈顶元素,也就是对列表的引用)复制并压入栈顶。因此,现在栈中有两个指向同一个列表的引用。
STORE_FAST 0STORE_FAST N 从栈中弹出元素,并将弹出的值存储到第 N 个变量名中。在本例中,我们有 STORE_FAST 0。那么第 0 个变量名是什么呢?回想一下,example.__code__.co_varnames 是 (a, b)。所以第 0 个变量名就是 a,而 dis.dis() 函数已经很贴心地帮我们确定了它!
所以现在 a 指的是新创建的列表。
STORE_FAST 1下一条指令是 STORE 1。同样,我们弹出堆栈,但这次我们将弹出的值(指向上面列表的指针)存储在 1 个变量名中,即变量 b。
所以现在 b 指的是和以前一样的同一个列表对象。
我们分析的关键在于,在 example() 函数的编译字节码中,只有一条BUILD_LIST 指令。这意味着我们只在堆上分配了一个 PyListObject 内存,而 Python 虚拟机却忠实地将其分配给了两个不同的变量名。这会导致一些微妙而恼人的行为,可能会浪费某人十分钟的时间。当然,我对此一无所知。
如果我们不使用链式赋值会怎样?和上面一样,让我们编写一些代码并检查其反汇编的字节码。在新文件 regular-assignment.py 中:
import disdef example(): a = [] b = []dis.dis(example)运行python regular-assignment.py,我们得到:
3 0 RESUME 0 4 2 BUILD_LIST 0 4 STORE_FAST 0 (a) 5 6 BUILD_LIST 0 8 STORE_FAST 1 (b) 10 RETURN_CONST 0 (None)输出字节码中有两条不同的BUILD_LIST指令,每条指令后面都跟着一条STORE_FAST指令!将分配两个不同的列表对象到堆上,每个列表对象都由两个不同的变量名引用。
dis 模块来检查函数对象的内部结构(常量、变量名、名称和代码),然后带领听众了解了一个简单的斐波那契数列函数的反汇编字节码。在我看来,这是一场必看的演讲。https://loriculus.org/blog/python-chained-assignment/
[1] dis 模块: https://docs.python.org/3/library/dis.html[2] CPython: https://en.wikipedia.org/wiki/CPython[3] 参见代码: https://github.com/python/cpython/blob/fdbc135f9cf57599cca8aeeed947d0b736fdb197/Python/executor_cases.c.h#L8146[4] 参见代码: https://github.com/python/cpython/blob/fdbc135f9cf57599cca8aeeed947d0b736fdb197/Objects/listobject.c#L3269-L3287[5] James Bennett 在 2018 年 PyCon 大会上的演讲: https://www.youtube.com/watch?v=cSSpnq362Bk[6] 这一部分: https://docs.python.org/3/library/dis.html#python-bytecode-instructions