Python 内部:可调用对象是如何工作的
【這篇文章所描述的 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 編譯器將表達式中的所有類似?(參數?…)?的結構都識別為一個調用?[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); elsex = 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
總結
以上是生活随笔為你收集整理的Python 内部:可调用对象是如何工作的的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Python-OpenCV 处理视频(三
- 下一篇: Python 阅读书目推荐