函數是任何一個語言的核心構成要素。首先,我想對于絕大部分的程序員,函數是我們在學習編程過程中,接觸到的第一個邏輯解耦工具。在我們學習C語言編程的頭一兩次課里,我們就開始學會使用函數封裝邏輯,然后在別處調用。另一方面,函數的調用,是程序運行的根本驅動力。對于大部分編程語言而言,其應用程序的啟動,都是從執行入口函數開始的。因此,在我們嘗試了解一個語言的底層構造機制時,函數是一個重要的話題。
本文分四個部分梳理python 函數的底層機制。首先是介紹與python函數有關的對象(python 處處皆對象),第二部分和第三部分分別討論函數的創建與運行。
而最后一部分討論的是函數中可能用到的各種類型的變量。
與函數有關的python對象
python 處處皆對象,任何一個python的組成元素,其在本質上,都是以一個對象表現的。對于python的函數系統,有三類對象與之有密切的關系,分別是
PyCodeObject,PyFunctionObject,PyFrameObject。
PyCodeObject
在固有印象里,python是一個腳本語言,然而python也是需要編譯的。python每次在執行一個腳本的時候,其前端編譯器都需要對腳本代碼進行編譯,這個編譯的過程和java等語言的編譯過程是相似的,都會生成平臺無關的中間代碼以及一些其他的靜態信息。而這個信息就被保存在PyCodeObject對象里。
python在編譯過程中,生成PyCodeObject 的原則是,針對于每一個作用域,生成一個PyCodeObject。由于作用域的嵌套性,PyCodeObject之間也存在嵌套關系。舉例來說
針對于寫在 test.py 文件中的如下代碼:
class Test:
def f():
pass
其在編譯時就會產生三個PyCodeObject,針對于整個文件的PyCodeObject,針對于類Test的PyCodeObject和針對于函數f的PyCodeObject。三個PyCodeObject 之間互相嵌套,嵌套的關系與它們之間的作用域關系一致。
PycodeObject 對象是可以離線存儲的,我們經常見到的.pyc 文件就是用來存儲一個模塊所對應的PycodeObject的。
PyCodeObject 的數據結構如下:
/* Bytecode object */
typedef struct {
PyObject_HEAD
int co_argcount; /* #arguments, except *args */
int co_nlocals; /* #local variables */
int co_stacksize; /* #entries needed for evaluation stack */
int co_flags; /* CO_..., see below */
PyObject *co_code; /* instruction opcodes */
PyObject *co_consts; /* list (constants used) */
PyObject *co_names; /* list of strings (names used) */
PyObject *co_varnames; /* tuple of strings (local variable names) */
PyObject *co_freevars; /* tuple of strings (free variable names) */
PyObject *co_cellvars; /* tuple of strings (cell variable names) */
/* The rest doesn't count for hash/cmp */
PyObject *co_filename; /* string (where it was loaded from) */
PyObject *co_name; /* string (name, for reference) */
int co_firstlineno; /* first source line number */
PyObject *co_lnotab;
void *co_zombieframe; /* for optimization only (see frameobject.c) */
PyObject *co_weakreflist; /* to support weakrefs to code objects */
} PyCodeObject;
如前所述,PyCodeObject主要存儲的是編譯器解析出的代碼靜態信息。如果當前的PyCodeObject 對應于一個函數,那么PyCodeObject中將記錄這個函數的參數個數(co_argcount),局部變量個數(co_nlocals)等信息。函數本身編譯產生的指令代碼序列以字符串的形式被存儲在co_code中。
說了這么多,這里可以得到一個結論,對于函數來說,PyCodeObject存儲的是,python函數在編譯以后,執行以前的靜態信息。
PyFunctionObject
python 是一種動態語言。一些在傳統語言當中編譯期發生的事情,在python里卻是在運行時發生的。比如 函數的定義。python中函數的定義,發生在虛擬機執行def 語句對應的指令序列時。而函數定義語句的產物就是本小節的主角,PyFunctionObject。
PyFunctionObject的數據結構如下:
typedef struct {
PyObject_HEAD
PyObject *func_code; /* A code object */
PyObject *func_globals; /* A dictionary (other mappings won't do) */
PyObject *func_defaults; /* NULL or a tuple */
PyObject *func_closure; /* NULL or a tuple of cell objects */
PyObject *func_doc; /* The __doc__ attribute, can be anything */
PyObject *func_name; /* The __name__ attribute, a string object */
PyObject *func_dict; /* The __dict__ attribute, a dict or NULL */
PyObject *func_weakreflist; /* List of weak references */
PyObject *func_module; /* The __module__ attribute, can be anything */
} PyFunctionObject;
PyFunctionObject 作為函數的定義載體,理所當然的要載有函數體內的指令代碼序列等靜態信息。PyFunctionObject 中的func_code就是用于引用函數對應的PycodeObject的指針。
除了pyCodeObject,PyFunctionObject還保存了函數外的全局名字空間(func_globals),函數的默認參數列表(func_defaults),以及函數的閉包變量引用(func_closures)。這些信息,我個人把它們看做是一個函數運行時的上下文環境,顯然這些信息對于函數的運行至關重要。
那么這里,我們可以得到一個結論,PyFuctionObject 是函數指令序列與函數執行環境的載體。
PyFrameObject
在很多編程語言中,都有函數棧幀這一概念。我想對于絕大部分程序員,這都是一個非常熟悉的概念了。而python函數運行時的棧就是用PyFrameObject對象表示的。
typedef struct _frame {
PyObject_VAR_HEAD
struct _frame *f_back; /* previous frame, or NULL */
PyCodeObject *f_code; /* code segment */
PyObject *f_builtins; /* builtin symbol table (PyDictObject) */
PyObject *f_globals; /* global symbol table (PyDictObject) */
PyObject *f_locals; /* local symbol table (any mapping) */
PyObject **f_valuestack; /* points after the last local */
PyObject **f_stacktop;
PyObject *f_trace; /* Trace function */
PyObject *f_exc_type, *f_exc_value, *f_exc_traceback;
PyThreadState *f_tstate;
int f_lasti; /* Last instruction if called */
int f_lineno; /* Current line number */
int f_iblock; /* index in f_blockstack */
PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* for try and loop blocks */
PyObject *f_localsplus[1]; /* locals+stack, dynamically sized */
} PyFrameObject;
我們清楚的在這個結構中看見了 f_valuestack和f_stacktop ,顯然這組指針用于指向一個函數棧幀空間的棧底和棧頂。
除了保存函數棧幀空間的位置以外,PyFrameObject對象還保存了一個函數執行時的全局名字空間(f_globals),這個名字空間就是PyfunctionObject攜帶的func_globals,函數的運行時全局名字空間會在函數開始運行前的初始化階段,由PyFunctionObject對象傳遞到PyFrameObject對象。
此外,成員 f_localsplus用于存儲函數實參等作用范圍在函數體內的變量。
那么這里可以看到,作為函數棧幀對象PyFrameObject 用來保存函數運行時的動態信息。其生命周期從函數開始運行到函數運行結束。
小結
這里對三種函數相關的對象做一個總結。
PyCodeObject存儲的是函數的代碼,聲明等靜態信息。
PyFrameObject存儲的是函數棧幀等函數運行時的動態信息。
而PyFunctionObject,是函數在定義后運行前的一個暫存對象。
保存有函數的靜態信息和運行時上下文環境。
某種程度上,PyFuctionObject,是連接PyCodeObject 與 PyFrameObject的橋梁。
函數創建
就像我們所共知的,python 是一門腳本語言,其語言運行的基本機制是虛擬機逐句執行python語句,說的更準確一點, python虛擬機逐句執行預先編譯產生的PyCodeObject 中的python中間代碼指令序列。
不同于傳統的靜態語言,python中函數的定義行為,也是在運行時,由虛擬機執行相應的指令序列完成的。
這里我們通過一個例子,來演示函數的定義過程。首先我們建立一個名字為test.py的python文件,其內容如下:
def f():
print "hello world"
緊接著,我們通過python內置的compile函數,可以得到test.py編譯產生的PyCodeObject 對象,利用dis模塊,我們可以提取得到test.py這個模塊所對應的指令碼序列:
0 LOAD_CONST 0 (<code object f at 0x1055df430, file "test.py", line 1>)
3 MAKE_FUNCTION 0
6 STORE_NAME 0 (f)
9 LOAD_CONST 1 (None)
12 RETURN_VALUE
到這里,我們看見了python 中間代碼指令序列的“廬山真面目”。
dis函數的輸出分為三列,第一列是指令相對于代碼段起始位置的偏移,第二列是指令碼,第三列為指令參數。關于指令碼的意義,官網上有一個較為權威的介紹。
觀察 test.py 所對應的指令序列:
LOAD_CONST 0 (<code object f at 0x1055df430, file "test.py", line 1>)
# 從test.py對應的PyCodeObject對象的co_const成員中,讀取函數f對應的PyCodeObject
MAKE_FUNCTION 0
# 利用f的PyCodeObject創建函數對象 PyFunctionObject
STORE_NAME 0 (f)
# 存儲函數對象到當前名字空間
可以看到,上述三條指令,完成了python語句層面的def操作。
其中的一個核心指令是 MAKE_FUNCTION.
python虛擬機在執行MAKE_FUNTION命令時,首先創建一個PyFunctionObject對象,之后會將函數對應的PyCodeObject 以及一個當前的全局名字空間(CurrentFrame.f_globals)綁定到PyFunctionObject對象上。
PyFunctionObject.func_code=PyCodeObejct
PyFunctionObject.func_globals= CurrentFrame.f_globals
此外,如果函數有默認參數的話,也在這個過程中將默認參數值傳遞給PyFunctiobObject保存
PyFunctionObejct.func_defaults=tuple(默認參數1,默認參數2...)
至此,我們可以看出python函數的定義語句,完成的核心功能就是生成并初始化一個PyFunctionObject對象。其目的在于完成對于函數靜態信息與執行上下文環境的封裝,為函數的調用做好準備。
函數運行
現在,我們已經知道,函數在python中,是以PyFunctionObject 對象的形式,存儲在當前名字空間中的。那么接下來要介紹的事情就是函數的調用。
函數調用對應的python中間代碼指令是 CALL_FUNCTION。概括的講,函數的調用分為兩個部分:創建PyFrameObject對象與執行PyEval_EvalFrameEx函數。
創建PyFrameObject
如同其他語言一樣,python語言中也存在函數棧幀的概念,其作用也與其他語言中的函數棧幀相似-- 作為函數運行與調用的活動記錄,保存局部動態數據。
追蹤python的底層源碼,我們可以定位到pyFrameObject 對象的創建代碼:
f=Frame_New(tstate,co,globals,NUll);
其中,co 是一個PyCodeObject 對象,globals,則是一個全局名字空間,
如前所述,這里的PyCodeObject,和globals 名字空間,是從PyFrameObject對象里獲得的!!
到這里,我們可以看清楚函數從創建到調用過程中,底層相關對象的轉換過程:
編譯期:生成PycodeObject。保存靜態信息
運行期(1):執行def語句。創建PyFunctionObject對象封裝PyCodeObject和全局名字空間。
運行期 (2):執行函數調用,利用PyFunctionObject對象創建并初始化PyFrameObject對象。
PyFrameObject中有一個很重要的成員:f_back;可以看到,這是一個指向另一個PyFrameObject 的指針,這暗示著我們,PyFrameObject對象在python運行時環境中是以鏈表形式組織的。
實際上,調用函數的棧幀與被調用函數的棧幀對象之間正式通過這個指針聯系的。
def caller():
calle()
生成PyFrameObject后,還需要將函數調用的實參從當前的函數調用棧復制到PyFrameObject 的f_locals
plus區,從而完成函數的參數傳遞工作。
執行PyEval_EvalFrameEx函數
結束了PyFrameObject 對象的創建與初始化之后,python虛擬機開始執行PyEval_EvalFrameEx 函數。
可以這么講,PyEval_EvalFrameEx函數是整個python虛擬機驅動的關鍵。
其代碼的骨干結構如下:
PyEval_EvalFrameEx()
{
while(1)
{
switch(opcode)
{
case “LOAD_CONST”:
...
case "MAKE_FUNCTION":
...
...
...
case "CALL_FUNCTION":
...
PyEval_EvalFrameEx();
...
}
}
}
通過執行這個函數,python虛擬機可以執行當前python函數對應的PyFrameObject 對象中f_code成員內保存的python中間代碼指令序列。
從而可以完成對于函數邏輯的執行。
這里我們注意到,PyEval_EvalFrameEx 函數中,包含著對于CALL_FUNCTION 指令的處理,而CALL_FUNCTION 指令本身的處理流程,又包含了一個對于PyEval_EvalFrameEx函數的調用。
所以,本質上,python通過這種對于PyEval_EvalFrameEx的遞歸調用,實現了對于函數之間調用的處理。
上述PyEval_EvalFrameEx 內的這種中間代碼處理邏輯,個人認為是一種比較經典的虛擬機主干邏輯設計。在我學習過的llvm項目中,其后端解釋器的構造也是相似的。
在這里可以看到的是,虛擬機是通過PyEval_EvalFrameEx內的主干邏輯來執行中間代碼序列的。而函數間的調用,是在PyEval_EvalFrameEx代碼內,MAKE_FUNCTION 指令處理分支上,遞歸調用PyEval_EvalFrameEx完成的。
如果追溯整個python虛擬機運行過程中,PyEval_EvalFrameEx
的調用軌跡,我們大概可以得到如下一幅圖:
比較特殊的一點是,最外層的PyEval_EvalFrameEx所執行的中間代碼,并不是屬于某個函數對應的PyCodeObject的。而是屬于一個名字叫做“__ main __”的模塊所對應的PyCodeObject的
__name__ == '__main__'
python 虛擬機在一開始,取尋找這個名字叫做“__ main __”的模塊,提取他的PyCodeObject,調用PyEval_EvalFrameEx 去執行 從而觸發了整個虛擬機的運轉。
某種程度上,這個特殊的“main” 模塊,就是python語言應用程序的“入口函數”。
函數中的變量
函數運行過程中訪問的變量概括的講,可以分為三類:全局變量,局部變量,參數變量。這其中,局部變量和參數變量的作用域范圍和存儲形式是很相近的。其存儲的位置都在PyFrameObject的f_localsplus數組中。而全局變量則是存儲在PyFrameObject的f_globals 所引用的名字空間中。
在python的中間代碼指令中,對于全局名字空間的訪問命令是:
LOAD_GLOBAL/STORE_GLOBAL。其語義分別是:從全局名字空間中取出某個全局對象壓棧,將函數棧中的某個對象寫入全局名字空間。
相似的,對于局部變量和參數變量的讀寫命令分別是:
LOAD_FAST/ STORE_FAST.
函數運行過程中,變量的訪問廣泛的存在于以下兩個抽象過程中:
讀取數據作為命令參數:
python虛擬機從當前棧幀的局部空間(f_localsplus)或者全局空間(f_globals)中讀取數據并壓棧,作為接下來中間代碼指令的執行參數。
讀取數據作為返回值:
python虛擬機執行命令的返回結果會存儲在當前的函數棧中,通過彈棧操作,將命令執行的結果寫入到全局或者局部空間中。