作者: Eli Bendersky
原文链接:https://eli.thegreenplace.net/2012/03/23/python-internals-how-callables-work/
本文中描述的Python版本是3.x,更确切地——Python的3.3 alpha发布。
Python里可调用(callable)概念是基本的。在考虑什么可以被“调用”时,最直接的答案是函数。不管是用户定义函数(你写的),还是内置函数(最有可能是在CPython解释器里用C实现),函数意味着被调用,不是吗?
嗯,还有方法,但它们不是特别有趣,因为它们只是绑定到对象的特殊函数。还有别的什么可以调用?你可能或者可能不熟悉调用对象的能力,只要它们属于定义了__call__魔法方法的类。因此,对象可以充当函数。进一步考虑一下,类也是可调用的。毕竟,我们是这样创建新对象的:
class Joe:
... [contents of class]
joe = Joe()
这里,我们“调用”Joe来创建一个新实例。因此类也可以作为函数!
事实证明,所有这些概念在CPython实现中很好地结合了在一起。Python里一切都是对象,包括前面章节里描述的每个实体(用户及内置函数,方法,对象,类)。所有这些调用由一个机制服务。这个机制是优雅且不难理解,因此值得了解它。让我们从头开始。
编译调用
CPython在两个主要步骤里执行我们的程序:
- Python源代码被编译为字节码。
- 一个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不经过通常的实参打包,可以直接调用底下的函数指针。为了理解这如何可能,强烈建议文档关于PyCFunction与METH_标记的这个部分。
接下来,是一些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对象及类的意义。
这是一个内部的简化——()扮演其他角色,像类定义(用于列出基类),函数定义(用于列出实参),修饰符等——这些都不在表达式里。我还故意忽略了表达式生成器。 |
CPython VM是一台栈机器。 |
在C代码可能以调用Python代码结束时,需要Py_EnterRecursiveCall,以允许CPython追踪其递归程度,并在太深时摆脱出来。注意用C编写的函数无需遵守这个递归限制。这是为什么在调用PyObject Call之前,do_call处理PyCFunction特殊情形的原因。 |
这里的“属性”指的是结构字段(有时在文档里也称为“槽”)。如果你完全不熟悉Python C扩展定义的方式,参考这个页面。 |
在我说一切是对象时,我是认真的。你可能认为对象是你定义类的实例。不过,深入到C层面,CPython为你创建并应付许多对象。类型(类),内置对象,函数,模块——这些都由对象表示。 |
这个例子仅运行在Python 3.3上,因为对split,sep关键字实参是新引入这个版本的。在之前的Python版本中split仅接受位置实参。 |