您的位置:首页 > 编程语言 > Python开发

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

2016-05-05 12:36 661 查看
【这篇文章所描述的 Python 版本是 3.x,更确切地说,是 CPython 3.3 alpha。】

在 Python 中,可调用对象 (callable) 的概念是十分基本的。当我们说什么东西是“可调用的”,马上可以联想到的显而易见的答案便是函数。无论是用户定义的函数 (你所编写的) 还是内置的函数 (经常是在 CPython 解析器内由 C 实现的),他们总是用来被调用的,不是么?

当然,还有方法也可以调用,但他们仅仅是被限制在对象中的特殊函数而已,没什么有趣的地方。还有什么可以被调用呢?你可能知道,也可能不知道,只要一个对象所属的类定义了
__call__
魔术方法,它也是可以被调用的。所以对象可以像函数那样使用。再深入思考一点,类也是可以被调用的。终究,我们是这样创建新的对象的:

class Joe:
... [contents of class]

joe = Joe()


在这里,我们“调用”了
Joe
来创建新的实例。所以说类也可以像函数那样使用!

可以证明,所有这些概念都很漂亮地在 CPython 被实现。在 Python 中,一切皆对象,包括我们在前面的段落中提到的每一个东西 (用户定义和内置函数、方法、对象、类)。所有这些调用都是由一个单一的机制来完成的。这一机制十分优雅,并且一点都不难理解,所以这很值得我们去了解。不过首先我们从头开始。


编译调用

CPython 经过两个主要的步骤来执行我们的程序:

Python 源代码被编译为字节码。
一个虚拟机使用一系列的内置对象和模块来执行这些字节码。

在这一节中,我会粗略地概括一下第一步中如何处理一个调用。我不会深入这些细节,而且他们也不是我想在这篇文章中关注的真正有趣的部分。如果你想了解更多 Python 代码在编译器中经历的流程,可以阅读 这篇文章
简单地来说,Python 编译器将表达式中的所有类似
(参数 …)
的结构都识别为一个调用 [1] 。这个操作的
AST 节点叫
Call
,编译器通过
Python/compile.c
文件中的
compiler_call
函数来生成
Call
对应的代码。在大多数情况下会生成
CALL_FUNCTION
字节码指令。它也有一些变种,例如含有“星号参数”——形如
func(a, b, *args)
,有一个专门的指令
CALL_FUNCTION_VAR
,但这些都不是我们文章所关注的,所以就忽略掉好了,它们仅仅是这个主题的一些小变种而已。


CALL_FUNCTION

于是
CALL_FUNCTION
就是我们这儿所关注的指令。这是 它做了什么

CALL_FUNCTION(argc)
调用一个函数。
argc
的低字节描述了定位参数 (positional parameters) 的数量,高字节则是关键字参数 (keyword parameters) 的数量。在栈中,操作码首先找到关键字参数。对于每个关键字参数,值在键的上面。而定位参数则在关键词参数的下面,其中最右边的参数在最上面。在所有参数下面,是要被调用的函数对象。将所有的函数参数和函数本身出栈,并将返回值压入栈。

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 里的优化

文章的主要部分在前面那个小节已经讲完了,所以这一部分是选读的。之前说过,我觉得这些内容很有意思,它展示了一些你可能并不认为是对象但事实上却是对象的东西。
我之前提到过,我们对于所有的
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'>


这里的
if
还有一个附加条件——传入函数的关键词参数数量为0。如果这个函数不接受任何参数 (在函数创建时以
METH_NOARGS
标志标明)
或仅仅一个对象参数 (
METH_0
标志),
call_function
就不需要通过正常的参数打包流程而可以直接调用函数指针。为了搞清楚这是如何实现的,我高度推荐你读一读 文档这个部分 关于
PyCFunction
METH_
标志的介绍。
下面,还有一个对 Python 写的类方法的特殊处理:

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


PyMethod
是一个用于表示 有界方法 (bound
methods) 的内部对象。方法的特殊之处在于它还带有一个所在对象的引用。
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
上,顺便,包括含有关键词参数的 PyCFunction 对象。一个不寻常的事实是,对于那些既接受关键词参数又接受定位参数的 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 虚拟机是一个 栈机器
[3]在 C 代码可能结束调用 Python 代码的地方需要使用
Py_EnterRecursiveCall
来让 CPython 保持对递归层级的跟踪,并在递归过深时跳出。注意,用
C 写的函数并不需要遵守这个递归限制。这也是为什么
do_call
的特殊情况
PyCFunction
先于调用
PyObject_Call
[4]这里的“属性”我表示的是一个结构体的字段。如果你对于 Python C 扩展的定义方式完全不熟悉,可以看看 这个页面
[5]当我说 一切 皆对象时,我的意思就是它。你也许会觉得对象是你定义的类的实例。然而,深入到 C 一级,CPython 如你一样创建和耍弄许许多多的对象。类型 (类)、内置对象、函数、模块,所有这些都表现为对象。
[6]这个例子只能在 Python 3.3 中运行,因为
split
sep
这个关键词参数是在这个版本中新加的。在之前版本的
Python 中
split
仅仅接受定位参数。
from: http://pycoders-weekly-chinese.readthedocs.io/en/latest/issue6/python-internals-how-callables-work.html
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: