深入理解python之函數系統

函數是任何一個語言的核心構成要素。首先,我想對于絕大部分的程序員,函數是我們在學習編程過程中,接觸到的第一個邏輯解耦工具。在我們學習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()
Alt text
Alt text

生成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
的調用軌跡,我們大概可以得到如下一幅圖:

Alt text
Alt text

比較特殊的一點是,最外層的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.

Alt text
Alt text

函數運行過程中,變量的訪問廣泛的存在于以下兩個抽象過程中:

讀取數據作為命令參數:
python虛擬機從當前棧幀的局部空間(f_localsplus)或者全局空間(f_globals)中讀取數據并壓棧,作為接下來中間代碼指令的執行參數。

讀取數據作為返回值:
python虛擬機執行命令的返回結果會存儲在當前的函數棧中,通過彈棧操作,將命令執行的結果寫入到全局或者局部空間中。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,247評論 6 543
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,520評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,362評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,805評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,541評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,896評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,887評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,062評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,608評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,356評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,555評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,077評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,769評論 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,175評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,489評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,289評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,516評論 2 379

推薦閱讀更多精彩內容