古明地觉的编程教室 14小时前
函数在底层是如何调用的?
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文深入探讨了Python函数调用的底层实现机制。文章首先区分了Python函数和C函数,然后通过字节码分析展示了函数调用的过程。核心内容包括CALL指令的工作原理,以及Python如何处理函数和方法调用的统一性。最后,文章阐述了PyFrameObject、PyCodeObject和PyFunctionObject之间的关系,揭示了函数对象在函数调用中的作用。

🔍 Python 函数分为两种:Python实现的函数(由PyFunctionObject表示,类型为function)和C实现的函数(由PyCFunctionObject表示,类型为builtin_function_or_method)。

💡 函数调用通过CALL指令实现,该指令负责从运行时栈中获取函数和参数,并执行函数。CALL指令需要考虑函数和方法的统一调用,包括在栈中预留NULL,以及处理方法中的self参数。

⚙️ CALL指令会根据调用的是函数还是方法,调整参数的个数和顺序,确保调用逻辑的统一性。对于方法,会将self作为第一个参数传入,并调整参数个数。

🔄 PyFrameObject根据PyCodeObject创建,PyFunctionObject则用于打包和运输PyCodeObject和全局命名空间。在函数执行过程中,PyFrameObject和PyCodeObject紧密协作,而PyFunctionObject的作用则逐渐消失。

原创 古明地觉 2024-11-19 10:29 北京


楔子


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

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



PyCFunctionObject


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

def foo():    passclass A:    def foo(self):        passprint(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。

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

所以函数分为两种:

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



函数的调用


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

import dis code_string = """def foo(a, b):    return a + bfoo(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 名字空间的一种打包和运输方式。

阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

Python 函数调用 字节码 PyFrameObject PyFunctionObject
相关文章