2

抽丝剥茧,深入剖析 Python 如何实现变量交换!

 2 years ago
source link: https://developer.51cto.com/art/202201/699453.htm
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.


Python 程序员肯定知道 a,b = b,a,这句话用来交换两个变量。相较于其它语言需要引入一个 temp 来临时存储变量的做法,Python 的这种写法无疑非常优雅。

简洁优雅的 C 写法:

  1. int a = 1; 
  2. int b = 2; 
  3. int temp; 
  4. temp = a; 
  5. b = temp; 

简洁优雅的 Python 写法:

  1. a,b = 1,2 
  2. a,bb = b,a 

虽然语法非常方便,但我们始终不曾想过:它是怎么运作的?背后支撑它的机制是什么?下面让我们一步步分析它。

通俗的说法

最常见的解释是:

a,b = b,a 中右侧是元组表达式,即 b,a 是一个两个元素的 tuple(a,b)。表达式左侧是两个待分配元素,而 = 相当于元组元素拆包赋值操作。

这种方法,理解起来最简单,但实际是这种情况么?

让我们从字节码上看下,是不是这种情况。

从字节码一窥交换变量

大家可能不太了解 Python 字节码。Python 解释器是一个基于栈的虚拟机。Python 解释器就是编译、解释 Python 代码的二进制程序。

虚拟机是一种执行代码的容器,相较于二进制代码具有方便移植的特点。而 Python 的虚拟机就是栈机器。

Python 中函数调用、变量赋值等操作,最后都转换为对栈的操作。这些对栈的具体操作,就保存在字节码里。

dis 模块可以反编译字节码,使其变成人类可读的栈机器指令。如下,我们看反编译 a,b=b,a 的代码。

  1. >>> import dis 
  2. >>> dis.dis("a,bb=b,a") 
  3.   1           0 LOAD_NAME                0 (b) 
  4.               2 LOAD_NAME                1 (a) 
  5.               4 ROT_TWO 
  6.               6 STORE_NAME               1 (a) 
  7.               8 STORE_NAME               0 (b) 
  8.              10 LOAD_CONST               0 (None) 
  9.              12 RETURN_VALUE 

可见,在 Python 虚拟机的栈上,我们按照表达式右侧的 b,a 的顺序,先后压入计算栈中,然后用一个重要指令 ROT_TWO,这个操作交换了 a 和 b 的位置,最后 STORE_NAME 操作将栈顶的两个元素先后弹出,传递给 a 和 b 元素。

栈的特性是先进后出(FILO)。当我们按b,a顺序压入栈的时候,弹出时先出的就是a,再弹出就是b。STORE_NAME指令会把栈顶元素弹出,并关联到相应变量上。

如果没有第 4 列的指令 ROT_TWO,此次 STORE_NAME 弹出的第一个变量会是后压栈的 a,这样就是 a=a 的效果。有了 ROT_TWO 则完成了变量的交换。

好了,我们知道靠压栈、弹栈和交换栈顶的两个元素,实现了 a,b = b,a 的操作。

同时,我们也知道了,上诉元组拆包赋值的说法,是不恰当的。

那 ROT_TWO 是怎么具体操作的呢?

后台怎么执行?

见名知意,可以猜出来 ROT_TWO 是交换两个栈顶变量的操作。在 Python 源代码的层面上,来看是如何交换两个栈顶的元素。

下载 Python 源代码,进入 Python/ceval.c 文件,在 1101 行,我们看到了 ROT_TWO 的操作。

  1. TARGET(ROT_TWO){ 
  2.  PyObject *top = TOP(); 
  3.  PyObject *second = SECOND(); 
  4.  SET_TOP(second); 
  5.  SET_SECOND(top); 
  6.  FAST_DISPATCH();  

代码比较简单,我们用 TOP 和 SECOND 宏获取了栈上的 a,b 元素,然后再用 SET_TOP、SET_SECOND 宏把值写入栈中。以此完成交换栈顶元素的操作。

求值顺序的奇怪现象!

下面,我们来看一个奇怪的现象,在这篇文章里,也可以看到这个现象。如下,我们试图排序这个列表:

  1. >>> a = [0, 1, 3, 2, 4] 
  2. >>> a[a[2]], a[2] = a[2], a[a[2]] 
  3. >>> a 
  4. >>> [0, 1, 2, 3, 4] 
  5. >>> a = [0, 1, 3, 2, 4] 
  6. >>> a[2], a[a[2]] = a[a[2]],a[2] 
  7. >>> a 
  8. >>> [0, 1, 3, 3, 4] 

按照理解 a,b = b,a 和 b,a=a,b 是一样的结果,但从上例中我们看到,这两者的结果是不同的。

导致这一现象的原因在于:求值的顺序。毫无疑问,整个表达式先求右侧的两个元素,然后作为常数保存起来。最后赋值给左侧的两个变量。

最后赋值时,需要注意,我们从左到右依次赋值,如果 a[2] 先修改的话,势必会影响到其后的 a[a[2]] 的列表下标。

“你可以使用反汇编代码,来分析产生这个现象的具体步骤。”

奇怪的变回拆包现象!!

当我们使用常数作为右侧元组,来给左侧变量赋值时;或使用超过三个元素,来完成便捷交换时,其在字节码层次上便不是 ROT_TWO 这种操作了。

  1. >>> dis.dis("a,b,c,d=b,c,d,a") 
  2.   1           0 LOAD_NAME 
  3.               3 LOAD_NAME 
  4.               6 LOAD_NAME 
  5.               9 LOAD_NAME 
  6.              12 BUILD_TUPLE 
  7.              15 UNPACK_SEQUENCE 
  8.              18 STORE_NAME 
  9.              21 STORE_NAME 
  10.              24 STORE_NAME 
  11.              27 STORE_NAME 
  12.              30 LOAD_CONST 
  13.              33 RETURN_VALUE 
  14. >>> 

很明显,这里是在偏移 12 字节处 BUILD_TUPLE 组装元组,然后解包赋值给左侧变量。上文所述的通俗说法,在这里又成立了!

也就是说,当小于四个元素交换时,Python 采用优化的栈操作来完成交换。

当使用常量或者超过四个元素时,采用元组拆包赋值的方式来交换。

至于为什么是四个元素,应该是因为 Python 最多支持到 ROT_THREE 操作,四个元素的话,系统不知道该怎么优化了。但在新版本的 Python 中,我看到了 ROT_FOUR 操作,所以这时候,四个元素还是 ROT_* 操作来优化的。

  1. >>>import opcode 
  2. >>>opcode.opmap["ROT_THREE"] 

此例中,该版本 Python 支持 ROT_THREE 操作,你也可以使用 ROT_FOUR 查看自己 Python 是否支持,进而确定是否可以四个以上元素便捷交换。


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK