Python 内部:可调用对象是如何工作的
【這篇文章所描述的 Python 版本是 3.x,更確切地說,是 CPython 3.3 alpha?!?/strong>
在 Python 中,可調(diào)用對象 (callable) 的概念是十分基本的。當我們說什么東西是“可調(diào)用的”,馬上可以聯(lián)想到的顯而易見的答案便是函數(shù)。無論是用戶定義的函數(shù) (你所編寫的) 還是內(nèi)置的函數(shù) (經(jīng)常是在 CPython 解析器內(nèi)由 C 實現(xiàn)的),他們總是用來被調(diào)用的,不是么?
當然,還有方法也可以調(diào)用,但他們僅僅是被限制在對象中的特殊函數(shù)而已,沒什么有趣的地方。還有什么可以被調(diào)用呢?你可能知道,也可能不知道,只要一個對象所屬的類定義了?__call__?魔術(shù)方法,它也是可以被調(diào)用的。所以對象可以像函數(shù)那樣使用。再深入思考一點,類也是可以被調(diào)用的。終究,我們是這樣創(chuàng)建新的對象的:
class Joe:... [contents of class]joe = Joe()在這里,我們“調(diào)用”了?Joe?來創(chuàng)建新的實例。所以說類也可以像函數(shù)那樣使用!
可以證明,所有這些概念都很漂亮地在 CPython 被實現(xiàn)。在 Python 中,一切皆對象,包括我們在前面的段落中提到的每一個東西 (用戶定義和內(nèi)置函數(shù)、方法、對象、類)。所有這些調(diào)用都是由一個單一的機制來完成的。這一機制十分優(yōu)雅,并且一點都不難理解,所以這很值得我們?nèi)チ私?。不過首先我們從頭開始。
編譯調(diào)用
CPython 經(jīng)過兩個主要的步驟來執(zhí)行我們的程序:
在這一節(jié)中,我會粗略地概括一下第一步中如何處理一個調(diào)用。我不會深入這些細節(jié),而且他們也不是我想在這篇文章中關(guān)注的真正有趣的部分。如果你想了解更多 Python 代碼在編譯器中經(jīng)歷的流程,可以閱讀?這篇文章?。
簡單地來說,Python 編譯器將表達式中的所有類似?(參數(shù)?…)?的結(jié)構(gòu)都識別為一個調(diào)用?[1]?。這個操作的 AST 節(jié)點叫?Call?,編譯器通過Python/compile.c?文件中的?compiler_call?函數(shù)來生成?Call?對應(yīng)的代碼。在大多數(shù)情況下會生成?CALL_FUNCTION?字節(jié)碼指令。它也有一些變種,例如含有“星號參數(shù)”——形如?func(a,?b,?*args)?,有一個專門的指令?CALL_FUNCTION_VAR?,但這些都不是我們文章所關(guān)注的,所以就忽略掉好了,它們僅僅是這個主題的一些小變種而已。
CALL_FUNCTION
于是?CALL_FUNCTION?就是我們這兒所關(guān)注的指令。這是?它做了什么?:
CALL_FUNCTION(argc)
調(diào)用一個函數(shù)。?argc?的低字節(jié)描述了定位參數(shù) (positional parameters) 的數(shù)量,高字節(jié)則是關(guān)鍵字參數(shù) (keyword parameters) 的數(shù)量。在棧中,操作碼首先找到關(guān)鍵字參數(shù)。對于每個關(guān)鍵字參數(shù),值在鍵的上面。而定位參數(shù)則在關(guān)鍵詞參數(shù)的下面,其中最右邊的參數(shù)在最上面。在所有參數(shù)下面,是要被調(diào)用的函數(shù)對象。將所有的函數(shù)參數(shù)和函數(shù)本身出棧,并將返回值壓入棧。
CPython 的字節(jié)碼由?Python/ceval.c?文件的一個巨大的函數(shù)?PyEval_EvalFrameEx?來執(zhí)行。這個函數(shù)十分恐怖,不過也僅僅是一個特別的操作碼分發(fā)器而已。他從指定幀的代碼對象中讀取指令并執(zhí)行它們。例如說這里是?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?根本沒有真正進行調(diào)用 (我們將在之后細究這件事),?oparg?是指令的數(shù)字參數(shù),stack_pointer?則指向棧頂?[2]?。?call_function?返回的值被壓入棧中,?DISPATCH?僅僅是調(diào)用下一條指令的宏。
call_function?也在?Python/ceval.c?文件。它真正實現(xiàn)了這條指令的功能。它雖然不算很長,但80行也已經(jīng)長到我不可能把它完全貼在這兒了。我將會從總體上解釋這個流程,并貼一些相關(guān)的小代碼片段取而代之。你完全可以在你最喜歡的編輯器中打開這些代碼。
所有的調(diào)用僅僅是對象調(diào)用
要理解調(diào)用過程在 Python 中是如何進行的,最重要的第一步是忽略?call_function?所做的大多數(shù)事情。是的,我就是這個意思。這個函數(shù)最最主要的代碼都是為了對各種情況進行優(yōu)化。完全移除這些對解析器的正確性毫無影響,影響的僅僅是它的性能。如果我們忽略所有的時間優(yōu)化,?call_function?所做的僅僅是從單參數(shù)的?CALL_FUNCTION?指令中解碼參數(shù)和關(guān)鍵詞參數(shù)的數(shù)量,并且將它們轉(zhuǎn)給?do_call?。我們將在后面重新回到這些優(yōu)化因為他們很有意思,不過現(xiàn)在先讓我們看看核心的流程。
do_call?從棧中將參數(shù)加載到?PyObject?對象中 (定位參數(shù)存入一個元組,關(guān)鍵詞對象存入一個字典),做一些跟綜和優(yōu)化,最后調(diào)用?PyObject_Call?。
PyObject_Call?是一個極其重要的函數(shù)。它可以在 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?屬性并且調(diào)用它?[4]?,?tp_call?是一個函數(shù)指針,因此我們可以這樣做。
先讓它這樣一會兒。忽略所有那些精彩的優(yōu)化,?Python 中的所有調(diào)用?都可以濃縮為下面這些內(nèi)容:
- Python 中一切皆對象?[5]?。
- 所有對象都有類型,對象的類型規(guī)定了對象可以做和被做的事情。
- 當一個對象是可被調(diào)用的,它的類型的?tp_call?將被調(diào)用。
作為一個 Python 用戶,你唯一需要直接與?tp_call?進行的交互是在你希望你的對象可以被調(diào)用的時候。當你在 Python 中定義你的類時,你需要實現(xiàn)__call__?方法來達到這一目的。這個方法被 CPython 直接映射到了?tp_call?上。如果你在 C 擴展中定義你的類,你需要自己手動給類對象的?tp_call屬性賦值。
我們回想起類本身也可以被“調(diào)用”以創(chuàng)建新的對象,所以?tp_call?也在這里起到了作用。甚至更加基本地,當你定義一個類時也會產(chǎn)生一次調(diào)用——在類的元類中。這是一個有意思的話題,我將會在未來的文章中討論它。
附加:CALL_FUNCTION 里的優(yōu)化
文章的主要部分在前面那個小節(jié)已經(jīng)講完了,所以這一部分是選讀的。之前說過,我覺得這些內(nèi)容很有意思,它展示了一些你可能并不認為是對象但事實上卻是對象的東西。
我之前提到過,我們對于所有的?CALL_FUNCTION?僅僅需要使用?PyObject_Call?就可以處理。事實上,對一些常見的情況做一些優(yōu)化是很有意義的,對這些情況來說,前面的方法可能過于麻煩了。?PyObject_Call?是一個非常通用的函數(shù),它需要將所有的參數(shù)放入專門的元組和字典對象中 (按順序?qū)?yīng)于定位參數(shù)和關(guān)鍵詞參數(shù))。?PyObject_Call?需要它的調(diào)用者為它從棧中取出所有這些參數(shù),并且存放好。然而在一些常見的情況中,我們可以避免很多這樣的開銷,這正是?call_function?中優(yōu)化的所在。
在?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 實現(xiàn)中表現(xiàn)為 PyCFunction 類型)。正如上面的注釋所說的,Python 里有很多這樣的函數(shù)。所有使用 C 實現(xiàn)的函數(shù),無論是 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?還有一個附加條件——傳入函數(shù)的關(guān)鍵詞參數(shù)數(shù)量為0。如果這個函數(shù)不接受任何參數(shù) (在函數(shù)創(chuàng)建時以?METH_NOARGS?標志標明) 或僅僅一個對象參數(shù) (METH_0?標志),?call_function?就不需要通過正常的參數(shù)打包流程而可以直接調(diào)用函數(shù)指針。為了搞清楚這是如何實現(xiàn)的,我高度推薦你讀一讀?文檔這個部分?關(guān)于?PyCFunction?和?METH_?標志的介紹。
下面,還有一個對 Python 寫的類方法的特殊處理:
else {if (PyMethod_Check(func) && PyMethod_GET_SELF(func) != NULL) {PyMethod?是一個用于表示?有界方法?(bound methods) 的內(nèi)部對象。方法的特殊之處在于它還帶有一個所在對象的引用。?call_function?提取這個對象并且將他放入棧中作為下一步的準備工作。
這是調(diào)用部分的代碼剩下的部分 (在這之后在?call_object?中只有一些清理棧的代碼):
if (PyFunction_Check(func))x = fast_function(func, pp_stack, n, na, nk); elsex = do_call(func, pp_stack, na, nk);我們已經(jīng)見過?do_call?了——它實現(xiàn)了調(diào)用的最通用形式。然而,這里還有一個優(yōu)化——如果?func?是一個?PyFunction?對象 (一個在?內(nèi)部?用于表示使用 Python 代碼定義的函數(shù)的對象),程序選擇了另一條路徑——?fast_function?。
為了理解?fast_function?做了什么,最重要的是首先要考慮在執(zhí)行一個 Python 函數(shù)時發(fā)生了什么。簡單地說,它的代碼對象被執(zhí)行 (也就是PyEval_EvalCodeEx?本身)。這些代碼期望它的參數(shù)已經(jīng)在棧中,因此在大多數(shù)情況下,沒必要將參數(shù)打包到容器中再重新釋放出來。稍稍注意一下,就可以將參數(shù)留在棧中,這樣許多寶貴的 CPU 周期就可以被節(jié)省出來。
剩下的一切最終落回到?do_call?上,順便,包括含有關(guān)鍵詞參數(shù)的 PyCFunction 對象。一個不尋常的事實是,對于那些既接受關(guān)鍵詞參數(shù)又接受定位參數(shù)的 C 函數(shù),不給它們傳遞關(guān)鍵詞參數(shù)要稍稍更高效一些。例如說?[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這是一個巨大的差異,但輸入數(shù)據(jù)很小。對于更大的字符串,這個差異就幾乎沒有了:
$ ~/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總結(jié)?
這篇文章的目的是討論在 Python 中,可調(diào)用對象意味著什么,并且從盡可能最底層的概念——CPython 虛擬機中的實現(xiàn)細節(jié)——來接近它。就我個人來說,我覺得這個實現(xiàn)非常優(yōu)雅,因為它將不同的概念統(tǒng)一到了同一個東西上。在附加部分里我們看到,在 Python 中有些我們常常認為不是對象的東西如函數(shù)和方法,實際上也是對象,并且也可以以相同的統(tǒng)一的方法來處理。我保證了,在以后的文章中我將會深入?tp_call?創(chuàng)建新的 Python 對象和類的內(nèi)容。
| [1] | 這是故意的簡化——?()?同樣可以用作其他用途如類定義 (用以列舉基類)、函數(shù)定義 (列舉參數(shù))、修飾器等等,但它們并不在表達式中。我同樣也故意忽略了生成器表達式。 |
| [2] | CPython 虛擬機是一個?棧機器?。 |
| [3] | 在 C 代碼可能結(jié)束調(diào)用 Python 代碼的地方需要使用?Py_EnterRecursiveCall?來讓 CPython 保持對遞歸層級的跟蹤,并在遞歸過深時跳出。注意,用 C 寫的函數(shù)并不需要遵守這個遞歸限制。這也是為什么?do_call?的特殊情況?PyCFunction?先于調(diào)用?PyObject_Call?。 |
| [4] | 這里的“屬性”我表示的是一個結(jié)構(gòu)體的字段。如果你對于 Python C 擴展的定義方式完全不熟悉,可以看看?這個頁面?。 |
| [5] | 當我說?一切?皆對象時,我的意思就是它。你也許會覺得對象是你定義的類的實例。然而,深入到 C 一級,CPython 如你一樣創(chuàng)建和耍弄許許多多的對象。類型 (類)、內(nèi)置對象、函數(shù)、模塊,所有這些都表現(xiàn)為對象。 |
| [6] | 這個例子只能在 Python 3.3 中運行,因為?split?的?sep?這個關(guān)鍵詞參數(shù)是在這個版本中新加的。在之前版本的 Python 中?split?僅僅接受定位參數(shù)。 |
from:?http://pycoders-weekly-chinese.readthedocs.io/en/latest/issue6/python-internals-how-callables-work.html
總結(jié)
以上是生活随笔為你收集整理的Python 内部:可调用对象是如何工作的的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Python-OpenCV 处理视频(三
- 下一篇: Python 阅读书目推荐