函数在底层是如何调用的?

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

楔子



上一篇文章我们说了 Python 函数的底层实现,并且还演示了如何通过函数的类型对象自定义一个函数,以及如何获取函数的参数。虽然这在工作中没有太大意义,但是可以让我们深刻理解函数的行为。

那么接下来看看函数是如何调用的。



PyCFunctionObject



在介绍调用之前,我们需要补充一个知识点。

def foo():
    pass

class A:

    def foo(self):
        pass

print(type(foo))  # <class 'function'>
print(type(A().foo))  # <class 'method'>
print(type(sum))  # <class 'builtin_function_or_method'>
print(type("".join))  # <class 'builtin_function_or_method'>

如果采用 Python 实现,那么函数的类型是 function,方法的类型是 method。而如果采用原生的 C 实现,那么函数和方法的类型都是 builtin_function_or_method。

关于方法,等我们介绍类的时候再说,先来看看函数。

所以函数分为两种:

  • Python 实现的函数,在底层由 PyFunctionObject 结构体实例表示,其类型对象 <class 'function'> 在底层由 PyFunction_Type 表示。

  • C 实现的函数(还有方法),在底层由 PyCFunctionObject 结构体实例表示,其类型对象 <class 'builtin_function_or_method'> 在底层由 PyCFunction_Type 表示。


像我们使用 def 关键字定义的就是 Python 实现的函数,而内置函数则是 C 实现的函数,它们在底层对应不同的结构,因为 C 实现的函数可以有更快的执行方式。



函数的调用



我们来调用一个函数,看看它的字节码是怎样的。

import dis 

code_string = """
def foo(a, b):
    return a + b

foo(1, 2)
"""

dis.dis(compile(code_string, "<file>""exec"))

字节码指令如下:

  0 RESUME                   0
  # 加载 PyCodeObject 对象,压入运行时栈
  2 LOAD_CONST               0 (<code object foo at 0x7f...>)
  # 从栈顶弹出 PyCodeObject 对象,构建函数
  4 MAKE_FUNCTION            0
  # 将符号 foo 和函数对象绑定起来,存储在名字空间中
  6 STORE_NAME               0 (foo)

  8 PUSH_NULL
  # 加载全局变量 foo,压入运行时栈
 10 LOAD_NAME                0 (foo)
  # 加载常量 1,压入运行时栈
 12 LOAD_CONST               1 (1)
  # 加载常量 2,压入运行时栈
 14 LOAD_CONST               2 (2)
  # 弹出 foo 和参数,进行调用
  # 指令参数 2,表示给调用的函数传递了两个参数
  # 函数调用结束后,将返回值压入栈中
 16 CALL                     2
  # 因为没有用变量保存,所以从栈顶弹出返回值并丢弃
 24 POP_TOP
  # 隐式的 return None
 26 RETURN_CONST             3 (None)
  
  # 函数内部逻辑对应的字节码,比较简单,就不说了
Disassembly of <code object foo at 0x7f6...>:
  0 RESUME                   0

  2 LOAD_FAST                0 (a)
  4 LOAD_FAST                1 (b)
  6 BINARY_OP                0 (+)
 10 RETURN_VALUE

我们看到函数调用使用的是 CALL 指令,那么这个指令都做了哪些事情呢?

TARGET(CALL) {
    // ...
    // 运行时栈从栈底到栈顶的元素分别是:NULL, 函数, 参数1, 参数 2, ...
    // 至于为啥会有一个 NULL,我们再看一下刚才的字节码指令就明白了
    // 在 LOAD_NAME 将函数对象的指针压入运行时栈之前,先执行了 PUSH_NULL
    // 所以栈底元素是 NULL,不过问题又来了,为啥要往栈里面压入一个 NULL 呢
    // PUSH_NULL 这个指令我们之前也见过,只不过当时没有解释
    // 它是干嘛的,接下来你就会明白

    // oparg 表示给函数传递的参数的个数,所以 args 指向第一个参数
    PyObject **args = (stack_pointer - oparg);
    // 等价于 *(args - 1),显然这是函数
    PyObject *callable = stack_pointer[-(1 + oparg)];
    // *(args - 2) 毫无疑问就是栈底元素 NULL
    // 但它却被赋值为 method,难道和方法有关吗?
    PyObject *method = stack_pointer[-(2 + oparg)];
    PyObject *res;  // 返回值
    #line 2653 "Python/bytecodes.c"
    // 如果 method 不为 NULL,说明执行的不是普通的函数,而是方法
    // 所谓方法其实就是将函数和 self 绑定起来的结果
    int is_meth = method != NULL;
    int total_args = oparg;
    // 总之现在我们明白为什么要压入一个 NULL 了,就是为了和方法调用保持统一
    // 如果调用的是方法,那么栈里的元素就是:函数, self, 参数1, 参数2, ...
    // 方法是对函数和 self 的绑定,调用方法本质上还是在调用函数
    // 只不过调用的时候,会自动传递 self,举个例子
    /* 
     * class A:
     *     def foo(self):
     *         pass
     *
     * a = A()
     */

    // 如果是 A.foo,那么拿到的就是普通的函数
    // 因为函数定义在类里面,所以 A.foo 也叫类的成员函数,但它依旧是一个普通的函数
    // 如果是 a.foo,那么拿到的就是方法,它会将 A.foo 和实例对象 a 自身绑定起来
    // 调用方法时会自动传递 self,所以 a.foo() 本质上就是 A.foo(a)
    if (is_meth) {  // 当 is_meth 为真时
        callable = method;  // method 才是要调用的 callable
        args--;  // 此时 self 变成了真正意义上的第一个参数,因此 args--
        total_args++;  // 参数个数加 1,因此 total_args++
    }
    // 通过 PUSH_NULL,可以让函数和方法的调用对应同一个指令
    // 当然,即使不考虑方法,提前 PUSH 一个 NULL 在逻辑上也是正确的
    // 因为任何函数都有返回值,执行完之后要设置在栈顶的位置
    // 而一开始 PUSH 的 NULL 正好为返回值预留了空间
    
    // ...

    // 如果调用的函数,那么栈里的元素是:NULL, 函数, 参数1, 参数2, ...
    // 如果调用的方法,那么栈里的元素是:函数, self, 参数1, 参数2, ...
    // 但对于方法而言,栈里的元素还有一种情况:NULL, 方法, 参数1, 参数2, ...
    // 对于这种情况,要将方法里面的函数和 self 提取出来
    // 所以当 is_meth 为 0,但 callable 的类型是 <class 'method'> 时
    if (!is_meth && Py_TYPE(callable) == &PyMethod_Type) {
        is_meth = 1;  // 将 is_meth 设置为 1
        args--;       // args 依旧向前移动一个位置
        total_args++; // 参数总个数加 1
        // 获取方法里面的实例对象
        PyObject *self = ((PyMethodObject *)callable)->im_self;
        // args 向前移动一个位置之后,它指向了目前方法所在的位置
        // 将该位置的值换成 self
        args[0] = Py_NewRef(self);
        // 获取方法里面的函数
        method = ((PyMethodObject *)callable)->im_func;
        // 将 args 的前一个位置的值设置成函数
        args[-1] = Py_NewRef(method);
        Py_DECREF(callable);
        callable = method;
        // 所以之前栈里的元素是:NULL, 方法, 参数1, 参数2, ...
        // args 之前也指向`参数1`,但在 args-- 之后,便指向了`方法`
        // 等到将 args[0] 设置成 self,将 args[-1] 设置成函数之后
        // 栈里的元素就变成了:函数, self, 参数1, 参数2, ...
    }
    // 到这里为止,不管是调用函数还是调用方法,逻辑都变得统一了
    // 此时变量 callable 指向实际要调用的函数
    // args 指向第一个参数,total_args 表示参数的个数
    int positional_args = total_args - KWNAMES_LEN();
    // 函数在初始化时,它的 vectorcall 字段会被设置为 _PyFunction_Vectorcall
    // 所以对于函数来讲,下面这个条件是成立的,因此可以被内联
    if (Py_TYPE(callable) == &PyFunction_Type &&
        tstate->interp->eval_frame == NULL &&
        ((PyFunctionObject *)callable)->vectorcall == _PyFunction_Vectorcall)
    {
        // 获取 co_flags
        int code_flags = ((PyCodeObject*)PyFunction_GET_CODE(callable))->co_flags;
        // 如果是函数的 PyCodeObject,那么 local 名字空间指定为 NULL
        // 因为局部变量不是从 local 名字空间中加载的,而是静态访问的
        PyObject *locals = code_flags & CO_OPTIMIZED ? NULL : \
                Py_NewRef(PyFunction_GET_GLOBALS(callable));
        // 在当前栈帧之上创建新的栈帧,初始化相关字段
        // 然后推入到虚拟机为其准备的 C Stack 中
        _PyInterpreterFrame *new_frame = _PyEvalFramePushAndInit(
            tstate, (PyFunctionObject *)callable, locals,
            args, positional_args, kwnames
        );
        kwnames = NULL;
        // 将运行时栈清空
        STACK_SHRINK(oparg + 2);
        if (new_frame == NULL) {
            goto error;
        }
        JUMPBY(INLINE_CACHE_ENTRIES_CALL);
        frame->return_offset = 0;
        DISPATCH_INLINED(new_frame);
    }
    // 到这里 callable 不是一个普通的 Python 函数,但它支持 vector 协议
    // 进行调用
    res = PyObject_Vectorcall(
        callable, args,
        positional_args | PY_VECTORCALL_ARGUMENTS_OFFSET,
        kwnames);
    // ...
    kwnames = NULL;
    assert((res != NULL) ^ (_PyErr_Occurred(tstate) != NULL));
    Py_DECREF(callable);
    for (int i = 0; i < total_args; i++) {
        Py_DECREF(args[i]);
    }
    if (res == NULL) { STACK_SHRINK(oparg); goto pop_2_error; }
    #line 3790 "Python/generated_cases.c.h"
    STACK_SHRINK(oparg);
    STACK_SHRINK(1);
    stack_pointer[-1] = res;
    next_instr += 3;
    CHECK_EVAL_BREAKER();
    DISPATCH();
}

当调用函数时,会执行 _PyFunction_Vectorcall,否则执行 PyObject_Vectorcall。

以上就是函数的调用逻辑,然后再补充一点,我们说 PyFrameObject 是根据 PyCodeObject 创建的,而 PyFunctionObject 也是根据 PyCodeObject 创建的,那么 PyFrameObject 和 PyFunctionObject 之间有啥关系呢?

如果把 PyCodeObject 比喻成妹子的话,那么 PyFunctionObject 就是妹子的备胎,PyFrameObject 就是妹子的心上人。其实在栈帧中执行指令的时候,PyFunctionObject 的影响就已经消失了。

也就是说,最终是 PyFrameObject 对象和 PyCodeObject 对象两者如胶似漆,跟 PyFunctionObject 对象之间没有关系,所以 PyFunctionObject 辛苦一场,实际上是为别人做了嫁衣。PyFunctionObject 主要是对 PyCodeObject 和 global 名字空间的一种打包和运输方式。

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