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 程式碼在編譯器中經歷的流程,可以閱讀 這篇文章 。
簡單地來說,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] | 這是故意的簡化—— () 同樣可以用作其他用途如類定義 (用以列舉基類)、函式定義 (列舉引數)、修飾器等等,但它們並不在表示式中。我同樣也故意忽略了生成器表示式。 |
[3] | 在 C 程式碼可能結束呼叫 Python 程式碼的地方需要使用 Py_EnterRecursiveCall 來讓 CPython 保持對遞迴層級的跟蹤,並在遞迴過深時跳出。注意,用
C 寫的函式並不需要遵守這個遞迴限制。這也是為什麼 do_call 的特殊情況 PyCFunction 先於呼叫 PyObject_Call 。 |
[5] | 當我說 一切 皆物件時,我的意思就是它。你也許會覺得物件是你定義的類的例項。然而,深入到 C 一級,CPython 如你一樣建立和耍弄許許多多的物件。型別 (類)、內建物件、函式、模組,所有這些都表現為物件。 |
[6] | 這個例子只能在 Python 3.3 中執行,因為 split 的 sep 這個關鍵詞引數是在這個版本中新加的。在之前版本的
Python 中 split 僅僅接受定位引數。 |
相關文章
- 菜鳥學SSH(十六)——Struts2內部是如何工作的
- Python基礎之:Python中的內部物件Python物件
- JavaScript 是如何工作:Shadow DOM 的內部結構 + 如何編寫獨立的元件!JavaScript元件
- Laravel 內部呼叫 APILaravelAPI
- 淺析Block的內部結構 , 及分析其是如何利用 NSInvocation 進行呼叫BloC
- Facebook內部是如何使用JavaScript和GraphQL的JavaScript
- JavaScript內部物件和Date物件JavaScript物件
- 內部呼叫@Transactional 註解的方法
- 靜態內部類 呼叫
- Vitual DOM 的內部工作原理
- JS引擎:它們是如何工作的?從呼叫堆疊到Promise,需要知道的所有內容JSPromise
- JavaScript是如何工作的:深入類和繼承內部原理 + Babel和TypeScript 之間轉換JavaScript繼承BabelTypeScript
- C++ 可呼叫物件的概念 callable objectC++物件Object
- 按照NSArray內部的某個物件排序物件排序
- python的描述符(器)是如何工作的?Python
- 物件導向之內部類物件
- JavaScript是如何工作的:引擎,執行時和呼叫堆疊的概述!JavaScript
- 領域驅動是如何訪問聚合內的物件的物件
- Facebook內部高效工作PPT指南
- 瀏覽器內部工作原理瀏覽器
- CPU內部的奧秘:程式碼是如何被執行的?
- Duolingo 的內部測試是如何運作的Go
- python可迭代物件Python物件
- 譯—JavaScript是如何工作的(2):V8引擎內部+優化程式碼的5個技巧JavaScript優化
- Ruby 和 Python 分析器是如何工作的?Python
- 熱門 Python 應用 The Fuck 是如何工作的Python
- WinMain是如何被呼叫的AI
- Kubernetes 內部元件工作原理元件
- DNS是如何工作的?DNS
- Cucumber是如何工作的?
- Javascript是如何工作的JavaScript
- Orchard是如何工作的?
- CDN是如何工作的?
- Python可變物件和不可變物件Python物件
- 小談java內部類物件的生成過程Java物件
- 譯—JavaScript是如何工作的(1):js引擎、執行時和呼叫棧的概述JavaScriptJS
- 在Linux中,什麼是檔案許可權?它們是如何工作的?Linux
- Redis 物件內部組織結構 —— 字典Redis物件