python连等赋值语句的执行逻辑

Python 中连等赋值语句的执行逻辑

问题

考虑如下python代码,思考程序的输出:

1
2
3
4
5
x=[0,0,0]
i=0
i=x[i]=1
print(x)
# [0,1,0]

按照一般的逻辑,赋值语句从右向左计算,应先计算x[0]=1,其结果1再赋值给i,因此x将是[1,0,0],但是与实际的程序输出不符

假设

在开始研究之前,可以先根据上述现象对python的连等赋值语句执行逻辑进行合理的假设,以便于后续设计验证。

序号假设
1右向左结合,赋值表达式返回值(c语言逻辑);但已被实验证伪
2python自动假设赋值语句的依赖关系,按依赖顺序赋值
3一条赋值语句拆开多句,按左到右顺序执行

验证

假设1

首先验证赋值语句是否有返回值,考虑如下测试程序:

1
2
3
4
5
>>> 2+(a=1)
  File "<stdin>", line 1
    2+(a=1)
        ^
SyntaxError: invalid syntax

可见python的赋值语句并不是表达式,并没有返回值。同时问题描述中的程序运行结果也不支持类似c等语言的连等逻辑。

因此python具有与其他语言不同的特性。

假设2

假设二是假设如果在一条连等语句中,不同的需要赋值的变量的表达式之间如果有相互引用,python会自动解析依赖关系,并确保被依赖的变量在最先赋值。

这个假设可以符合问题描述中的输出结果。但是为了进一步验证,考虑如下程序:

1
2
3
4
5
6
>>> x=[1,2,3,4,5]
>>> i=0
>>> j=1
>>> i=x[i+j]=j=2
>>> print(x)
[1, 2, 3, 2, 5]

如果按照依赖推断的逻辑,第四行的赋值语句应当首先赋值i和j均为2,而后执行x[4]=2。注意列表的下标从0开始,所以期望的输出应当是[1,2,3,4,2],与实际的输出不符合

假设3

假设三是假设连等赋值语句会被拆解成多个直接单一赋值的语句,例如问题描述中的i=x[i]=1,会被拆解成:

1
2
i=1
x[i]=1

这个假设也与问题描述相符。进一步考虑上面验证假设二时的程序,i=x[i+j]=j=2将会被拆解成:

1
2
3
4
         #i=0,j=1,x=[1,2,3,4,5]
i=2      #i=2,j=1,x=[1,2,3,4,5]
x[i+j]=2 #i=2,j=1,x=[1,2,3,2,5]  x[2+1]=2
j=2      #i=2,j=2,x=[1,2,3,2,5]

与程序的实际输出相符。

但是上述的验证方法都是类似“黑箱”的研究方法,只从python解释器的输入和输出推测其行为,作为问题的答案是不够严谨的。需要进一步研究。

解释

首先需要明确的是,python中的变量只是对实际值或者数据对象的引用,与c中的直接保存数据的方式不一样。

其次,python是解释性语言,在运行的过程中实时解释代码并执行。但是python在执行的过程中并不是直接执行源程序,而是将程序编译为具有单一操作的字节码,再由python的“虚拟机”执行。执行python脚本后出现的.pyc文件,就是对字节码的缓存。

既然python字节码一次只执行单一操作,那么对于我们研究上述问题显然是有帮助的。在python中,dis标准库提供了将字节码反汇编的功能,同时它也支持直接由python代码生成汇编指令。

例如,我们首先研究一个单独的普通赋值语句:

1
2
3
4
5
6
>>> import dis
>>> dis.dis('x=1')
  1           0 LOAD_CONST               0 (1)
              2 STORE_NAME               0 (x)
              4 LOAD_CONST               1 (None)
              6 RETURN_VALUE

其中第一列表示字节码在源代码中的行号,第二列是字节码指令相对起始位置的偏移(字节)。第三列是指令名称,第四列是参数(猜测是在栈或者引用名称列表中的偏移量),第五列是参数以人类友好形式的表示。

为了理解上述反汇编后的代码,参考python的dis库中文官方文档

指令名称作用
LOAD_CONST加载常数将指定的常数引用推入栈顶压入
STORE_NAME引用分配将栈顶的引用分配给变量
RETURN_VALUE返回值将栈顶的引用返回
DUP_TOP复制引用将栈顶的引用引用复制一份压入栈顶

因此可以看出,在执行x=1这一语句的过程中,python解释器实际上完成的是如下的操作:

  1. 加载常数1的引用,压入系统栈顶
  2. 将栈顶的引用(即刚刚的常数1)分配引用给’x’这个变量名称
  3. 加载常数引用“None”,压入栈顶
  4. 将栈顶的引用(None)返回

从上面的操作也可以看出赋值语句确实没有返回值(None)。

下面来看连等的情况:

1
2
3
4
5
6
7
8
9
>>> dis.dis('x=y=z=5')
  1           0 LOAD_CONST               0 (5)
              2 DUP_TOP
              4 STORE_NAME               0 (x)
              6 DUP_TOP
              8 STORE_NAME               1 (y)
             10 STORE_NAME               2 (z)
             12 LOAD_CONST               1 (None)
             14 RETURN_VALUE

可以看出python在处理连等语句时,先载入要赋值的常数,而后不断复制引用-分配变量,并且是按表达式中从左到右的顺序。

现在可以来看问题描述中的连等语句,及其交换顺序后的代码的实际执行操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
>>> dis.dis('i=x[i]=1')
  1           0 LOAD_CONST               0 (1)
              2 DUP_TOP
              4 STORE_NAME               0 (i)
              6 LOAD_NAME                1 (x)
              8 LOAD_NAME                0 (i)
             10 STORE_SUBSCR
             12 LOAD_CONST               1 (None)
             14 RETURN_VALUE
>>> dis.dis('x[i]=i=1')
  1           0 LOAD_CONST               0 (1)
              2 DUP_TOP
              4 LOAD_NAME                0 (x)
              6 LOAD_NAME                1 (i)
              8 STORE_SUBSCR
             10 STORE_NAME               1 (i)
             12 LOAD_CONST               1 (None)
             14 RETURN_VALUE

同样给出新出现的指令的解释:

指令名称作用
LOAD_NAME加载引用将某一变量名称对应的数据压入栈顶
STORE_SUBSCR列表赋值要求栈顶第二个元素为列表,将列表中以栈顶元素为索引位置的数据赋值为栈顶第三个元素

因此上述指令中的第610、第48位置实际上就是执行了x[i]=1的过程。但是由于连等语句中的顺序不同,在第一条中i先被分配了1这一数据的引用,而后才被作为索引,而在LOAD_NAME加载索引时,载入的已经是i新分配的值即1了(而非原来的0)。

在第二条交换了顺序后的赋值语句x[i]=i=1中,i首先用作索引,而后才被分配新的引用,因此x[i]=x[0]=1i=1之前执行。

小结

本文讨论python连等赋值语句的执行逻辑,首先按照黑箱方法提出可能的解释并进行简单检验,而后利用python中dis标准库的反汇编字节码实际验证连等语句的执行顺序,最后可以得出如下结论:

  1. python 的赋值语句不是表达式,没有返回值
  2. python中的连等赋值语句,可以拆分为多个单变量赋相同值的一组赋值语句,赋值顺序按变量在原语句中从左到右排列。

当然,利用反汇编字节码虽然能够较为完整地解释上述现象,但是有关python为何如此设计、python解释器在解释连等赋值语句时具体转换过程,就要涉及语法树、编译原理等更高层面的知识范畴了。

0%