剖析字节码指令,以及 Python 赋值语句的原理

文摘   2024-10-29 10:19   北京  

楔子


前面我们考察了虚拟机执行字节码指令的原理,那么本篇文章就来看看这些指令对应的逻辑是怎样的,每个指令都做了哪些事情。当然啦,由于字节码指令有两百多个,我们没办法逐一分析,这里会介绍一些常见的。至于其它的指令,会随着学习的深入,慢慢揭晓。

介绍完常见指令之后,我们会探讨 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 == 0goto 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 ((123))
     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 == NULLgoto 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 有更深的理解了呢。

古明地觉的编程教室
Python、Rust 程序猿,你感兴趣的内容我都会写,点个关注吧(#^.^#)
 最新文章