Python源碼剖析筆記6-函數(shù)機制

Python的函數(shù)機制是很重要的部分,很多時候用python寫腳本,就是幾個函數(shù)簡單解決問題,不需要像java那樣必須弄個class什么的。

1 函數(shù)對象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中幾個重要的變量,func_code, func_globals。其中func_code是函數(shù)對象對應(yīng)的PyCodeObject,而func_globals則是函數(shù)的global名字空間,其實這個值是從上一層PyFrameObject傳遞而來。func_defaults是存儲函數(shù)默認(rèn)值的,后面分析函數(shù)參數(shù)的時候會提到,func_closure與閉包相關(guān),后面也會提到。

###func.py
def f():
  print "Function"

f()

如上面例子func.py,該文件編譯后對應(yīng)2個PyCodeObject對象,一個是func.py本身,一個是函數(shù)f。而PyFunctionObject則是在執(zhí)行字節(jié)碼def f():時通過MAKE_FUNCTION指令生成。創(chuàng)建PyFunctionObject對象時,會將函數(shù)f對應(yīng)的PyCodeObject對象和當(dāng)前PyFrameObject對象傳入作為參數(shù),最終也就是賦值給PyFunctionObject中的func_code和func_globals字段了。在調(diào)用函數(shù)時,會將PyFunctionObject對象傳入到fast_function函數(shù)中,最終根據(jù)PyFunctionObject對象的func_code和func_globals字段構(gòu)建新的棧幀對象PyFrameObject,然后調(diào)用PyEval_EvalFrameEx在新的棧幀中執(zhí)行函數(shù)字節(jié)碼。其中PyEval_EvalFrameEx函數(shù)在之前的Python執(zhí)行原理中有提到過,當(dāng)時提到的PyEval_EvalCodeEx函數(shù)其實也是創(chuàng)建了新的棧幀對象PyFrameObject然后執(zhí)行PyEval_EvalFrameEx函數(shù)。

2 函數(shù)調(diào)用棧幀

函數(shù)調(diào)用通過棧幀來建立關(guān)聯(lián),每個被調(diào)用函數(shù)的棧幀PyFrameObject會通過f_back指針指向調(diào)用函數(shù)。而local,global以及builtin名字空間,local名字空間針對新的棧幀是全新的,而global名字空間則是由創(chuàng)建PyFrameObject時從PyFunctionObject傳遞過來。builtin名字空間則是共享調(diào)用者棧幀的(如果該棧幀是初始棧幀,則會先獲取builtin字典用于設(shè)置PyFrameObject的f_builtins字段)。

這里可以回顧一下C語言中的函數(shù)調(diào)用的棧幀關(guān)系。如下面的代碼,對應(yīng)的棧幀結(jié)構(gòu)如圖所示。在調(diào)用函數(shù)時,會先把函數(shù)參數(shù)會壓入當(dāng)前函數(shù)的棧幀中,每個函數(shù)都有自己的棧幀,由于esp會變化,所以其他函數(shù)會通過ebp來索引函數(shù)參數(shù)。

圖1 c語言函數(shù)棧幀
//函數(shù)調(diào)用棧幀測試代碼func.c
int bar(int c, int d)
{
    int e = c + d;
    return e;
}

int foo(int a, int b)
{
    return bar(a, b);
}

int main(void)
{
    foo(2, 3);
    return 0;
}

那么python中是如何來模擬函數(shù)參數(shù)傳遞的呢?從C語言函數(shù)調(diào)用過程可以知道,函數(shù)調(diào)用前,函數(shù)參數(shù)會先壓入到調(diào)用函數(shù)的棧幀中,而被調(diào)用函數(shù)則根據(jù)ebp來取參數(shù)。這里先回顧下PyFrameObject對象的結(jié)構(gòu),函數(shù)調(diào)用與PyFrameObject有著千絲萬縷的聯(lián)系。

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;

PyFrameObject對象中,f_valuestack指向運行時棧的棧底,而f_stacktop則是指向棧頂,在往運行時棧中壓入函數(shù)參數(shù)時,f_stacktop會變化,這兩個變量有點類似C里面的ebp和esp。f_localsplus則是指向局部變量+Cell對象+Free對象+運行時棧,其內(nèi)存布局如圖2所示。其中cell對象和free對象在閉包中用到,后面再看,這里主要說說局部變量和運行時棧。python在調(diào)用函數(shù)之前,會先將函數(shù)對象,函數(shù)參數(shù)壓入到當(dāng)前棧幀的運行時棧中,而在執(zhí)行函數(shù)時,會新建一個PyFrameObject棧幀對象,然后將函數(shù)參數(shù)拷貝到新棧幀的存儲局部變量的那塊空間中(也就是f_localsplus執(zhí)行的那塊內(nèi)存),接著才會調(diào)用PyEval_EvalFrameEx執(zhí)行被調(diào)用函數(shù)的代碼。

圖2 f_localsplus內(nèi)存布局

看下面的func2.py,通過這個例子可以來看一下函數(shù)調(diào)用流程。例子代碼和對應(yīng)字節(jié)碼如下。

#func2.py
def f(name, age):
    age += 5
    print '%s is %s old' % (name, age)
f('ssj', 18)

##字節(jié)碼
In [1]: import dis

In [2]: source = open('func2.py').read()

In [3]: co = compile(source, 'func2.py', 'exec')

In [4]: dis.dis(co)
  1           0 LOAD_CONST               0 (<code object f at 0x10776faf8, file "func2.py", line 1>)
              3 MAKE_FUNCTION            0
              6 STORE_NAME               0 (f)

  4           9 LOAD_NAME                0 (f)
             12 LOAD_CONST               1 ('ssj')
             15 LOAD_CONST               2 (18)
             18 CALL_FUNCTION            2
             21 POP_TOP             
             22 LOAD_CONST               3 (None)
             25 RETURN_VALUE 

In [5]: dis.dis(co.co_consts[0])
  2           0 LOAD_FAST                1 (age)
              3 LOAD_CONST               1 (5)
              6 INPLACE_ADD         
              7 STORE_FAST               1 (age)
              ......

可以看到def f(name, age):跟之前說過的一樣,字節(jié)碼就是通過MAKE_FUNCTION指令創(chuàng)建PyFunctionObject對象并存儲到local名字空間,對應(yīng)的符號為函數(shù)名f,如果函數(shù)有默認(rèn)參數(shù),在MAKE_FUNCTION指令中還會設(shè)置默認(rèn)參數(shù)到func_defaults字段。而準(zhǔn)備調(diào)用函數(shù)時,則是先講函數(shù)對象和函數(shù)參數(shù)執(zhí)行函數(shù)時,會將函數(shù)參數(shù)壓棧,然后才通過CALL_FUNCTION指令調(diào)用函數(shù)。在調(diào)用PyEval_EvalFrameEx執(zhí)行函數(shù)代碼前,創(chuàng)建新的棧幀后,會先將函數(shù)參數(shù)拷貝到f_localsplus指向的那片局部變量空間中,然后才真正執(zhí)行函數(shù)f調(diào)用代碼。執(zhí)行函數(shù)f時,會將age參數(shù)壓入棧然后加上5,然后存儲到f_localsplus的第二個字段(第一個字段為name字符串"ssj")。函數(shù)參數(shù)位置變化如下圖所示。

圖3 函數(shù)參數(shù)位置變化

3 函數(shù)執(zhí)行時名字空間

還是看第一節(jié)中給的例子func.py,其對應(yīng)的字節(jié)碼如下,其實定義函數(shù)def f():就是用函數(shù)對應(yīng)的PyCodeObject和棧幀對應(yīng)的f_globals構(gòu)建PyFunctionObject對象,然后通過STORE_NAME指令將PyFunctionObject對象與函數(shù)名f關(guān)聯(lián)并存儲到local名字空間。函數(shù)f對應(yīng)的PyCodeObject可以通過co.co_consts[0]獲取并查看。

In [1]: source = open('func.py').read()

In [2]: import dis

In [3]: co = dis.dis(source, 'func.py', 'exec')
 1           0 LOAD_CONST               0 (<code object f at 0x1107688a0, file "func.py", line 1>)
              3 MAKE_FUNCTION            0
              6 STORE_NAME               0 (f)

  4           9 LOAD_NAME                0 (f)
             12 CALL_FUNCTION            0
             15 POP_TOP             
             16 LOAD_CONST               1 (None)
             19 RETURN_VALUE  

In [10]: dis.dis(co.co_consts[0])
  2           0 LOAD_CONST               1 ('Function')
              3 PRINT_ITEM          
              4 PRINT_NEWLINE       
              5 LOAD_CONST               0 (None)
              8 RETURN_VALUE        

之前有說過python中分為local,global,builtin名字空間,函數(shù)執(zhí)行時的名字空間略有不同。其global名字空間我們可以看到是通過PyFunctionObject從上一層棧幀傳遞來的,而local名字空間則是賦值為NULL,也就是函數(shù)中并沒有用到local名字空間,那么問題來了,函數(shù)中的那些局部變量是怎么訪問到的呢?那其實在函數(shù)中局部變量是通過LOAD_FAST指令(這個指令下一節(jié)會分析)來訪問的,也就是說它訪問的是f_localsplus的內(nèi)存空間,不需要動態(tài)查找f_locals這個PyDictObject,靜態(tài)的方法可以提供效率。

4 函數(shù)參數(shù)

Python中函數(shù)參數(shù)分為位置參數(shù),鍵參數(shù)以及擴展位置參數(shù)和擴展鍵參數(shù)。位置參數(shù)就是之前我們例子中的參數(shù),而鍵參數(shù)則是在調(diào)用函數(shù)指定參數(shù)的值。而擴展位置參數(shù)和擴展鍵參數(shù)格式則是類似*lst**kwargs。位置參數(shù)還能設(shè)置默認(rèn)值,如果有默認(rèn)值,默認(rèn)值是在MAKE_FUNCTION指令賦值給func_defaults的。

下面的例子可以看到這幾種參數(shù)的用法。擴展位置參數(shù)在python內(nèi)部是通過一個元組對象存儲的,不管最終傳遞了幾個參數(shù)。而擴展鍵參數(shù)在python內(nèi)部則是通過一個字典對象存儲的。對于像 def f(a, b, *lst):這樣的函數(shù),如果調(diào)用函數(shù)時參數(shù)為f(1,2,3,4),其實在PyCodeObject對象中的co_argcount=2, co_nlocals=3。co_argcount是位置參數(shù)的個數(shù),而co_nlocals是局部變量數(shù)目,包括位置參數(shù)在內(nèi)。

##params1.py 位置參數(shù)和鍵參數(shù)
def f(a, b):
    print a, b
f(1, 2)
f(b=2, a=1)

##params2.py 位置參數(shù),擴展位置參數(shù),擴展鍵參數(shù)
def f(value, *lst, **kwargs):
    print value # -1
    print lst  # (1,2)
    print kwargs # {'a':3, 'b':4}
f(-1, 1, 2, a=3, b=4)

##params3.py 位置參數(shù)默認(rèn)值
def f(lst = []):
    lst.append(3) 
    print lst 
f() #打印[3]
f() #打印[3,3]

最后還要提到的一點的是,函數(shù)參數(shù)默認(rèn)值是在定義函數(shù)時設(shè)置的。如例子中的params3.py所示,如果指定了參數(shù)默認(rèn)值,而調(diào)用函數(shù)時又沒有覆蓋默認(rèn)值,則容易出現(xiàn)問題。要解決這個問題,可以在函數(shù)f中加個判斷if lst: lst = []。

5 閉包和裝飾器

之前提到過,PyCodeObject中有兩個字段與閉包相關(guān),分別是co_cellvars和co_freevars。其中co_cellvars通常是一個元組,里面保存的是嵌套作用域中使用的變量名集合,而co_freevars也通常是一個元組,里面保存的是外層作用域中的變量名集合。如下面這個閉包的例子,有三個PyCodeObject對象,closure.py本身,函數(shù)get_func以及inner_func分布對應(yīng)一個PyCodeObject。其中g(shù)et_func的PyCodeObject中的co_cellvars值是元組('value',),同時,inner_func的PyCodeObject的co_freevars存儲的內(nèi)容也是變量名value。

#closure.py 閉包
def get_func():
    value = "inner"
    def inner_func():
         print value
    return inner_func
show_value = get_func()
show_value()

可以看下get_func和inner_func的PyFrameObject的內(nèi)存布局,就可以大致了解閉包的機制了。其實就是在外層函數(shù)的局部變量中存儲內(nèi)層嵌套函數(shù)inner_func的PyFunctionObject對象,而PyFunctionObject中的func_closure字段是一個存儲PyCellObject的元組對象。在執(zhí)行inner_func時,會先將func_closure中存儲的PyCellObject對象拷貝到inner_func的PyFrameObject的free對象中,也就是cell對象后面那塊存儲空間,這樣在inner_func中通過freevars引用到value了(注意,這個freevars不是inner_func的PyCodeObject中的co_freevars,而是PyFrameObject中的對應(yīng)的內(nèi)存區(qū)域,雖然他們的內(nèi)容是一致的)。

圖5 閉包機制圖示

裝飾器是基于閉包實現(xiàn)的,可以對一個函數(shù),方法,類進(jìn)行加工,實現(xiàn)一些額外功能,在實際編碼中會經(jīng)常用到,比如檢查用戶是否登錄,檢查輸入?yún)?shù)等,就可以用到裝飾器來減少冗余代碼。下面是一個裝飾器的例子:

#decorator.py 裝飾器
def wrapper(fn):
    def _wrapper():
        print 'wrapper '
        fn()
    return _wrapper

@wrapper
def func():
    print 'real func'

if __name__ == "__main__":
    func()  #輸出'wrapper' 'real func'

更多裝飾器介紹,參見vamei的這篇文章 Python深入05 裝飾器

6 參考資料

  • 《python源碼剖析》 主要例子和原理都是參照本書
  • Python快速教程
  • 宋勁松 《Linux C語言一站式編程》
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容