剖析字節(jié)碼指令,以及 Python 賦值語句的原理
楔子
前面我們考察了虛擬機執(zhí)行字節(jié)碼指令的原理,那么本篇文章就來看看這些指令對應(yīng)的邏輯是怎樣的,每個指令都做了哪些事情。當然啦,由于字節(jié)碼指令有兩百多個,我們沒辦法逐一分析,這里會介紹一些常見的。至于其它的指令,會隨著學習的深入,慢慢揭曉。
介紹完常見指令之后,我們會探討 Python 賦值語句的背后原理,并分析它們的差異。
常用指令
有一部分指令出現(xiàn)的頻率極高,非常常用,我們來看一下。
我們舉例說明:
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 對應(yīng)兩條字節(jié)碼指令。
- LOAD_CONST:加載一個常量,這里是 16;
- STORE_FAST:在局部作用域中創(chuàng)建一個局部變量,這里是 age;
print(age) 對應(yīng)四條字節(jié)碼指令。
- LOAD_GLOBAL:在局部作用域中加載一個全局變量或內(nèi)置變量,這里是 print;
- LOAD_FAST:在局部作用域中加載一個局部變量,這里是 age;
- CALL:函數(shù)調(diào)用;
- POP_TOP:從棧頂彈出返回值;
print(name) 對應(yīng)兩條字節(jié)碼指令。
- LOAD_GLOBAL:在局部作用域中加載一個全局變量或內(nèi)置變量,這里是 print;
- LOAD_GLOBAL:在局部作用域中加載一個全局變量或內(nèi)置變量,這里是 name;
- CALL:函數(shù)調(diào)用;
- POP_TOP:從棧頂彈出返回值;
name = "古明地戀" 對應(yīng)兩條字節(jié)碼指令。
- LOAD_CONST:加載一個常量,這里是 "古明地戀";
- STORE_GLOBAL:在局部作用域中創(chuàng)建一個 global 關(guān)鍵字聲明的全局變量,這里是 name;
這些指令非常常見,因為它們和常量、變量的加載,以及變量的定義密切相關(guān),你寫的任何代碼在反編譯之后都少不了它們的身影。
注:不管加載的是常量、還是變量,得到的永遠是指向?qū)ο蟮闹羔槨?/p>
變量賦值的具體細節(jié)
這里再通過變量賦值感受一下字節(jié)碼的執(zhí)行過程,首先關(guān)于變量賦值,你平時是怎么做的呢?
圖片
這些賦值語句背后的原理是什么呢?我們通過字節(jié)碼來逐一回答。
1)a, b = b, a 的背后原理是什么?
想要知道背后的原理,查看它的字節(jié)碼是我們最好的選擇。
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)
里面關(guān)鍵的就是 SWAP 指令,雖然我們還沒看這個指令,但也能猜出來它負責交換棧里面的兩個元素。假設(shè) a 和 b 的值分別為 22、33,看一下運行時棧的變化過程。
圖片
示意圖還是很好理解的,關(guān)鍵就在于 SWAP 指令,它是怎么交換元素的呢?
TARGET(SWAP) {
// 獲取棧頂元素
PyObject *top = stack_pointer[-1];
// oparg 表示交換的元素個數(shù)
// 所以 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();
}
執(zhí)行 SWAP 指令之前,棧里有兩個元素,棧頂元素是 a,棧底元素是 b。執(zhí)行 SWAP 指令之后,棧頂元素是 b,棧底元素是 a。然后后面的兩個 STORE_NAME 會將棧里面的元素 b、a 依次彈出,賦值給 a、b,從而完成變量交換。
2)a, b, c = c, b, a 的背后原理是什么?
老規(guī)矩,還是查看字節(jié)碼,因為一切真相都隱藏在字節(jié)碼當中。
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 是相似的,首先按照從左往右的順序,將等號右邊的變量依次壓入棧中,然后調(diào)用 SWAP 指令交換棧頂和棧底的元素。最后將棧里的元素彈出,按照從左往右的順序,依次賦值給等號左邊的變量。
所以 SWAP 適用于兩個或三個變量之間的交換,兩個變量交換很好理解,關(guān)鍵是三個變量交換,依舊只需要一個 SWAP 指令,因為中間的元素是不需要動的。
3)a, b, c, d = d, c, b, a 的背后原理是什么?它和上面提到的 1)和 2)有什么區(qū)別呢?
我們還是看一下字節(jié)碼。
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)
將等號右邊的變量,按照從左往右的順序,依次壓入棧中,但此時沒有直接將棧里面的元素做交換,而是構(gòu)建一個元組。因為往棧里面壓入了四個元素,所以 BUILD_TUPLE 后面的 oparg 是 4,表示構(gòu)建長度為 4 的元組。
TARGET(BUILD_TUPLE) {
// stack_pointer 指向運行時棧的棧頂,oparg 表示運行時棧的元素個數(shù)
// 那么 stack_pointer - oparg 便指向運行時棧的棧底
PyObject **values = (stack_pointer - oparg);
PyObject *tup; // 指向創(chuàng)建的元組
#line 1489 "Python/bytecodes.c"
// 運行時棧本質(zhì)上就是個數(shù)組,索引從小到大的方向表示棧底到棧頂?shù)姆较? // 當執(zhí)行 a, b, c, d = d, c, b, a 時,會將右側(cè)的變量依次入棧
// 運行時棧里的元素從棧底到棧頂依次是 d、c、b、a
// 拷貝數(shù)組(運行時棧)里的元素,創(chuàng)建元組,結(jié)果是 (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 開始,將數(shù)組里的元組依次拷貝到元組中
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,負責對序列進行解包,它的指令參數(shù)也是 4,表示要解包的序列的長度為 4,我們來看看它的邏輯。
TARGET(UNPACK_SEQUENCE) {
PREDICTED(UNPACK_SEQUENCE);
// 獲取棧頂元素,也就是上一步創(chuàng)建的元組:(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();
}
假設(shè)變量 a b c d 的值分別為 1 2 3 4,我們畫圖來描述一下整個過程。
圖片
可以看到當交換的變量多了之后,不會直接在運行時棧里面操作,而是將棧里面的元素挨個彈出、構(gòu)建元組(準確的說應(yīng)該是先構(gòu)建元組,然后再清空運行時棧)。接著再按照指定順序,將元組里面的元素重新壓到棧里面。
當然不管是哪一種做法,Python 在進行變量交換時所做的事情是不變的,核心分為三步。
- 1)將等號右邊的變量,按照從左往右的順序,依次壓入棧中;
- 2)對運行時棧里面元素的順序進行調(diào)整;
- 3)將運行時棧里面的元素挨個彈出,還是按照從左往右的順序,再依次賦值給等號左邊的變量;
只不過當變量不多時,調(diào)整元素位置會直接基于棧進行操作。而當達到四個時,則需要借助元組。
然后多元賦值也是同理,比如 a, b, c = 1, 2, 3,看一下它的字節(jié)碼。
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] 有區(qū)別嗎?
答案是沒有區(qū)別,兩者在反編譯之后對應(yīng)的字節(jié)碼指令只有一處不同。
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,現(xiàn)在變成了 BUILD_LIST,其它部分一模一樣,所以兩者的效果是相同的。當然啦,由于元組的構(gòu)建比列表快一些,因此還是推薦第一種寫法。
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)
出現(xiàn)了一個新的字節(jié)碼指令 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 的區(qū)別是什么?
is 用于判斷兩個變量是不是引用同一個對象,也就是保存的對象的地址是否相等;而 == 則是判斷兩個變量引用的對象是否相等,等價于 a.__eq__(b) 。
Python 的變量在 C 看來只是一個指針,因此兩個變量是否指向同一個對象,等價于 C 中的兩個指針存儲的地址是否相等;
而 Python 的 ==,則需要調(diào)用 PyObject_RichCompare,來比較它們指向的對象所維護的值是否相等。
這兩個語句的字節(jié)碼指令集只有一處不同:
# 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 調(diào)用的指令是 IS_OP,而 == 調(diào)用的指令是 COMPARE_OP。
// Python 的 is 在 C 的層面就是比較兩個指針是否相等
TARGET(IS_OP) {
// 獲取棧頂?shù)膬蓚€元素
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"
// 如果相等,結(jié)果為 True,否則為 False
b = res ? Py_True : Py_False;
#line 2907 "Python/generated_cases.c.h"
// 此時棧里面有兩個元素,彈出一個,然后將棧頂元素修改為比較結(jié)果
// 為了方便,你也可以理解為:將棧里的兩個元素彈出,再將比較結(jié)果入棧
// 效果上兩者是等價的
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);
// 調(diào)用 PyObject_RichCompare 函數(shù)進行比較
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"
// 將比較結(jié)果入棧
STACK_SHRINK(1);
stack_pointer[-1] = res;
next_instr += 1;
DISPATCH();
}
這里我們再看一下 PyObject_RichCompare 函數(shù),看看底層是怎么比較的。
// 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)
{
// ...
// 調(diào)用了 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 字段,它負責實現(xiàn)比較邏輯
// 另外在 Python 里面每個操作符都對應(yīng)一個魔法方法
// 而在底層,所有的比較操作符都由 tp_richcompare 實現(xiàn)
richcmpfunc f; // 比較函數(shù)
PyObject *res;
int checked_reverse_op = 0;
// 如果 v 和 w 不是同一種類型,并且 type(w) 是 type(v) 的子類
// 那么優(yōu)先查找 type(w) 的 tp_richcompare,如果有則調(diào)用
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,如果有則調(diào)用
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);
}
// 如果以上條件都不滿足,說明沒有實現(xiàn)比較操作
// 那么檢測操作符是否是 == 或 !=
// 因為對于這兩個操作符,不管什么類型,都是合法的
// 此時會比較它們的內(nèi)存地址
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:
// 如果沒實現(xiàn)比較操作,并且操作符也不是 == 和 !=
// 那么報錯,這兩個實例之間無法進行比較
_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__ 等等。但在底層,它們都對應(yīng) tp_richcompare,至于具體是哪一種,則由參數(shù)控制。所以我們實現(xiàn)任意一個用于比較的魔法方法,底層都會實現(xiàn) tp_richcompare。
至于 tp_richcompare 具體支持多少種操作符,則取決于實現(xiàn)了幾個魔法方法,比如我們只實現(xiàn)了 __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 表示該對象和自己進行比較,結(jié)果應(yīng)該始終是相等的呀,為啥也不一定成立呢?以下面兩種情況為例:
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 是一個特殊的浮點數(shù),意思是 not a number(不是一個數(shù)字),用于表示空值。而 nan 和所有數(shù)字的比較結(jié)果均為 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 位于列表中,并且數(shù)量是 3
print(np.nan in lst) # True
print(lst.count(np.nan)) # 3
出現(xiàn)以上結(jié)果的原因就在于,元素被放到了容器里,而容器的一些 API 在比較元素時會先判定地址是否相同,即:是否指向了同一個對象。如果是,直接認為相等;否則,再去比較對象維護的值是否相等。
可以理解為先進行 is 判斷,如果結(jié)果為 True,直接判定兩者相等;如果 is 操作的結(jié)果不為 True,再進行 == 判斷。
因此 np.nan in lst 的結(jié)果為 True,lst.count(np.nan) 的結(jié)果是 3,因為它們會先比較對象的地址。地址相同,則直接認為對象相等。
在用 pandas 做數(shù)據(jù)處理的時候,nan 是一個非常容易坑的地方。
提到 is 和 ==,那么問題來了,在和 True、False、None 比較時,是用 is 還是用 == 呢?
由于 True、False、None 它們不僅是關(guān)鍵字,而且也被看做是一個常量,最重要的是它們都是單例的,所以我們應(yīng)該用 is 判斷。
另外 is 在底層只需要一個 == 即可完成,這是非常簡單的低級操作,而 Python 的 == 在底層則需要調(diào)用 PyObject_RichCompare 函數(shù)。因此 is 在速度上也更有優(yōu)勢,比函數(shù)調(diào)用要快。
小結(jié)
以上我們就分析了常見的幾個指令,以及變量賦值的底層邏輯,怎么樣,是不是對 Python 有更深的理解了呢。