当前位置:首页>python>Python 字节码中的链式赋值陷阱

Python 字节码中的链式赋值陷阱

  • 2026-09-09 11:44:41
Python 字节码中的链式赋值陷阱

链式赋值有一把脚枪(footgun)

在 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)

理解字节码

至此,我们已经基本了解了链式赋值在字节码层面的实现方式。以下是一些我们需要了解的重要概念:

  • • CPython 是一个基于栈的虚拟机 (VM)。实际上,CPython 的实现包含两个栈,其中一个是执行栈,用于存储数据。在本文的后续部分,我所说的“栈”指的就是执行栈。另一个是块栈(block stack),与本文的讨论无关。
  • • 最左侧一列的数字指的是源代码的行号。我们要求的是 example 函数的反汇编字节码,所以我们的第一行号是 3,这与我们在源文件中写入的行号一致。
  • • 源代码行号旁边的方框是与每一行对应的字节码指令。例如,第 4 行(我们在这里进行链式赋值)展开后会生成四条字节码指令。
  • • 第二列中的数字是生成的字节码中的字节偏移量。例如,我们生成的字节码中的第五个字节(索引 = 4)是 COPY 指令。所有字节偏移量都是偶数并非巧合。自 Python 3.6 起,每个 Python 指令都被赋予奇数个参数(即使它不需要任何参数),以确保字节偏移量始终为偶数。
  • • 第三列中的数字是指令参数,因此它们的含义取决于指令本身。
  • • 每个函数对象都有一个 __code__ 属性,用于存储 Python 虚拟机执行该函数所需的所有信息。该代码对象包含一个 varnames 元组,其中包含函数中所有局部变量的名称。要访问它,可以使用表达式 example.__code__.co_varnames。在我们的示例中,该表达式的计算结果为 (a, b)。

让我们逐一查看第4行 (a = b = []) 的说明。

  • • BUILD_LIST 0

BUILD_LIST N 从栈中弹出 N 个元素,将它们转换为列表,然后将指向该列表的C 指针压入求值栈(参见代码[3])。更准确地说,它创建一个 PyListObject,将其强制转换为 PyObject,然后返回它(参见代码[4])。在本例中,我们有 BUILD_LIST 0,它创建一个空列表并将其压入栈中。

  • • COPY 0

COPY N 将栈中倒数第 N 个元素复制并压入栈中。在本例中,我们有 COPY 1,这意味着我们将倒数第 1 个元素(即栈顶元素,也就是对列表的引用)复制并压入栈顶。因此,现在栈中有两个指向同一个列表的引用。

  • • STORE_FAST 0

STORE_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指令!将分配两个不同的列表对象到堆上,每个列表对象都由两个不同的变量名引用。

参考文献

  • • 在研究 Python 虚拟机时,我发现James Bennett 在 2018 年 PyCon 大会上的演讲[5]非常有启发性。他简要讨论了 CPython 中虚拟机的概念,讲解了如何使用 dis 模块来检查函数对象的内部结构(常量、变量名、名称和代码),然后带领听众了解了一个简单的斐波那契数列函数的反汇编字节码。在我看来,这是一场必看的演讲。
  • • dis 模块文档的这一部分[6]对每个字节码指令进行了解释,并提供了不错的伪代码来说明这些指令是如何实际工作的。

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

最新文章

随机文章