楔子
前面我们考察了虚拟机执行字节码指令的原理,那么本篇文章就来看看这些指令对应的逻辑是怎样的,每个指令都做了哪些事情。当然啦,由于字节码指令有一两百个,我们没办法逐一分析,这里会介绍一些常见的。至于其它的指令,会随着学习的深入,慢慢揭晓。
介绍完常见指令之后,我们会探讨 Python 赋值语句的背后原理,并分析它们的差异。
常用指令
有一部分指令出现的频率极高,非常常用,我们来看一下。
- LOAD_CONST:加载一个常量;
- LOAD_FAST:在局部作用域中加载一个局部变量;
- LOAD_GLOBAL:在局部作用域中加载一个全局变量或内置变量;
- LOAD_NAME:在全局作用域中加载一个全局变量或内置变量;
- STORE_FAST:在局部作用域中定义一个局部变量,来建立和某个对象之间的映射关系;
- STORE_GLOBAL:在局部作用域中定义一个使用 global 关键字声明的全局变量,来建立和某个对象之间的映射关系;
- STORE_NAME:在全局作用域中定义一个全局变量,来建议和某个对象之间的映射关系;
我们举例说明:
import dis
name = "古明地觉"
def foo():
age = 16
print(age)
global name
print(name)
name = "古明地恋"
dis.dis(foo)
"""
6 0 LOAD_CONST 1 (16)
2 STORE_FAST 0 (age)
7 4 LOAD_GLOBAL 0 (print)
6 LOAD_FAST 0 (age)
8 CALL_FUNCTION 1
10 POP_TOP
9 12 LOAD_GLOBAL 0 (print)
14 LOAD_GLOBAL 1 (name)
16 CALL_FUNCTION 1
18 POP_TOP
10 20 LOAD_CONST 2 ('古明地恋')
22 STORE_GLOBAL 1 (name)
24 LOAD_CONST 0 (None)
26 RETURN_VALUE
"""
我们看到 age = 16 对应两条字节码指令。
- LOAD_CONST:加载一个常量,这里是 16;
- STORE_FAST:在局部作用域中创建一个局部变量,这里是 age;
print(age) 对应四条字节码指令。
- LOAD_GLOBAL:在局部作用域中加载一个全局变量或内置变量,这里是 print;
- LOAD_FAST:在局部作用域中加载一个局部变量,这里是 age;
- CALL_FUNCTION:函数调用;
- POP_TOP:从栈顶弹出返回值;
print(name) 对应四条字节码指令。
- LOAD_GLOBAL:在局部作用域中加载一个全局变量或内置变量,这里是 print;
- LOAD_GLOBAL:在局部作用域中加载一个全局变量或内置变量,这里是 name;
- CALL_FUNCTION:函数调用;
- POP_TOP:从栈顶弹出返回值;
name = "古明地恋" 对应两条字节码指令。
- LOAD_CONST:加载一个常量,这里是 "古明地恋";
- STORE_GLOBAL:在局部作用域中创建一个 global 关键字声明的全局变量,这里是 name;
这些指令非常常见,因为它们和常量、变量的加载,以及变量的定义密切相关,你写的任何代码在反编译之后都少不了它们的身影。
注:不管加载的是常量、还是变量,得到的永远是指向对象的指针。
变量赋值的具体细节
这里再通过变量赋值感受一下字节码的执行过程,首先关于变量赋值,你平时是怎么做的呢?
这些赋值语句背后的原理是什么呢?我们通过字节码来逐一回答。
1)a, b = b, a 的背后原理是什么?
想要知道背后的原理,查看它的字节码是我们最好的选择。
1 0 LOAD_NAME 0 (b)
2 LOAD_NAME 1 (a)
4 ROT_TWO
6 STORE_NAME 1 (a)
8 STORE_NAME 0 (b)
10 LOAD_CONST 0 (None)
12 RETURN_VALUE
里面关键的就是 ROT_TWO 指令,虽然我们还没看这个指令,但也能猜出来它负责交换栈里面的两个元素。假设 a 和 b 的值分别为 22、33,看一下运行时栈的变化过程。
示意图还是很好理解的,关键就在于 ROT_TWO 指令,它是怎么交换元素的呢?
case TARGET(ROT_TWO): {
// 获取栈顶元素
PyObject *top = TOP();
// 获取从栈顶开始的第二个元素(栈底元素)
PyObject *second = SECOND();
// 将栈顶元素设置为 second,将栈的第二个元素设置为 top
// 完成两个元素之间的交换
SET_TOP(second);
SET_SECOND(top);
FAST_DISPATCH();
}
执行 ROT_TWO 指令之前,栈里有两个元素,栈顶元素是 a,栈底元素是 b。执行 ROT_TWO 指令之后,栈顶元素是 b,栈底元素是 a。然后后面的两个 STORE_NAME 会将栈里面的元素 b、a 依次弹出,赋值给 a、b,从而完成变量交换。
2)a, b, c = c, b, a 的背后原理是什么?
老规矩,还是查看字节码,因为一切真相都隐藏在字节码当中。
1 0 LOAD_NAME 0 (c)
2 LOAD_NAME 1 (b)
4 LOAD_NAME 2 (a)
6 ROT_THREE
8 ROT_TWO
10 STORE_NAME 2 (a)
12 STORE_NAME 1 (b)
14 STORE_NAME 0 (c)
16 LOAD_CONST 0 (None)
18 RETURN_VALUE
整个过程和 a, b = b, a 是相似的,首先 LOAD_NAME 将变量 c、b、a 依次压入栈中。由于栈先入后出的特性,此时栈的三个元素按照顺序(从栈顶到栈底)分别是 a、b、c。然后是 ROT_THREE 和 ROT_TWO,毫无疑问,这两个指令执行完之后,会将栈的三个元素调换顺序,也就是将 a、b、c 变成 c、b、a。最后 STORE_NAME 将栈的三个元素 c、b、a 依次弹出,分别赋值给 a、b、c,从而完成变量的交换。
因此核心就在 ROT_THREE 和 ROT_TWO 上面,由于后者上面已经说过了,所以我们看一下 ROT_THREE。
case TARGET(ROT_THREE): {
PyObject *top = TOP();
PyObject *second = SECOND();
PyObject *third = THIRD();
SET_TOP(second);
SET_SECOND(third);
SET_THIRD(top);
FAST_DISPATCH();
}
栈顶元素是 top、栈的第二个元素是 second、栈的第三个元素是 third,然后将栈顶元素设置为 second、栈的第二个元素设置为 third、栈的第三个元素设置为 top。所以栈里面的 a、b、c 在经过 ROT_THREE 之后就变成了 b、c、a,显然这还不是正确的结果。于是继续执行 ROT_TWO,将栈的前两个元素进行交换,执行完之后就变成了 c、b、a。
假设 a、b、c 的值分别为 "a"、"b"、"c",整个过程如下:
对于多元赋值来说,解释器的做法是固定的,首先按照从左往右的顺序,将等号右边的变量依次压入栈中,然后在栈里面对元素做处理,最后再将栈里的元素弹出,仍旧按照从左往右的顺序,依次赋值给等号左边的变量。
另外这里为了交换栈里的三个元素,使用了两个指令,但其实一个指令就够了,只需将栈顶元素和栈底元素进行交换即可,因为中间的元素是不需要动的。而在之后的版本中,官方优化了这个逻辑。
3)a, b, c, d = d, c, b, a 的背后原理是什么?它和上面提到的 1)和 2)有什么区别呢?
我们还是看一下字节码。
1 0 LOAD_NAME 0 (d)
2 LOAD_NAME 1 (c)
4 LOAD_NAME 2 (b)
6 LOAD_NAME 3 (a)
8 BUILD_TUPLE 4
10 UNPACK_SEQUENCE 4
12 STORE_NAME 3 (a)
14 STORE_NAME 2 (b)
16 STORE_NAME 1 (c)
18 STORE_NAME 0 (d)
20 LOAD_CONST 0 (None)
22 RETURN_VALUE
将等号右边的变量,按照从左往右的顺序,依次压入栈中,但此时没有直接将栈里面的元素做交换,而是构建一个元组。因为往栈里面压入了四个元素,所以 BUILD_TUPLE 后面的 oparg 是 4,表示构建长度为 4 的元组。
case TARGET(BUILD_TUPLE): {
// 元素从栈顶到栈底依次是 a、b、c、d
PyObject *tup = PyTuple_New(oparg);
if (tup == NULL)
goto error;
// 将元素依次弹出,弹出的顺序也是 a、b、c、d
// 但是注意循环,元素是从后往前设置的
// 所以 item[3], item[2], item[1], item[0] = a, b, c, d
while (--oparg >= 0) {
PyObject *item = POP();
PyTuple_SET_ITEM(tup, oparg, item);
}
// 将元组 item 压入栈中,元组为 (d, c, b, a)
PUSH(tup);
DISPATCH();
}
此时栈里面只有一个元素,指向一个元组。接下来是 UNPACK_SEQUENCE,负责对序列进行解包,它的指令参数也是 4,表示要解包的序列的长度为 4,我们来看看它的逻辑。
case TARGET(UNPACK_SEQUENCE): {
PREDICTED(UNPACK_SEQUENCE);
// seq:从栈里面弹出的元组 (d, c, b, a)
// item:用于遍历元素
// items:指向一个 PyObject * 类型的数组
PyObject *seq = POP(), *item, **items;
if (PyTuple_CheckExact(seq) &&
PyTuple_GET_SIZE(seq) == oparg) {
// 获取元组内部的 ob_item 字段,元素就存储在它指向的数组中
items = ((PyTupleObject *)seq)->ob_item;
// 遍历内部的每一个元素,并依次压入栈中
// 由于是从后往前遍历的,所以遍历的元素依次是 a b c d
// 但在压入栈中之后,元素从栈顶到栈底就变成了 d c b a
while (oparg--) {
item = items[oparg];
Py_INCREF(item);
PUSH(item);
}
} else if (PyList_CheckExact(seq) &&
PyList_GET_SIZE(seq) == oparg) {
// 该指令同样适用于列表,逻辑一样(一会儿会看到)
items = ((PyListObject *)seq)->ob_item;
while (oparg--) {
item = items[oparg];
Py_INCREF(item);
PUSH(item);
}
}
// ...
Py_DECREF(seq);
DISPATCH();
}
最后 STORE_NAME 将 d c b a 依次弹出,赋值给变量 a b c d,从而完成变量交换。所以当交换的变量多了之后,不会直接在运行时栈里面操作,而是将栈里面的元素挨个弹出,构建元组;然后再按照指定顺序,将元组里面的元素重新压到栈里面。
假设变量 a b c d 的值分别为 1 2 3 4,我们画图来描述一下整个过程。
不管是哪一种做法,Python 在进行变量交换时所做的事情是不变的,核心分为三步走。首先将等号右边的变量,按照从左往右的顺序,依次压入栈中;然后对运行时栈里面元素的顺序进行调整;最后再将运行时栈里面的元素挨个弹出,还是按照从左往右的顺序,再依次赋值给等号左边的变量。
只不过当变量不多时,调整元素位置会直接基于栈进行操作;而当达到四个时,则需要额外借助于元组。
然后多元赋值也是同理,比如 a, b, c = 1, 2, 3,看一下它的字节码。
1 0 LOAD_CONST 0 ((1, 2, 3))
2 UNPACK_SEQUENCE 3
4 STORE_NAME 0 (a)
6 STORE_NAME 1 (b)
8 STORE_NAME 2 (c)
10 LOAD_CONST 1 (None)
12 RETURN_VALUE
元组直接作为一个常量被加载进来了,然后解包,再依次赋值。
4)a, b, c, d = d, c, b, a 和 a, b, c, d = [d, c, b, a] 有区别吗?
答案是没有区别,两者在反编译之后对应的字节码指令只有一处不同。
1 0 LOAD_NAME 0 (d)
2 LOAD_NAME 1 (c)
4 LOAD_NAME 2 (b)
6 LOAD_NAME 3 (a)
8 BUILD_LIST 4
10 UNPACK_SEQUENCE 4
12 STORE_NAME 3 (a)
14 STORE_NAME 2 (b)
16 STORE_NAME 1 (c)
18 STORE_NAME 0 (d)
20 LOAD_CONST 0 (None)
22 RETURN_VALUE
前者是 BUILD_TUPLE,现在变成了 BUILD_LIST,其它部分一模一样,并且解包用的依旧是 UNPACK_SEQUENCE 指令,所以两者的效果是相同的。当然啦,由于元组的构建比列表快一些,因此还是推荐第一种写法。
5)a = b = c = 123 背后的原理是什么?
如果变量 a、b、c 指向的值相同,比如都是 123,那么便可以通过这种方式进行链式赋值。那么它背后是怎么做的呢?
1 0 LOAD_CONST 0 (123)
2 DUP_TOP
4 STORE_NAME 0 (a)
6 DUP_TOP
8 STORE_NAME 1 (b)
10 STORE_NAME 2 (c)
12 LOAD_CONST 1 (None)
14 RETURN_VALUE
出现了一个新的字节码指令 DUP_TOP,只要搞清楚它的作用,事情就简单了。
case TARGET(DUP_TOP): {
// 获取栈顶元素,注意是获取、不是弹出
// TOP:查看元素,POP:弹出元素
PyObject *top = TOP();
// 增加指向对象的引用计数
Py_INCREF(top);
// 压入栈中
PUSH(top);
FAST_DISPATCH();
}
所以 DUP_TOP 干的事情就是将栈顶元素拷贝一份,再重新压到栈里面。另外不管链式赋值语句中有多少个变量,模式都是一样的。
我们以 a = b = c = d = e = 123 为例:
1 0 LOAD_CONST 0 (123)
2 DUP_TOP
4 STORE_NAME 0 (a)
6 DUP_TOP
8 STORE_NAME 1 (b)
10 DUP_TOP
12 STORE_NAME 2 (c)
14 DUP_TOP
16 STORE_NAME 3 (d)
18 STORE_NAME 4 (e)
20 LOAD_CONST 1 (None)
22 RETURN_VALUE
将常量压入运行时栈,然后拷贝一份,赋值给 a;再拷贝一份,赋值给 b;再拷贝一份,赋值给 c;再拷贝一份,赋值给 d;最后自身赋值给 e。
当然啦,虽然 Python 一切皆对象,但拿到的都是指向对象的指针,所以这里拷贝的是指针。
以上就是链式赋值的秘密,其实没有什么好神奇的,就是将栈顶元素进行拷贝,再依次赋值。但是这背后有一个坑,就是给变量赋的值不能是可变对象,否则容易造成 BUG。
a = b = c = {}
a["ping"] = "pong"
print(a) # {'ping': 'pong'}
print(b) # {'ping': 'pong'}
print(c) # {'ping': 'pong'}
虽然 Python 一切皆对象,但对象都是通过指针来间接操作的。所以 DUP_TOP 是将字典的地址拷贝一份,而字典只有一个,因此最终 a、b、c 会指向同一个字典。
6)a is b 和 a == b 的区别是什么?
is 用于判断两个变量是不是引用同一个对象,也就是保存的对象的地址是否相等;而 == 则是判断两个变量引用的对象是否相等,等价于 a.__eq__(b) 。
Python 的变量在 C 看来只是一个指针,因此两个变量是否指向同一个对象,等价于 C 中的两个指针存储的地址是否相等;
而 Python 的 ==,则需要调用 PyObject_RichCompare,来比较它们指向的对象所维护的值是否相等。
这两个语句的字节码指令是一样的,唯一的区别就是指令 COMPARE_OP 的参数不同。
// a is b
1 0 LOAD_NAME 0 (a)
2 LOAD_NAME 1 (b)
4 COMPARE_OP 8 (is)
6 POP_TOP
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
// a == b
1 0 LOAD_NAME 0 (a)
2 LOAD_NAME 1 (b)
4 COMPARE_OP 2 (==)
6 POP_TOP
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
我们看到指令参数一个是 8、一个是 2,然后是 COMPARE_OP 指令的背后逻辑:
case TARGET(COMPARE_OP): {
// 弹出栈顶元素,这里是 b
PyObject *right = POP();
// 显然 left 就是 a,因为 b 被弹出之后,a 就成为了新的栈顶元素
PyObject *left = TOP();
// 进行比较,比较结果为 res
PyObject *res = cmp_outcome(tstate, oparg, left, right);
// 减少 left 和 right 引用计数
Py_DECREF(left);
Py_DECREF(right);
// 将栈顶元素替换为 res
SET_TOP(res);
if (res == NULL)
goto error;
// 指令预测,暂时不用管,等介绍 if 控制流的时候再说
PREDICT(POP_JUMP_IF_FALSE);
PREDICT(POP_JUMP_IF_TRUE);
DISPATCH();
}
所以逻辑很简单,核心就在 cmp_outcome 函数中。
// Python/ceval.c
static PyObject *
cmp_outcome(PyThreadState *tstate, int op, PyObject *v, PyObject *w)
{
int res = 0;
// op 就是 COMPARE_OP 指令的参数
switch (op) {
// PyCmp_IS 是一个枚举变量,等于 8,定义在 Include/opcode.h 中
// 而 is 关键字,在 C 的层面就是一个 == 判断
case PyCmp_IS:
res = (v == w);
break;
// is not 则对应 !=
case PyCmp_IS_NOT:
res = (v != w);
break;
// in 关键字
case PyCmp_IN:
res = PySequence_Contains(w, v);
if (res < 0)
return NULL;
break;
// not in 关键字
case PyCmp_NOT_IN:
res = PySequence_Contains(w, v);
if (res < 0)
return NULL;
res = !res;
break;
// except 关键字
case PyCmp_EXC_MATCH:
if (PyTuple_Check(w)) {
Py_ssize_t i, length;
length = PyTuple_Size(w);
for (i = 0; i < length; i += 1) {
PyObject *exc = PyTuple_GET_ITEM(w, i);
if (!PyExceptionClass_Check(exc)) {
_PyErr_SetString(tstate, PyExc_TypeError,
CANNOT_CATCH_MSG);
return NULL;
}
}
}
else {
if (!PyExceptionClass_Check(w)) {
_PyErr_SetString(tstate, PyExc_TypeError,
CANNOT_CATCH_MSG);
return NULL;
}
}
res = PyErr_GivenExceptionMatches(v, w);
break;
default:
// 剩下的走 PyObject_RichCompare 逻辑
// 这是一个函数调用,比较对象维护的值是否相等
return PyObject_RichCompare(v, w, op);
}
v = res ? Py_True : Py_False;
Py_INCREF(v);
return v;
}
我们实际举个栗子:
a = 3.14
b = float("3.14")
print(a is b) # False
print(a == b) # True
a 和 b 都是 3.14,两者是相等的,但不是同一个对象。
反过来也是如此,如果 a is b 成立,那么 a == b 也不一定成立。可能有人好奇,a is b 成立说明 a 和 b 指向的是同一个对象,那么 a == b 表示该对象和自己进行比较,结果应该始终是相等的呀,为啥也不一定成立呢?以下面两种情况为例:
class Girl:
def __eq__(self, other):
return False
g = Girl()
print(g is g) # True
print(g == g) # False
__eq__ 返回 False,此时虽然是同一个对象,但是两者不相等。
import math
import numpy as np
a = float("nan")
b = math.nan
c = np.nan
print(a is a, a == a) # True False
print(b is b, b == b) # True False
print(c is c, c == c) # True False
nan 是一个特殊的浮点数,意思是 not a number(不是一个数字),用于表示空值。而 nan 和所有数字的比较结果均为 False,即使是和它自身比较。
但需要注意的是,在使用 == 进行比较的时候虽然是不相等的,但如果放到容器里面就不一定了。举个例子:
import numpy as np
lst = [np.nan, np.nan, np.nan]
print(lst[0] == np.nan) # False
print(lst[1] == np.nan) # False
print(lst[2] == np.nan) # False
# lst 里面的三个元素和 np.nan 均不相等
# 但是 np.nan 位于列表中,并且数量是 3
print(np.nan in lst) # True
print(lst.count(np.nan)) # 3
出现以上结果的原因就在于,元素被放到了容器里,而容器的一些 API 在比较元素时会先判定它们存储的对象的地址是否相同,即:是否指向了同一个对象。如果是,直接认为相等;否则,再去比较对象维护的值是否相等。可以理解为先进行 is 判断,如果结果为 True,直接判定两者相等;如果 is 操作的结果不为 True,再去进行 == 判断。
因此 np.nan in lst 的结果为 True,lst.count(np.nan) 的结果是 3,因为它们会先比较对象的地址。地址相同,则直接认为对象相等。
在用 pandas 做数据处理的时候,nan 是一个非常容易坑的地方。
提到 is 和 ==,那么问题来了,在和 True、False、None 比较时,是用 is 还是用 == 呢?由于 True、False、None 它们不仅是关键字,而且也被看做是一个常量,最重要的是它们都是单例的,所以我们应该用 is 判断。
另外 is 在底层只需要一个 == 即可完成,但 Python 的 ==,在底层则需要调用 PyObject_RichCompare 函数。因此 is 在速度上也更有优势,== 操作肯定比函数调用要快。
补充:判断对象是否相等,底层有两个常用的函数,分别是 PyObject_RichCompare 和 PyObject_RichCompareBool。
PyObject_RichCompare 是直接比较对象的值是否相等。而 PyObject_RichCompareBool 会先比较地址是否相等(即是否是同一个对象),如果是同一个对象,那么直接认为相等,否则再调用 PyObject_RichCompare 判断值是否相等。
对于容器的一些 API,在比较对象是否相等时,调用的都是 PyObject_RichCompareBool。
小结
以上我们就分析了常见的几个指令,以及变量赋值的底层逻辑,怎么样,是不是对 Python 有更深的理解了呢。
欢迎大家关注我的公众号:古明地觉的编程教室。
如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。