Python内部:可调用对象如何工作

作者: Eli Bendersky

原文链接:https://eli.thegreenplace.net/2012/03/23/python-internals-how-callables-work/

本文中描述的Python版本是3.x,更确切地——Python3.3 alpha发布。

Python里可调用(callable)概念是基本的。在考虑什么可以被“调用”时,最直接的答案是函数。不管是用户定义函数(你写的),还是内置函数(最有可能是在CPython解释器里用C实现),函数意味着被调用,不是吗?

嗯,还有方法,但它们不是特别有趣,因为它们只是绑定到对象的特殊函数。还有别的什么可以调用?你可能或者可能不熟悉调用对象的能力,只要它们属于定义了__call__魔法方法的类。因此,对象可以充当函数。进一步考虑一下,类也是可调用的。毕竟,我们是这样创建新对象的:

class Joe:

  ... [contents of class]

 

joe = Joe()

这里,我们“调用”Joe来创建一个新实例。因此类也可以作为函数!

事实证明,所有这些概念在CPython实现中很好地结合了在一起。Python里一切都是对象,包括前面章节里描述的每个实体(用户及内置函数,方法,对象,类)。所有这些调用由一个机制服务。这个机制是优雅且不难理解,因此值得了解它。让我们从头开始。

编译调用

CPython在两个主要步骤里执行我们的程序:

  1. Python源代码被编译为字节码。
  2. 一个VM执行这个字节码,使用内置对象和模块的工具箱来帮助它完成工作。

在本节,我将快速概述第一步如何应用于调用。我不会太深入,因为这些细节不是我想在本文里关注的真正有趣的部分。如果你想更多地了解Python源代码在编译器中所经历的流程,阅读这个

简单地说,Python编译器将一个表达式内,任何后跟(arguments…)的东西识别为调用【1】。对此的AST节点是Call。编译器在Python/compile.c里的函数compiler_call中为Call发布代码。在大多数情形里,将发布CALL_FUNCTION字节码。在本文中,我将忽略一些变形。例如,如果调用有“star args”——func(a, b, *args),有处理这——CALL_FUNCTION_VAR的特殊函数。它和其他特殊指令只是同一个主题的不同变形。

CALL_FUNCTION

因此CALL_FUNCTION是我们准备在这里关注的指令。这就是它的作用

CALL_FUNCTION(argc)

调用一个函数。Argc的低位字节表示位置参数的个数,高位字节表示关键字参数的个数。在栈上,操作码首先找到关键字参数。对每个关键字实参,值在这个键之上。关键字参数之下,位置参数在栈上,最右边的参数在顶部。这些参数之下,栈上是要调用的函数对象。弹出所有的函数,以及函数本身,并压入返回值。

CPython字节码由Python/ceval.c里的巨函数PyEval_EvalFrameEx求值。这个函数有点吓人,但它只不过是操作码的一个花哨的调度程序。它从给定函数框的代码对象读取指令并执行它们。例如,下面是CALL_FUNCTION的处理句柄(清理了一点,删除了记录与计时的宏):

TARGET(CALL_FUNCTION)

{

    PyObject **sp;

    sp = stack_pointer;

    x = call_function(&sp, oparg);

    stack_pointer = sp;

    PUSH(x);

    if (x != NULL)

        DISPATCH();

    break;

}

不太坏——它实际上可读性非常高。Call_function进行实际的调用(我们将稍微调查一下),oparg是指令的数值实参,而stack_pointer指向栈顶【2】。Call_function返回的值压回栈上,DISPATCH只是调用下一条指令的某个宏魔法。

Call_function也在Python/ceval.c里。它实现了指令的实际功能。80行它不是很长,但足以让我不能完整地把它贴在这里。相反,我将解释一般性的流程并将小片段贴到相关的地方;欢迎你在你喜欢的编辑器里跟踪代码。

任何调用只是一个对象调用

理解Python里调用如何工作最重要的第一步是忽略call_function的大部分工作。是的,我是认真的。这个函数里绝大部分代码处理各种常见情形的优化。可以删除它,而不影响解析器的正确性,仅是其性能。如果我们暂时忽略所有的优化,call_function所做的就是从CALL_FUNCTION的单个实参里解码出实参个数与关键字实参个数,转发给do_call。后面我们将回到优化,因为它们是有趣的,但目前,让我们看一下核心流程是什么。

Do_call从栈将所有实参载入PyObject对象(一个用于位置实参的元组、一个用于关键字实参的字典),进行自己的一点追踪和优化,但最终调用PyObject_Call

PyObject_Call是一个超级重要的函数。它还可以用于Python C API中的扩展。下面就是,带有它所有的荣耀:

PyObject *

PyObject_Call(PyObject *func, PyObject *arg, PyObject *kw)

{

    ternaryfunc call;

 

    if ((call = func->ob_type->tp_call) != NULL) {

        PyObject *result;

        if (Py_EnterRecursiveCall(" while calling a Python object"))

            return NULL;

        result = (*call)(func, arg, kw);

        Py_LeaveRecursiveCall();

        if (result == NULL && !PyErr_Occurred())

            PyErr_SetString(

                PyExc_SystemError,

                "NULL result without error in PyObject_Call");

        return result;

    }

    PyErr_Format(PyExc_TypeError, "'%.200s' object is not callable",

                 func->ob_type->tp_name);

    return NULL;

}

深度递归保护以及错误处理放一边【3】,PyObject_Call提取对象序列的tp_call属性【4】并调用它。这是可能的,因为tp_call保存了一个函数指针。

让它一边凉快去一会儿。就是这样。忽略所有美妙的优化,在Python里所有调用归结为:

  • Python里一切是对象【5】:
  • 每个对象有一个类型;对象的类型决定了这个对象可以处理的东西。
  • 在调用这个对象时,调用其类型的tp_call属性。

作为Python的一个使用者,在你希望你的对象可调用时,你仅需要直接与tp_call交互。如果在Python里你定义了类,出于这个目的,你必须实现__call__方法。这个方法被CPython直接映射到tp_call。如果你把你的类定义为C扩展,你必须手动在你的类的类型对象里对tp_call赋值。

但记住,类本身被“调用”来创建新对象,因此tp_call也在这里扮演一个角色。甚至更为重要的,当你定义了一个类时,也涉及一个调用——类的元类。这是一个有趣的话题,我将在将来的文章里讨论。

额外学分:CALL_FUNCTION中的优化

这部分是可选的,因为本文的主要观点在前面章节已经给出。也就是说,我认为这个材料是有趣的,因为它提供了一些你通常不认为是对象的东西,实际上在Python里是对象的例子。

正如我之前提到的,对每个CALL_FUNCTION我们可以只使用PyObject_Call,然后完成。事实上,在这可能是过度的常见情形里,进行一些优化是合理的。PyObject_Call是一个非常通用的函数,需要其所有实参在特殊的元组及字典对象中(分别用于位置及关键字实参)。这些实参需要从栈获取,放入PyObject_Call期望的容器里。在某些常见的情形里,我们可以避免这个开销,这正是call_function中的优化。

Call_function处理的第一个特殊情形是:

/* Always dispatch PyCFunction first, because these are

   presumed to be the most frequent callable object.

*/

if (PyCFunction_Check(func) && nk == 0) {

这处理builtin_function_or_method类型的对象(由C实现里的PyCFunction类型表示)。在Python里有很多这样的东西,就像上面注释指出的。所有以C实现的函数与方法,不管在CPython解释器,还是在C扩展里,都归在这个类别。例如:

>>> type(chr)

<class 'builtin_function_or_method'>

>>> type("".split)

<class 'builtin_function_or_method'>

>>> from pickle import dump

>>> type(dump)

<class 'builtin_function_or_method'>

还有一个附加条件,如果传递给函数的关键字实参数量为零。这允许一些重要的优化。如果所讨论的函数不接受实参(在该函数创建时由METH_NOARGS标记),或者只接受一个实参(METH_0标记),call_function不经过通常的实参打包,可以直接调用底下的函数指针。为了理解这如何可能,强烈建议文档关于PyCFunctionMETH_标记的这个部分

接下来,是一些Python写的对类方法的特殊处理:

else {

  if (PyMethod_Check(func) && PyMethod_GET_SELF(func) != NULL) {

PyMethod是用于表示被绑定方法bound method)的内部对象。关于方法的特殊之处是,它们携带它们绑定对象的一个引用。Call_function提取这个对象,并把它放在栈是,为接下来做准备。

下面是调用代码的余下部分(在它之后在call_object里只是一些栈清理):

if (PyFunction_Check(func))

    x = fast_function(func, pp_stack, n, na, nk);

else

    x = do_call(func, pp_stack, na, nk);

我们已经遇到过do_call——它实现了最一般形式的调用。不过,还有一个优化——如果func是一个PyFunction(用于内部表示在Python代码里定义函数的对象),采取另一个路径——fast_function

为了理解fast_function做什么,首先考虑在执行一个Python函数时发生了什么是重要的。简单地说,其代码对象被求值(使用PyEval_EvalCodeEx本身)。这个代码期望其实参在栈上。因此,在大多数情形里,没有必要将实参打包到容器里,然后再拆包。只要稍加注意,它们可以留在栈上,可以节省许多宝贵的CPU周期。

其他都落到do_call。通过这个方法,这包括了有关键字实参的FyCFunction对象。这个事实的一个奇怪的方面是,不将关键字参数传递给C函数会更有效,因为这些函数要么接受关键字参数,要么只接受位置参数。例如【6】:

$ ~/test/python_src/33/python -m timeit -s's="a;b;c;d;e"' 's.split(";")'

1000000 loops, best of 3: 0.3 usec per loop

$ ~/test/python_src/33/python -m timeit -s's="a;b;c;d;e"' 's.split(sep=";")'

1000000 loops, best of 3: 0.469 usec per loop

这是一个大的差别,但输入非常小。对更大的字符串,差别几乎看不见:

$ ~/test/python_src/33/python -m timeit -s's="a;b;c;d;e"*1000' 's.split(";")'

10000 loops, best of 3: 98.4 usec per loop

$ ~/test/python_src/33/python -m timeit -s's="a;b;c;d;e"*1000' 's.split(sep=";")'

10000 loops, best of 3: 98.7 usec per loop

总结

本文的目的是讨论在Python中可调用的含义,从尽可能低的层次(CPython虚拟机的实现细节)着手处理这个概念。个人的,我发现这个实现非常优雅,因为它将几个概念整合为一个。就如额外学分一节所示,我们通常不视为对象的Python实体——函数与方法+实际上是对象,可以统一的方式处理。正如我承诺的,将来的文章将深入tp_call对创建新Python对象及类的意义。

[1]

这是一个内部的简化——()扮演其他角色,像类定义(用于列出基类),函数定义(用于列出实参),修饰符等——这些都不在表达式里。我还故意忽略了表达式生成器。

[2]

CPython VM是一台栈机器

[3]

在C代码可能以调用Python代码结束时,需要Py_EnterRecursiveCall,以允许CPython追踪其递归程度,并在太深时摆脱出来。注意用C编写的函数无需遵守这个递归限制。这是为什么在调用PyObject Call之前,do_call处理PyCFunction特殊情形的原因。

[4]

这里的“属性”指的是结构字段(有时在文档里也称为“槽”)。如果你完全不熟悉Python C扩展定义的方式,参考这个页面

[5]

在我说一切是对象时,我是认真的。你可能认为对象是你定义类的实例。不过,深入到C层面,CPython为你创建并应付许多对象。类型(类),内置对象,函数,模块——这些都由对象表示。

[6]

这个例子仅运行在Python 3.3上,因为对split,sep关键字实参是新引入这个版本的。在之前的Python版本中split仅接受位置实参。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值