楔子

上一篇文章我们说了 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"))

字节码指令如下:

  // 加载 PyCodeObject 对象,压入运行时栈
  0 LOAD_CONST               0 (<code object foo at 0x7f69...>)
  // 加载函数名 foo,压入运行时栈
  2 LOAD_CONST               1 ('foo')
  // 从栈顶弹出函数名和 PyCodeObject 对象,构建函数
  4 MAKE_FUNCTION            0
  // 将符号 foo 和函数对象绑定起来,存储在名字空间中
  6 STORE_NAME               0 (foo)
  // 加载全局变量 foo,压入运行时栈
  8 LOAD_NAME                0 (foo)
  // 加载常量 1,压入运行时栈
 10 LOAD_CONST               2 (1)
  // 加载常量 2,压入运行时栈
 12 LOAD_CONST               3 (2)
  // 弹出 foo 和参数,进行调用,指令参数 2,表示给调用的函数传递了两个参数
  // 函数调用结束后,将返回值压入栈中
 14 CALL_FUNCTION            2
  // 因为没有用变量保存,所以从栈顶弹出返回值并丢弃
 16 POP_TOP
  // 隐式的 return None
 18 LOAD_CONST               4 (None)
 20 RETURN_VALUE
  
  // 函数内部逻辑对应的字节码,比较简单,就不说了
Disassembly of <code object foo at 0x7f69...>:
  0 LOAD_FAST                0 (a)
  2 LOAD_FAST                1 (b)
  4 BINARY_ADD
  6 RETURN_VALUE

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

case TARGET(CALL_FUNCTION): {
    PREDICTED(CALL_FUNCTION);
    PyObject **sp, *res;
    // 指向运行时栈的栈顶
    sp = stack_pointer;
    // 调用函数,将返回值赋值给 res
    // tstate 表示线程状态对象,&sp 是一个三级指针,oparg 表示指令参数
    res = call_function(tstate, &sp, oparg, NULL);
    // 函数执行完毕之后,sp 会指向运行时栈的栈顶
    // 所以再将修改之后的 sp 赋值给 stack_pointer
    stack_pointer = sp;
    // 将 res 压入栈中:*stack_pointer++ = res
    PUSH(res);
    if (res == NULL) {
        goto error;
    }
    DISPATCH();
}

所以函数调用会执行 CALL_FUNCTION 指令,但是函数的核心执行流程是在 call_function 里面,它位于 ceval.c 中,我们来看一下。

Py_LOCAL_INLINE(PyObject *) _Py_HOT_FUNCTION
call_function(PyThreadState *tstate, PyObject ***pp_stack, Py_ssize_t oparg, PyObject *kwnames)
{
    // pp_stack 参数是在 CALL_FUNCTION 指令中传入的栈顶指针的指针
    // 由于栈里面的元素都是 PyObject *,所以栈顶指针 stack_pointer 是 PyObject **
    // 而 pp_stack 是栈顶指针的指针,所以它的类型是 PyObject ***
    // 对于当前运行时栈来说,从栈底到栈顶的元素依次是:函数、参数1、参数2、...、参数n
    // 而 *pp_stack 指向栈顶元素,所以通过 (*pp_stack) - oparg - 1 即可拿到函数指针
    PyObject **pfunc = (*pp_stack) - oparg - 1;
    // 我们在 Python 中定义的 foo 就对应这里的 func 
    // 可能有人好奇,为什么要搞一个三级指针出来,直接传 stack_pointer 不好吗
    // 原因很简单,因为函数执行完毕之后,运行时栈的元素会发生改变
    // 这也意味着 stack_pointer 会发生改变,因为必须把它的指针传进去
    PyObject *func = *pfunc;
    // 两个 PyObject *
    PyObject *x, *w;
    // 通过关键字参数传递的参数个数,对于当前函数来说是 0
    Py_ssize_t nkwargs = (kwnames == NULL) ? 0 : PyTuple_GET_SIZE(kwnames);
    // 通过位置参数参数传递的参数个数,对于当前函数来说是 2
    Py_ssize_t nargs = oparg - nkwargs;
    // 移动栈指针,这里相当于 stack_pointer - oparg
    // 所以在移动之后,stack 会指向第一个参数
    PyObject **stack = (*pp_stack) - nargs - nkwargs;
    
    // 到此函数和参数都已经获取完毕,那么开始调用了,通过调用 C 函数来实现 Python 函数的调用
    // 如果通过 threading.settrace 或 sys.settrace 绑定了追踪函数,那么调用 trace_call_function
    if (tstate->use_tracing) {
        x = trace_call_function(tstate, func, stack, nargs, kwnames);
    }
    // 而我们这里没有绑定追踪函数,所以会调用 _PyObject_Vectorcall
    // 当然啦,trace_call_function 只是一个包装器,它内部依旧调用了 _PyObject_Vectorcall
    // 所谓的追踪函数只是为了在执行时收集一些堆栈信息,用于调试和性能分析
    else {
        x = _PyObject_Vectorcall(func, stack, nargs | PY_VECTORCALL_ARGUMENTS_OFFSET, kwnames);
    }
    // 执行完毕之后,将返回值赋值给 x,而在 CALL_FUNCTION 指令中,有下面一行代码:
    // res = call_function(tstate, &sp, oparg, NULL);
    // 里面的 res 就是这里返回的 x,后续会将 res 压入运行时栈
    // 如果没有接收返回值,那么再执行 POP_TOP 将其从栈顶弹出、丢弃
    // 如果接收了返回值,那么执行 STORE_FAST 将其保存起来
    assert((x != NULL) ^ (_PyErr_Occurred(tstate) != NULL));

    // 在后续将返回值 res 压入运行时栈之前,要先将栈里的函数参数清空
    while ((*pp_stack) > pfunc) {
        w = EXT_POP(*pp_stack);
        Py_DECREF(w);
    }
    // 循环结束之后,栈顶也发生了改变,因此在 CALL_FUNCTION 指令中,还要将 *pp_stack 赋值给 stack_pointer
    // 所以会有一行 stack_pointer = sp,而 sp 就是这里的 *pp_stack
    // 相信你明白在调用 call_function 时为什么要传递三级指针了,因为需要在调用完毕后,外部的 sp 能够被影响
    // 所以必须传递 &sp,即三级指针,当 *pp_stack 在变化时,外部的 sp 也在变化
    // 而 call_function 执行完毕后,sp 会指向新的栈顶,因此再将它赋值给 stack_pointer
    // 然后执行 PUSH(res),也就是 *stack_pointer++ = res,将返回值压入运行时栈
    return x;
}

因此接下来重点就在 _PyObject_Vectorcall 函数上面,它都做了哪些事情呢?

// Include/cpython/abstract.h

static inline PyObject *
_PyObject_Vectorcall(PyObject *callable, PyObject *const *args,
                     size_t nargsf, PyObject *kwnames)
{
    PyObject *res;
    vectorcallfunc func;
    assert(kwnames == NULL || PyTuple_Check(kwnames));
    assert(args != NULL || PyVectorcall_NARGS(nargsf) == 0);
    // 获取对象的 vectorcallfunc
    func = _PyVectorcall_Function(callable);
    // 如果 func 为空,说明对象不支持矢量调用
    if (func == NULL) {
        // 那么执行 tp_call,也就是退化为常规调用
        Py_ssize_t nargs = PyVectorcall_NARGS(nargsf);
        return _PyObject_MakeTpCall(callable, args, nargs, kwnames);
    }
    // 否则执行矢量调用函数
    res = func(callable, args, nargsf, kwnames);
    // 检查返回值的有效性,是否符合 Python 协议
    return _Py_CheckFunctionResult(callable, res, NULL);
}

// 获取对象内部的矢量调用函数
static inline vectorcallfunc
_PyVectorcall_Function(PyObject *callable)
{
    // 获取对象的类型对象
    PyTypeObject *tp = Py_TYPE(callable);
    // 类型对象的 tp_vectorcall_offset
    // 它记录了对象的矢量调用函数相对于对象首地址的偏移量
    Py_ssize_t offset = tp->tp_vectorcall_offset;
    vectorcallfunc ptr;
    // 先判断对象是否支持矢量调用,如果类型对象没有设置 _Py_TPFLAGS_HAVE_VECTORCALL 标志位
    // 即 tp->tp_flags & _Py_TPFLAGS_HAVE_VECTORCALL == 0
    // 说明变量 callable 指向的对象不支持矢量调用,因此返回 NULL,然后退化为常规调用
    if (!PyType_HasFeature(tp, _Py_TPFLAGS_HAVE_VECTORCALL)) {
        return NULL;
    }
    assert(PyCallable_Check(callable));
    assert(offset > 0);
    // 否则说明对象支持矢量调用,从对象的首地址向后偏移 offset 个字节,会得到一个函数指针
    // 这个函数指针符合矢量调用协议,由于我们调用的是 Python 函数,所以它肯定是支持的
    memcpy(&ptr, (char *) callable + offset, sizeof(ptr));
    return ptr;
}

Python 函数在底层对应的结构体是 PyFunctionObject,所以它内部一定有一个字段指向了符合矢量调用协议的函数,该字段便是 vectorcall。在介绍 Python 函数的创建时,我们看到它被赋值为 _PyFunction_Vectorcall。

// Objects/funcobject.c

PyTypeObject PyFunction_Type = {
    PyVarObject_HEAD_INIT(&PyType_Type, 0)
    "function",                                 /* tp_name */
    sizeof(PyFunctionObject),                   /* tp_basicsize */
    0,                                          /* tp_itemsize */
    (destructor)func_dealloc,                   /* tp_dealloc */
    // PyFunctionObject 内部的 vectorcall 字段便是矢量函数指针
    // 而在类型对象 PyFunction_Type 中记录了它相对于 PyFunctionObject 的偏移量
    offsetof(PyFunctionObject, vectorcall),     /* tp_vectorcall_offset */
    0,                                          /* tp_getattr */
    // ...
}

PyObject *
PyFunction_NewWithQualName(PyObject *code, PyObject *globals, PyObject *qualname)
{
    PyFunctionObject *op;
    PyObject *doc, *consts, *module;
    static PyObject *__name__ = NULL;
    // ...
    // 被赋值为 _PyFunction_Vectorcall
    op->vectorcall = _PyFunction_Vectorcall;
    // ...
    _PyObject_GC_TRACK(op);
    return (PyObject *)op;
}

所以最终 _PyObject_Vectorcall 内部会通过 _PyFunction_Vectorcall 来执行 Python 函数,显然执行的关键就落在了 _PyFunction_Vectorcall 上面,看一下它的逻辑。

// Objects/call.c

PyObject *
_PyFunction_Vectorcall(PyObject *func, PyObject* const* stack,
                       size_t nargsf, PyObject *kwnames)
{
    // 获取函数的 PyCodeObject 对象
    PyCodeObject *co = (PyCodeObject *)PyFunction_GET_CODE(func);
    // 获取函数的 global 名字空间
    PyObject *globals = PyFunction_GET_GLOBALS(func);
    // 获取函数的默认值参数
    PyObject *argdefs = PyFunction_GET_DEFAULTS(func);
    PyObject *kwdefs, *closure, *name, *qualname;
    PyObject **d;
    Py_ssize_t nkwargs = (kwnames == NULL) ? 0 : PyTuple_GET_SIZE(kwnames);
    Py_ssize_t nd;

    assert(PyFunction_Check(func));
    // 获取实际的参数个数
    Py_ssize_t nargs = PyVectorcall_NARGS(nargsf);
    assert(nargs >= 0);
    assert(kwnames == NULL || PyTuple_CheckExact(kwnames));
    assert((nargs == 0 && nkwargs == 0) || stack != NULL);
    
    // 如果没有仅限位置参数、没有关键字参数、没有闭包,那么走快速通道
    if (co->co_kwonlyargcount == 0 && nkwargs == 0 &&
        (co->co_flags & ~PyCF_MASK) == (CO_OPTIMIZED | CO_NEWLOCALS | CO_NOFREE))
    {   
        // 如果参数没有默认值,co_argcount 和传递的位置参数相等
        // 那么执行 function_code_fastcall
        if (argdefs == NULL && co->co_argcount == nargs) {
            return function_code_fastcall(co, stack, nargs, globals);
        }
        // 如果参数有默认值,但当参数和个数和默认值个数相等时(此时外界一个参数都不传)
        // 那么也会执行 function_code_fastcall
        else if (nargs == 0 && argdefs != NULL
                 && co->co_argcount == PyTuple_GET_SIZE(argdefs)) {
            stack = _PyTuple_ITEMS(argdefs);
            return function_code_fastcall(co, stack, PyTuple_GET_SIZE(argdefs),
                                          globals);
        }
        // 所以 function_code_fastcall 对应的便是快速通道,既然是快速通道,那么自然是要求的
        // 首先定义函数时,不可以出现 / 和 *,比如像 def foo(a, b, /, c, *, d) 这种
        // 也就是要这么定义:def foo(a, b, c, d),不能有任何多余的东西
        // 然后传参的时候,也必须都通过位置参数传递,比如 foo(1, 2, 3, 4),这种情况下会走快速通道
        // 第二种走快速通道的方式是,所有参数都有默认值,比如 def foo(a=1, b=2, c=3, d=4)
        // 然后调用时不额外传值,也就是让所有参数都使用默认值
        
        // 以上两种情况都会执行快速通道,而在 function_code_fastcall
    }
    
    // 否则执行通用逻辑
    // 获取仅限默认值参数、闭包、函数名等
    kwdefs = PyFunction_GET_KW_DEFAULTS(func);
    closure = PyFunction_GET_CLOSURE(func);
    name = ((PyFunctionObject *)func) -> func_name;
    qualname = ((PyFunctionObject *)func) -> func_qualname;

    if (argdefs != NULL) {
        d = _PyTuple_ITEMS(argdefs);
        nd = PyTuple_GET_SIZE(argdefs);
    }
    else {
        d = NULL;
        nd = 0;
    }
    // 执行通用逻辑 _PyEval_EvalCodeWithName
    return _PyEval_EvalCodeWithName((PyObject*)co, globals, (PyObject *)NULL,
                                    stack, nargs,
                                    nkwargs ? _PyTuple_ITEMS(kwnames) : NULL,
                                    stack + nargs,
                                    nkwargs, 1,
                                    d, (int)nd, kwdefs,
                                    closure, name, qualname);
}

无论是快速通道 function_code_fastcall,还是通用通道 _PyEval_EvalCodeWithName,它们内部做的事情都是一样的。

  • 调用 _PyFrame_New_NoTrack 函数创建栈帧,并初始化内部字段。
  • 栈帧创建完毕之后,里面的字段都是初始值,所以还要基于函数的参数信息修改栈帧字段(主要是修改 f_localsplus)。
  • 栈帧字段设置完毕之后,调用 PyEval_EvalFrameEx 函数,在栈帧中执行字节码。

而这两者的区别就在于第二步,如果走快速通道,那么它的参数处理会非常简单,我们看一下 function_code_fastcall 的逻辑。

// Objects/call.c

static PyObject* _Py_HOT_FUNCTION
function_code_fastcall(PyCodeObject *co, PyObject *const *args, Py_ssize_t nargs,
                       PyObject *globals)
{
    PyFrameObject *f;
    PyThreadState *tstate = _PyThreadState_GET();
    PyObject **fastlocals;
    Py_ssize_t i;
    PyObject *result;

    assert(globals != NULL);
    assert(tstate != NULL);
    // 基于 PyCodeObject 对象、全局名字空间创建栈桢
    f = _PyFrame_New_NoTrack(tstate, co, globals, NULL);
    if (f == NULL) {
        return NULL;
    }
    // 获取 f_localsplus,它用于局部变量、cell 变量、free 变量、运行时栈
    // 不过这里不会存在 cell 变量和 free 变量,否则无法进入快速通道
    fastlocals = f->f_localsplus;
    // 将传递的位置参数拷贝到 f_localsplus 的第一段内存(局部变量)
    for (i = 0; i < nargs; i++) {
        Py_INCREF(*args);
        fastlocals[i] = *args++;
    }
    // 调用 PyEval_EvalFrameEx,然后执行帧评估函数 _PyEval_EvalFrameDefault
    result = PyEval_EvalFrameEx(f,0);

    if (Py_REFCNT(f) > 1) {
        Py_DECREF(f);
        _PyObject_GC_TRACK(f);
    }
    else {
        ++tstate->recursion_depth;
        Py_DECREF(f);
        --tstate->recursion_depth;
    }
    return result;
}

所以快速通道的整个过程非常简单,如果是走通用通道 _PyEval_EvalCodeWithName,它的逻辑也是一样的,只是在处理参数处理方面要复杂很多。比如要考虑位置参数、关键字参数、*args、**kwargs、哪些参数使用默认值、哪些参数不使用默认值等等。但快速通道和通用通道做的事情是一样的,都是先为调用的函数创建栈桢、然后设置栈桢字段、最后调用帧评估函数。

所以当调用一个 Python 函数时,底层 C 函数的调用链路就很清晰了。

因此我们看到,总共有两条途径,这两条途径的区别就在于一个参数复杂、一个参数不复杂。但最终两者是殊途同归的,都会走到 PyEval_EvalFrameEx 那里,然后在新的栈帧中执行字节码。

小结

以上就是整个函数的调用逻辑,还是非常清晰的,至于这两条执行途径的具体细节,以及参数是如何解析的,我们下一篇文章再说。

另外再补充一点,我们说 PyFrameObject 是根据 PyCodeObject 创建的,而 PyFunctionObject 也是根据 PyCodeObject 创建的,那么 PyFrameObject 和 PyFunctionObject 之间有啥关系呢?

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

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


 

欢迎大家关注我的公众号:古明地觉的编程教室。

如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。