ac酱のSecret Base Talk is cheap!

python中的赋值

2019-09-05
ac酱

在刷题过程中发现python在多元赋值上和C等其他语言有着很大的不同,查阅了一些资料,特此记录。

问题

在leetcode的题解中看到一行代码:

pre.right, node.left, node = node, None, node.left

按照之前的习惯,认为拆分后应该等价为:

pre.right = node
node.left = None
node = node.left

但是经过修改后程序不是报错,就是运行结果与预期不相符。经过尝试后发现,实际上拆分后应等价为:

temp = node.left
pre.right = node
node.left = None
node = temp 

似乎在不知不觉中,python为你新增了一个临时变量,暂存了运算结果,在赋值符号右边的运算式计算完毕后,再一次赋值给赋值符号左边的变量名。

那么python在赋值时到底做了些什么呢?

python中的赋值

我们都知道,C语言中,在给变量赋值时,需要先指定变量的数据类型,根据数据的类型分配一块内存区域进行数值存储。第一次声明一个变量的类型之后,他的所占的内存地址就不会改变了,之后多次进行赋值,改变的也只有该段内存中存储的值。

把一个变量a赋值给另一个变量b时,相当于把变量a的值拷贝一份传递给变量b,变量a与变量b的地址不会发生任何改变。

int a;
printf("%d\n",&a);
a = 1;
printf("%d\n",&a);
a = 2;
printf("%d\n",&a);

int b;
printf("%d\n",&b);
b = a;
printf("%d\n",b);
printf("a:%d,b:%d\n",&a,&b);
6487580
6487580
6487580
6487576
2
a:6487580,b:6487576

而python就完全不同了。python中,“变量”的严格叫法是名字(name),给变量赋值相当于给对象贴标签,变量本身是没有任何意义的。 例如:

a = 1

python内部首先将分配一块内存空间用于创建整数对象1,然后给整数对象1贴上名为a的标签。之后执行:

a = 2

会在另一块内存空间中创建整数对象2,然后把标签a从对象1上撕下来贴在2身上,之后我们无法通过a来访问1这个对象了。之后把名字a赋值给名字b:

b = a

相当于在刚才的对象2身上又贴了一个新的标签b,我们既能通过名字a也能通过名字b访问对象2,访问的对象是相同的。

再看看python中函数参数的传递:

def fun_a(a):
    a += 4

g = 0
fun_a(g)
g
0

全局变量g传递给函数fun_a时,相当于函数中的参数a也被作为标签贴在了对象0上,之后a被重新赋值(a+=4),相当于从对象0撕下标签a贴到对象4上,但是g依然是对象0上的标签。

如果传入函数的参数是一个列表对象:

def fun_b(names):
    names[0] = ['x', 'y']

n_list = ['a', 'b', 'c']
fun_b(n_list)
n_list
[['x', 'y'], 'b', 'c']

和之前类似,names和n_list都是[‘a’, ‘b’, ‘c’]上的标签,只是列表对象中的第0个元素被重新赋值了,但是两个标签依然贴在这个列表对象上,虽然列表对象的值更新了,但是对象依然是原来的对象。

python中的“多元”赋值

python中存在另一种将多个变量同时赋值的方法,我们称为多元赋值(multuple,将 “mul-tuple”连在一起自造的)。因为采用这种方式赋值时,等号两边的对象都是元组。

x, y, z = 1, 2, 'a string'
# 等同于 (x, y, z) = (1, 2, 'a string')
这样一来,问题也能得到解释了。
```python
pre.right, node.left, node = node, None, node.left
```
首先先对赋值符号右边的序列封装成元组,之后再将该元组进行拆分,对应地赋值给左边的变量。由于封装元组的操作在赋值之前,因此赋值时进行无论变量如何改变,都不会影响到已经封装好的元组。

看似十分有道理,但是当我们使用dis模块进行验证的时候,发现事情并不是这么简单。

import dis
dis.dis("pre.right, node.left, node = node, None, node.left")
  1           0 LOAD_NAME                0 (node)
              2 LOAD_CONST               0 (None)
              4 LOAD_NAME                0 (node)
              6 LOAD_ATTR                1 (left)
              8 ROT_THREE
             10 ROT_TWO
             12 LOAD_NAME                2 (pre)
             14 STORE_ATTR               3 (right)
             16 LOAD_NAME                0 (node)
             18 STORE_ATTR               1 (left)
             20 STORE_NAME               0 (node)
             22 LOAD_CONST               0 (None)
             24 RETURN_VALUE

可以看到,这里并没有把赋值号右侧的序列封装成元组,而是按从左到右的顺序分别把他们压入栈中,然后经过ROT_THREEROT_TWO操作后,使他们在栈中的位置完全颠倒,再根据赋值符号左侧的变量依次赋值。

dis.dis("x, y, z = 1, 2, 'a string'")
  1           0 LOAD_CONST               4 ((1, 2, 'a string'))
              2 UNPACK_SEQUENCE          3
              4 STORE_NAME               0 (x)
              6 STORE_NAME               1 (y)
              8 STORE_NAME               2 (z)
             10 LOAD_CONST               3 (None)
             12 RETURN_VALUE

而像这样赋值号右侧都为常量的,则会将其封装为元组,再进行序列拆分。

当赋值符号右侧含有非常量,且数量大于3时,则会进行BUILD_TUPLEUNPACK_SEQUENCE操作。这样是因为ROT_THREEROT_TWO无法对数量大于3的栈顶元素进行翻转。

由此可以猜测`BUILD_TUPLE`和`UNPACK_SEQUENCE`的复杂度要大于`ROT_THREE`和`ROT_TWO`,否则为什么在变量个数少于3的情况下还要特意使用`ROT_THREE`和`ROT_TWO`呢?

利用这种特性,我们能将常用的变量交换的操作,从原来的3行代码压缩到1行:

# 交换a与b的值
temp = a
a = b
b = temp

# 等价于
a, b = b, a

python中的连续赋值

foo = [0]
bar = foo
foo[0] = foo = [1]

print(foo)
print(bar)

这段代码如果按照C语言的思想,以foo[0]=(foo=[1])的循序执行的话,将会得到:

[[1]]
[0]

但是在python中,运行的结果是:

[1]
[[1]]

忘掉C语言的思想,我们用python的思想执行一遍:

1. 首先foo和bar都指向[0];
2. 之后foo[0]的值发生变化,变为[1],由于foo和bar指向的是同一个对象,因此foo和bar此时都为[[1]];
3. 最后foo又被指向[1]。

以这样的顺序,最终得到的上面的结果。由此可见,在连续赋值的过程中,赋值的顺序是:

1. foo[0] = [1]
2. bar = [1]

根据查阅到的资料,使用dis模块查看连续赋值的过程,可以得到连续赋值的执行顺序:

首先构建要赋值的对象
将对象在栈顶进行一份复制,然后将复制的值赋给第一个变量
将对象在栈顶进行一份复制,然后将复制的值赋给第二个变量
……
将对象赋值给最后一个变量

这和我们推导得到的结论是一致的。

ac酱

完成于2019-09-06 中午

参考资料:


Similar Posts

Comments