Python 內部:可呼叫物件是如何工作的

garfielder007發表於2016-05-05

【這篇文章所描述的 Python 版本是 3.x,更確切地說,是 CPython 3.3 alpha。】

在 Python 中,可呼叫物件 (callable) 的概念是十分基本的。當我們說什麼東西是“可呼叫的”,馬上可以聯想到的顯而易見的答案便是函式。無論是使用者定義的函式 (你所編寫的) 還是內建的函式 (經常是在 CPython 解析器內由 C 實現的),他們總是用來被呼叫的,不是麼?

當然,還有方法也可以呼叫,但他們僅僅是被限制在物件中的特殊函式而已,沒什麼有趣的地方。還有什麼可以被呼叫呢?你可能知道,也可能不知道,只要一個物件所屬的類定義了 __call__ 魔術方法,它也是可以被呼叫的。所以物件可以像函式那樣使用。再深入思考一點,類也是可以被呼叫的。終究,我們是這樣建立新的物件的:

class Joe:
  ... [contents of class]

  joe = Joe()

在這裡,我們“呼叫”了 Joe 來建立新的例項。所以說類也可以像函式那樣使用!

可以證明,所有這些概念都很漂亮地在 CPython 被實現。在 Python 中,一切皆物件,包括我們在前面的段落中提到的每一個東西 (使用者定義和內建函式、方法、物件、類)。所有這些呼叫都是由一個單一的機制來完成的。這一機制十分優雅,並且一點都不難理解,所以這很值得我們去了解。不過首先我們從頭開始。

編譯呼叫

CPython 經過兩個主要的步驟來執行我們的程式:

  1. Python 原始碼被編譯為位元組碼。
  2. 一個虛擬機器使用一系列的內建物件和模組來執行這些位元組碼。

在這一節中,我會粗略地概括一下第一步中如何處理一個呼叫。我不會深入這些細節,而且他們也不是我想在這篇文章中關注的真正有趣的部分。如果你想了解更多 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

相關文章