楔子
前面我们考察了虚拟机执行字节码指令的原理,那么本篇文章就来看看这些指令对应的逻辑是怎样的,每个指令都做了哪些事情。当然啦,由于字节码指令有两百多个,我们没办法逐一分析,这里会介绍一些常见的。至于其它的指令,会随着学习的深入,慢慢揭晓。
介绍完常见指令之后,我们会探讨 Python 赋值语句的背后原理,并分析它们的差异。
常用指令
有一部分指令出现的频率极高,非常常用,我们来看一下。
我们举例说明:
import dis
name = "古明地觉"
def foo():
age = 16
print(age)
global name
print(name)
name = "古明地恋"
dis.dis(foo)
"""
1 0 RESUME 0
2 2 LOAD_CONST 1 (16)
4 STORE_FAST 0 (age)
3 6 LOAD_GLOBAL 1 (NULL + print)
16 LOAD_FAST 0 (age)
18 CALL 1
26 POP_TOP
5 28 LOAD_GLOBAL 1 (NULL + print)
38 LOAD_GLOBAL 2 (name)
48 CALL 1
56 POP_TOP
6 58 LOAD_CONST 2 ('古明地恋')
60 STORE_GLOBAL 1 (name)
62 RETURN_CONST 0 (None)
"""
我们看到 age = 16 对应两条字节码指令。
LOAD_CONST:加载一个常量,这里是 16;
STORE_FAST:在局部作用域中创建一个局部变量,这里是 age;
print(age) 对应四条字节码指令。
LOAD_GLOBAL:在局部作用域中加载一个全局变量或内置变量,这里是 print;
LOAD_FAST:在局部作用域中加载一个局部变量,这里是 age;
CALL:函数调用;
POP_TOP:从栈顶弹出返回值;
print(name) 对应四条字节码指令。
LOAD_GLOBAL:在局部作用域中加载一个全局变量或内置变量,这里是 print;
LOAD_GLOBAL:在局部作用域中加载一个全局变量或内置变量,这里是 name;
CALL:函数调用;
POP_TOP:从栈顶弹出返回值;
name = "古明地恋" 对应两条字节码指令。
LOAD_CONST:加载一个常量,这里是 "古明地恋";
STORE_GLOBAL:在局部作用域中创建一个 global 关键字声明的全局变量,这里是 name;
这些指令非常常见,因为它们和常量、变量的加载,以及变量的定义密切相关,你写的任何代码在反编译之后都少不了它们的身影。
注:不管加载的是常量、还是变量,得到的永远是指向对象的指针。
变量赋值的具体细节
这里再通过变量赋值感受一下字节码的执行过程,首先关于变量赋值,你平时是怎么做的呢?
这些赋值语句背后的原理是什么呢?我们通过字节码来逐一回答。
1)a, b = b, a 的背后原理是什么?
想要知道背后的原理,查看它的字节码是我们最好的选择。
0 RESUME 0
2 LOAD_NAME 0 (b)
4 LOAD_NAME 1 (a)
6 SWAP 2
8 STORE_NAME 1 (a)
10 STORE_NAME 0 (b)
12 RETURN_CONST 0 (None)
里面关键的就是 SWAP 指令,虽然我们还没看这个指令,但也能猜出来它负责交换栈里面的两个元素。假设 a 和 b 的值分别为 22、33,看一下运行时栈的变化过程。
示意图还是很好理解的,关键就在于 SWAP 指令,它是怎么交换元素的呢?
TARGET(SWAP) {
// 获取栈顶元素
PyObject *top = stack_pointer[-1];
// oparg 表示交换的元素个数
// 所以 stack_pointer[-oparg] 表示获取栈底元素
PyObject *bottom = stack_pointer[-(2 + (oparg-2))];
#line 3389 "Python/bytecodes.c"
assert(oparg >= 2);
#line 4680 "Python/generated_cases.c.h"
// 将栈顶元素和栈顶元素进行交换
stack_pointer[-1] = bottom;
stack_pointer[-(2 + (oparg-2))] = top;
DISPATCH();
}
执行 SWAP 指令之前,栈里有两个元素,栈顶元素是 a,栈底元素是 b。执行 SWAP 指令之后,栈顶元素是 b,栈底元素是 a。然后后面的两个 STORE_NAME 会将栈里面的元素 b、a 依次弹出,赋值给 a、b,从而完成变量交换。
2)a, b, c = c, b, a 的背后原理是什么?
老规矩,还是查看字节码,因为一切真相都隐藏在字节码当中。
0 RESUME 0
2 LOAD_NAME 0 (c)
4 LOAD_NAME 1 (b)
6 LOAD_NAME 2 (a)
8 SWAP 3
10 STORE_NAME 2 (a)
12 STORE_NAME 1 (b)
14 STORE_NAME 0 (c)
16 RETURN_CONST 0 (None)
整个过程和 a, b = b, a 是相似的,首先按照从左往右的顺序,将等号右边的变量依次压入栈中,然后调用 SWAP 指令交换栈顶和栈底的元素。最后将栈里的元素弹出,按照从左往右的顺序,依次赋值给等号左边的变量。
所以 SWAP 适用于两个或三个变量之间的交换,两个变量交换很好理解,关键是三个变量交换,依旧只需要一个 SWAP 指令,因为中间的元素是不需要动的。
3)a, b, c, d = d, c, b, a 的背后原理是什么?它和上面提到的 1)和 2)有什么区别呢?
我们还是看一下字节码。
0 RESUME 0
2 LOAD_NAME 0 (d)
4 LOAD_NAME 1 (c)
6 LOAD_NAME 2 (b)
8 LOAD_NAME 3 (a)
10 BUILD_TUPLE 4
12 UNPACK_SEQUENCE 4
16 STORE_NAME 3 (a)
18 STORE_NAME 2 (b)
20 STORE_NAME 1 (c)
22 STORE_NAME 0 (d)
24 RETURN_CONST 0 (None)
将等号右边的变量,按照从左往右的顺序,依次压入栈中,但此时没有直接将栈里面的元素做交换,而是构建一个元组。因为往栈里面压入了四个元素,所以 BUILD_TUPLE 后面的 oparg 是 4,表示构建长度为 4 的元组。
TARGET(BUILD_TUPLE) {
// stack_pointer 指向运行时栈的栈顶,oparg 表示运行时栈的元素个数
// 那么 stack_pointer - oparg 便指向运行时栈的栈底
PyObject **values = (stack_pointer - oparg);
PyObject *tup; // 指向创建的元组
#line 1489 "Python/bytecodes.c"
// 运行时栈本质上就是个数组,索引从小到大的方向表示栈底到栈顶的方向
// 当执行 a, b, c, d = d, c, b, a 时,会将右侧的变量依次入栈
// 运行时栈里的元素从栈底到栈顶依次是 d、c、b、a
// 拷贝数组(运行时栈)里的元素,创建元组,结果是 (d, c, b, a)
tup = _PyTuple_FromArraySteal(values, oparg);
if (tup == NULL) { STACK_SHRINK(oparg); goto error; }
#line 2038 "Python/generated_cases.c.h"
// 清空运行时栈
STACK_SHRINK(oparg);
// 然后将 tup 入栈
STACK_GROW(1);
stack_pointer[-1] = tup;
DISPATCH();
}
// Object/tupleobject.c
PyObject *
_PyTuple_FromArraySteal(PyObject *const *src, Py_ssize_t n)
{
if (n == 0) {
return tuple_get_empty();
}
// 申请长度为 n 的元组
PyTupleObject *tuple = tuple_alloc(n);
// ...
PyObject **dst = tuple->ob_item;
// 从 0 开始,将数组里的元组依次拷贝到元组中
for (Py_ssize_t i = 0; i < n; i++) {
PyObject *item = src[i];
dst[i] = item;
}
_PyObject_GC_TRACK(tuple);
return (PyObject *)tuple;
}
此时栈里面只有一个元素,指向一个元组。接下来是 UNPACK_SEQUENCE,负责对序列进行解包,它的指令参数也是 4,表示要解包的序列的长度为 4,我们来看看它的逻辑。
TARGET(UNPACK_SEQUENCE) {
PREDICTED(UNPACK_SEQUENCE);
// 获取栈顶元素,也就是上一步创建的元组:(d, c, b, a)
PyObject *seq = stack_pointer[-1];
#line 1057 "Python/bytecodes.c"
// ...
// 将元组里的元素弹出,并依次入栈,此时方向和之前是相反的
PyObject **top = stack_pointer + oparg - 1;
int res = unpack_iterable(tstate, seq, oparg, -1, top);
#line 1462 "Python/generated_cases.c.h"
Py_DECREF(seq);
#line 1070 "Python/bytecodes.c"
if (res == 0) goto pop_1_error;
#line 1466 "Python/generated_cases.c.h"
STACK_SHRINK(1);
STACK_GROW(oparg);
next_instr += 1;
DISPATCH();
}
假设变量 a b c d 的值分别为 1 2 3 4,我们画图来描述一下整个过程。
可以看到当交换的变量多了之后,不会直接在运行时栈里面操作,而是将栈里面的元素挨个弹出、构建元组(准确的说应该是先构建元组,然后再清空运行时栈)。接着再按照指定顺序,将元组里面的元素重新压到栈里面。
当然不管是哪一种做法,Python 在进行变量交换时所做的事情是不变的,核心分为三步。
1)将等号右边的变量,按照从左往右的顺序,依次压入栈中;
2)对运行时栈里面元素的顺序进行调整;
3)将运行时栈里面的元素挨个弹出,还是按照从左往右的顺序,再依次赋值给等号左边的变量;
只不过当变量不多时,调整元素位置会直接基于栈进行操作。而当达到四个时,则需要借助元组。
然后多元赋值也是同理,比如 a, b, c = 1, 2, 3,看一下它的字节码。
0 RESUME 0
2 LOAD_CONST 0 ((1, 2, 3))
4 UNPACK_SEQUENCE 3
8 STORE_NAME 0 (a)
10 STORE_NAME 1 (b)
12 STORE_NAME 2 (c)
14 RETURN_CONST 1 (None)
元组直接作为一个常量被加载进来了,然后解包,再依次赋值。运行时栈变化如下:
没有任何问题,以上就是多元赋值的原理。
4)a, b, c, d = d, c, b, a 和 a, b, c, d = [d, c, b, a] 有区别吗?
答案是没有区别,两者在反编译之后对应的字节码指令只有一处不同。
0 RESUME 0
2 LOAD_NAME 0 (d)
4 LOAD_NAME 1 (c)
6 LOAD_NAME 2 (b)
8 LOAD_NAME 3 (a)
10 BUILD_LIST 4
12 UNPACK_SEQUENCE 4
16 STORE_NAME 3 (a)
18 STORE_NAME 2 (b)
20 STORE_NAME 1 (c)
22 STORE_NAME 0 (d)
24 RETURN_CONST 0 (None)
前者是 BUILD_TUPLE,现在变成了 BUILD_LIST,其它部分一模一样,所以两者的效果是相同的。当然啦,由于元组的构建比列表快一些,因此还是推荐第一种写法。
5)a = b = c = 123 背后的原理是什么?
如果变量 a、b、c 指向的值相同,比如都是 123,那么便可以通过这种方式进行链式赋值。那么它背后是怎么做的呢?
0 RESUME 0
2 LOAD_CONST 0 (123)
4 COPY 1
6 STORE_NAME 0 (a)
8 COPY 1
10 STORE_NAME 1 (b)
12 STORE_NAME 2 (c)
14 RETURN_CONST 1 (None)
出现了一个新的字节码指令 COPY,只要搞清楚它的作用,事情就简单了。
TARGET(COPY) {
// 获取栈底元素,由于当前只有一个元素,所以它也是栈顶元素
PyObject *bottom = stack_pointer[-(1 + (oparg-1))];
PyObject *top;
#line 3364 "Python/bytecodes.c"
assert(oparg > 0);
top = Py_NewRef(bottom);
#line 4636 "Python/generated_cases.c.h"
// 将元素压入栈中,也就是将元素拷贝了一份,然后重新入栈
STACK_GROW(1);
stack_pointer[-1] = top;
DISPATCH();
}
所以 COPY 干的事情就是将栈顶元素拷贝一份,再重新压到栈里面。
另外不管链式赋值语句中有多少个变量,模式都是一样的,我们以 a = b = c = d = e = 123 为例:
0 RESUME 0
2 LOAD_CONST 0 (123)
4 COPY 1
6 STORE_NAME 0 (a)
8 COPY 1
10 STORE_NAME 1 (b)
12 COPY 1
14 STORE_NAME 2 (c)
16 COPY 1
18 STORE_NAME 3 (d)
20 STORE_NAME 4 (e)
22 RETURN_CONST 1 (None)
将常量 123 压入运行时栈,然后拷贝一份,赋值给 a;再拷贝一份,赋值给 b;再拷贝一份,赋值给 c;再拷贝一份,赋值给 d;最后自身赋值给 e。
以上就是链式赋值的秘密,其实没有什么好神奇的,就是将栈顶元素进行拷贝,再依次赋值。
但是这背后有一个坑,就是给变量赋的值不能是可变对象,否则容易造成 BUG。
a = b = c = {}
a["ping"] = "pong"
print(a) # {'ping': 'pong'}
print(b) # {'ping': 'pong'}
print(c) # {'ping': 'pong'}
虽然 Python 一切皆对象,但对象都是通过指针来间接操作的。所以 COPY 是将字典的地址拷贝一份,而字典只有一个,因此最终 a、b、c 会指向同一个字典。
6)a is b 和 a == b 的区别是什么?
is 用于判断两个变量是不是引用同一个对象,也就是保存的对象的地址是否相等;而 == 则是判断两个变量引用的对象是否相等,等价于 a.__eq__(b) 。
Python 的变量在 C 看来只是一个指针,因此两个变量是否指向同一个对象,等价于 C 中的两个指针存储的地址是否相等;
而 Python 的 ==,则需要调用 PyObject_RichCompare,来比较它们指向的对象所维护的值是否相等。
这两个语句的字节码指令集只有一处不同:
# a is b
0 RESUME 0
2 LOAD_NAME 0 (a)
4 LOAD_NAME 1 (b)
6 IS_OP 0
8 POP_TOP
10 RETURN_CONST 0 (None)
# a == b
0 RESUME 0
2 LOAD_NAME 0 (a)
4 LOAD_NAME 1 (b)
6 COMPARE_OP 40 (==)
10 POP_TOP
12 RETURN_CONST 0 (None)
我们看到 a is b 调用的指令是 IS_OP,而 == 调用的指令是 COMPARE_OP。
// Python 的 is 在 C 的层面就是比较两个指针是否相等
TARGET(IS_OP) {
// 获取栈顶的两个元素
PyObject *right = stack_pointer[-1];
PyObject *left = stack_pointer[-2];
PyObject *b;
#line 2088 "Python/bytecodes.c"
// 进行比较,即 left == right
int res = Py_Is(left, right) ^ oparg;
#line 2902 "Python/generated_cases.c.h"
Py_DECREF(left);
Py_DECREF(right);
#line 2090 "Python/bytecodes.c"
// 如果相等,结果为 True,否则为 False
b = res ? Py_True : Py_False;
#line 2907 "Python/generated_cases.c.h"
// 此时栈里面有两个元素,弹出一个,然后将栈顶元素修改为比较结果
// 为了方便,你也可以理解为:将栈里的两个元素弹出,再将比较结果入栈
// 效果上两者是等价的
STACK_SHRINK(1);
stack_pointer[-1] = b;
DISPATCH();
}
TARGET(COMPARE_OP) {
PREDICTED(COMPARE_OP);
// 获取栈里的两个元素
PyObject *right = stack_pointer[-1];
PyObject *left = stack_pointer[-2];
PyObject *res;
// ...
assert((oparg >> 4) <= Py_GE);
// 调用 PyObject_RichCompare 函数进行比较
res = PyObject_RichCompare(left, right, oparg>>4);
#line 2813 "Python/generated_cases.c.h"
Py_DECREF(left);
Py_DECREF(right);
#line 2038 "Python/bytecodes.c"
if (res == NULL) goto pop_2_error;
#line 2818 "Python/generated_cases.c.h"
// 将比较结果入栈
STACK_SHRINK(1);
stack_pointer[-1] = res;
next_instr += 1;
DISPATCH();
}
这里我们再看一下 PyObject_RichCompare 函数,看看底层是怎么比较的。
// Include/object.h
#define Py_LT 0
#define Py_LE 1
#define Py_EQ 2
#define Py_NE 3
#define Py_GT 4
#define Py_GE 5
// Objects/object.c
int _Py_SwappedOp[] = {Py_GT, Py_GE, Py_EQ, Py_NE, Py_LT, Py_LE};
static const char * const opstrings[] = {"<", "<=", "==", "!=", ">", ">="};
PyObject *
PyObject_RichCompare(PyObject *v, PyObject *w, int op)
{
// ...
// 调用了 do_richcompare
PyObject *res = do_richcompare(tstate, v, w, op);
_Py_LeaveRecursiveCallTstate(tstate);
return res;
}
static PyObject *
do_richcompare(PyThreadState *tstate, PyObject *v, PyObject *w, int op)
{
// 类型对象在底层有一个 tp_richcompare 字段,它负责实现比较逻辑
// 另外在 Python 里面每个操作符都对应一个魔法方法
// 而在底层,所有的比较操作符都由 tp_richcompare 实现
richcmpfunc f; // 比较函数
PyObject *res;
int checked_reverse_op = 0;
// 如果 v 和 w 不是同一种类型,并且 type(w) 是 type(v) 的子类
// 那么优先查找 type(w) 的 tp_richcompare,如果有则调用
if (!Py_IS_TYPE(v, Py_TYPE(w)) &&
PyType_IsSubtype(Py_TYPE(w), Py_TYPE(v)) &&
(f = Py_TYPE(w)->tp_richcompare) != NULL) {
checked_reverse_op = 1;
res = (*f)(w, v, _Py_SwappedOp[op]);
if (res != Py_NotImplemented)
return res;
Py_DECREF(res);
}
// 否则查找 type(v) 的 tp_richcompare,如果有则调用
if ((f = Py_TYPE(v)->tp_richcompare) != NULL) {
res = (*f)(v, w, op);
if (res != Py_NotImplemented)
return res;
Py_DECREF(res);
}
// 前面两个条件都不满足,那么查找 type(w) 的 tp_richcompare
if (!checked_reverse_op && (f = Py_TYPE(w)->tp_richcompare) != NULL) {
res = (*f)(w, v, _Py_SwappedOp[op]);
if (res != Py_NotImplemented)
return res;
Py_DECREF(res);
}
// 如果以上条件都不满足,说明没有实现比较操作
// 那么检测操作符是否是 == 或 !=
// 因为对于这两个操作符,不管什么类型,都是合法的
// 此时会比较它们的内存地址
switch (op) {
case Py_EQ:
res = (v == w) ? Py_True : Py_False;
break;
case Py_NE:
res = (v != w) ? Py_True : Py_False;
break;
default:
// 如果没实现比较操作,并且操作符也不是 == 和 !=
// 那么报错,这两个实例之间无法进行比较
_PyErr_Format(tstate, PyExc_TypeError,
"'%s' not supported between instances of '%.100s' and '%.100s'",
opstrings[op],
Py_TYPE(v)->tp_name,
Py_TYPE(w)->tp_name);
return NULL;
}
return Py_NewRef(res);
}
虽然在 Python 里面用于比较的魔法方法有多个,比如 __eq__、__le__、__gt__ 等等。但在底层,它们都对应 tp_richcompare,至于具体是哪一种,则由参数控制。所以我们实现任意一个用于比较的魔法方法,底层都会实现 tp_richcompare。
至于 tp_richcompare 具体支持多少种操作符,则取决于实现了几个魔法方法,比如我们只实现了 __eq__,但操作符为 Py_ET,那么就会抛出 Py_NotImplemented。
我们实际举个栗子:
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 在速度上也更有优势,比函数调用要快。
小结
以上我们就分析了常见的几个指令,以及变量赋值的底层逻辑,怎么样,是不是对 Python 有更深的理解了呢。