《深度剖析CPython解釋器》15. Python函數機制的深度解析(第二部分): 函數在底層是如何被調用的


楔子

在上一篇博客中,我們說了Python函數的底層實現,並且還演示了如何自定義一個函數,雖然這在工作中沒有太大意義,但是可以讓我們深刻理解函數的行為。此外我們還介紹了如何獲取函數的參數,而這一次我們就來看看函數如何調用的。

函數的調用

s = """
def foo():
    a, b = 1, 2
    return a + b

foo()
"""

if __name__ == '__main__':
    import dis
    dis.dis(compile(s, "call_function", "exec"))

我們以一個非常簡單的函數為例,看看它的字節碼:

  2           0 LOAD_CONST               0 (<code object foo at 0x00000219BA3F1450, file "call_function", line 2>)
              2 LOAD_CONST               1 ('foo')
              4 MAKE_FUNCTION            0
              6 STORE_NAME               0 (foo)

  6           8 LOAD_NAME                0 (foo)
             10 CALL_FUNCTION            0
             12 POP_TOP
             14 LOAD_CONST               2 (None)
             16 RETURN_VALUE

Disassembly of <code object foo at 0x00000219BA3F1450, file "call_function", line 2>:
  3           0 LOAD_CONST               1 ((1, 2))
              2 UNPACK_SEQUENCE          2
              4 STORE_FAST               0 (a)
              6 STORE_FAST               1 (b)

  4           8 LOAD_FAST                0 (a)
             10 LOAD_FAST                1 (b)
             12 BINARY_ADD
             14 RETURN_VALUE

還是那句話,模塊有一個PyCodeObject對象,函數也有一個PyCodeObject對象,只不過后者是在前者的常量池當中。而且dis模塊在顯示字節碼的時候,自動幫我們分開了,我們從上到下依次捋一遍。

  • 0 LOAD_CONST 0 (<code object......: 遇到def關鍵字知道這是一個函數, 所以會加載其對應的PyCodeObject對象
  • 2 LOAD_CONST 1 ('foo'): 加載函數名
  • 4 MAKE_FUNCTION 0: 通過MAKE_FUNCTION指令構造一個函數
  • 6 STORE_NAME 0 (foo): 將符號"foo"和上一步得到的函數綁定起來, 存儲在local空間中, 這個local空間顯然是模塊的local空間、即global空間
  • 8 LOAD_NAME 0 (foo): 注意這一步是在調用的時候發生的, 將變量foo加載進來
  • 10 CALL_FUNCTION 0: 通過CALL_FUNCTION指令調用該函數(我們后面將要分析的重點), 后面的0表示參數個數
  • 12 POP_TOP: 將上一步函數的返回值從運行時棧的頂部彈出
  • 14 LOAD_CONST 2 (None): 加載返回值None
  • 16 RETURN_VALUE: 將返回值返回

模塊對應的字節碼就是上面那樣,再來看看函數的,事實上對於現在的你來說已經很簡單了。

  • 0 LOAD_CONST 1 ((1, 2)): 從常量池中加載元組, 我們說對於列表而言是先將內部的元素一個一個加載進來、然后通過BUILD_LIST構建一個列表, 但是對於元組來說則可以直接加載, 原因就是元組內的元素指向對象的地址不可以變
  • 2 UNPACK_SEQUENCE 2: 解包
  • 4 STORE_FAST 0 (a): 將解包得到兩個常量中的第一個常量賦值給a
  • 6 STORE_FAST 0 (B): 將解包得到兩個常量中的第二個常量賦值給b
  • 8 LOAD_FAST 0 (a): 加載局部變量a
  • 10 LOAD_FAST 1 (b): 加載局部變量b
  • 12 BINARY_ADD: 執行加法運算
  • 14 RETURN_VALUE: 將返回值返回

所以從目前來看,這些字節碼已經沒什么難度了,但是我們看到調用函數是用過CALL_FUNCTION指令,那么這個指令都做了哪些事情呢?

        case TARGET(CALL_FUNCTION): {
            PREDICTED(CALL_FUNCTION);
            //sp: 運行時棧棧頂指針
            //res: 函數的返回值, 一個PyObject *
            PyObject **sp, *res;
            //指向運行時棧的棧頂
            sp = stack_pointer;
            //調用函數, 將返回值賦值給res, tstate表示線程對象, &sp顯然是一個三級指針了, oparg表示指令的操作數
            res = call_function(tstate, &sp, oparg, NULL);
            stack_pointer = sp;
            PUSH(res);
            if (res == NULL) {
                goto error;
            }
            DISPATCH();
        }

然后重點是call_function函數,我們來看一下,同樣位於 ceval.c 中。

#define PyCFunction_Check(op) (Py_TYPE(op) == &PyCFunction_Type)
#define PyFunction_Check(op) (Py_TYPE(op) == &PyFunction_Type)


Py_LOCAL_INLINE(PyObject *) _Py_HOT_FUNCTION
call_function(PyThreadState *tstate, PyObject ***pp_stack, Py_ssize_t oparg, PyObject *kwnames)
{	
    //獲取PyFunctionObject對象,因為pp_stack是在CALL_FUNCTION指令中傳入的棧頂指針
    //傳入的oparg是0,kwnames是NULL,這里的pfunc就是MAKE_FUNCTION中創建的PyFunctionObject對象
    PyObject **pfunc = (*pp_stack) - oparg - 1;
    //這里的func和pfunc是一樣的
    PyObject *func = *pfunc;
    PyObject *x, *w;
    //處理參數,對於我們當前的函數來說,這里的nkwargs和nargs都是0    
    Py_ssize_t nkwargs = (kwnames == NULL) ? 0 : PyTuple_GET_SIZE(kwnames);
    Py_ssize_t nargs = oparg - nkwargs;
    //移動棧指針
    PyObject **stack = (*pp_stack) - nargs - nkwargs;
	
    //然后這里有兩種執行方式, 我們后面會說, 但是我們看到將返回值賦值給了x
    if (tstate->use_tracing) {
        x = trace_call_function(tstate, func, stack, nargs, kwnames);
    }
    else {
        x = _PyObject_Vectorcall(func, stack, nargs | PY_VECTORCALL_ARGUMENTS_OFFSET, kwnames);
    }

    assert((x != NULL) ^ (_PyErr_Occurred(tstate) != NULL));

    //清空函數棧
    while ((*pp_stack) > pfunc) {
        w = EXT_POP(*pp_stack);
        Py_DECREF(w);
    }

    return x;
}



static PyObject *
trace_call_function(PyThreadState *tstate,
                    PyObject *func,
                    PyObject **args, Py_ssize_t nargs,
                    PyObject *kwnames)
{
    PyObject *x; //返回值
    //調用_PyObject_Vectorcall, 將返回值設置給x
    if (PyCFunction_Check(func)) {
        C_TRACE(x, _PyObject_Vectorcall(func, args, nargs, kwnames));
        return x;
    }
    //這里暫時先不用管, 這里是調用一個方法, 顯然它是和類相關, 我們在介紹類的時候會說
    else if (Py_TYPE(func) == &PyMethodDescr_Type && nargs > 0) {
        PyObject *self = args[0];
        func = Py_TYPE(func)->tp_descr_get(func, self, (PyObject*)Py_TYPE(self));
        if (func == NULL) {
            return NULL;
        }
        C_TRACE(x, _PyObject_Vectorcall(func,
                                        args+1, nargs-1,
                                        kwnames));
        Py_DECREF(func);
        return x;
    }
    return _PyObject_Vectorcall(func, args, nargs | PY_VECTORCALL_ARGUMENTS_OFFSET, kwnames);
}

然后會調用 _PyFunction_FastCallDict 函數:

//Objects/call.c
PyObject *
_PyFunction_FastCallDict(PyObject *func, PyObject *const *args, Py_ssize_t nargs,
                         PyObject *kwargs)
{
    PyCodeObject *co = (PyCodeObject *)PyFunction_GET_CODE(func); //獲取PyCodeObject對象
    PyObject *globals = PyFunction_GET_GLOBALS(func);//獲取global名字空間
    PyObject *argdefs = PyFunction_GET_DEFAULTS(func);//獲取參數
    PyObject *kwdefs, *closure, *name, *qualname; //一些其它屬性
    PyObject *kwtuple, **k;
    PyObject **d;
    Py_ssize_t nd, nk;
    PyObject *result;

    assert(func != NULL);
    assert(nargs >= 0);
    assert(nargs == 0 || args != NULL);
    assert(kwargs == NULL || PyDict_Check(kwargs));
	
    //我們觀察一下下面的return
    //一個是function_code_fastcall,一個是最后的_PyEval_EvalCodeWithName
    //從名字上能看出來function_code_fastcall是一個快分支, 它適用於沒有參數函數
    if (co->co_kwonlyargcount == 0 &&
        (kwargs == NULL || PyDict_GET_SIZE(kwargs) == 0) &&
        (co->co_flags & ~PyCF_MASK) == (CO_OPTIMIZED | CO_NEWLOCALS | CO_NOFREE))
    {
        /* Fast paths */
        if (argdefs == NULL && co->co_argcount == nargs) {
            //function_code_fastcall里面邏輯很簡單
            //直接抽走當前PyFunctionObject里面PyCodeObject和函數運行時的global命名空間等信息
            //根據PyCodeObject對象直接為其創建一個PyFrameObject對象,然后PyEval_EvalFrameEx執行棧幀
            //也就是真正的進入了函數調用,執行函數里面的代碼
            return function_code_fastcall(co, args, nargs, globals);
        }
        else if (nargs == 0 && argdefs != NULL
                 && co->co_argcount == PyTuple_GET_SIZE(argdefs)) {
            /* function called with no arguments, but all parameters have
               a default value: use default values as arguments .*/
            args = _PyTuple_ITEMS(argdefs);
            return function_code_fastcall(co, args, PyTuple_GET_SIZE(argdefs),
                                          globals);
        }
    }
	
    //適用於有參數的情況
    nk = (kwargs != NULL) ? PyDict_GET_SIZE(kwargs) : 0;
    if (nk != 0) {
        Py_ssize_t pos, i;
        kwtuple = PyTuple_New(2 * nk);
        if (kwtuple == NULL) {
            return NULL;
        }

        k = _PyTuple_ITEMS(kwtuple);
        pos = i = 0;
        while (PyDict_Next(kwargs, &pos, &k[i], &k[i+1])) {
            Py_INCREF(k[i]);
            Py_INCREF(k[i+1]);
            i += 2;
        }
        assert(i / 2 == nk);
    }
    else {
        kwtuple = NULL;
        k = NULL;
    }
	
    //獲取相關參數
    kwdefs = PyFunction_GET_KW_DEFAULTS(func);
    closure = PyFunction_GET_CLOSURE(func);
    name = ((PyFunctionObject *)func) -> func_name;
    qualname = ((PyFunctionObject *)func) -> func_qualname;

    if (argdefs != NULL) {
        d = _PyTuple_ITEMS(argdefs);
        nd = PyTuple_GET_SIZE(argdefs);
    }
    else {
        d = NULL;
        nd = 0;
    }
	
    //如果有參數的話,現在會走這一步,邏輯會復雜一些,不過這些都是后話了
    //但是顯然最終也會經過PyEval_EvalFrameEx, 進而進入哪一個大大的for循環
    result = _PyEval_EvalCodeWithName((PyObject*)co, globals, (PyObject *)NULL,
                                      args, nargs,
                                      k, k != NULL ? k + 1 : NULL, nk, 2,
                                      d, nd, kwdefs,
                                      closure, name, qualname);
    Py_XDECREF(kwtuple);
    return result;
}

因此我們看到,總共有兩條路徑,分別針對無參和有參,但是最終殊途同歸、都會走到PyEval_EvalFrameEx那里。然后虛擬機在新的棧幀中執行新的PyCodeObject,而這個PyCodeObject就是函數對應的PyCodeObject。

但是到這里恐怕就有人有疑問了,我們之前說過PyFrameObject是根據PyCodeObject創建的,而PyFunctionObject也是根據PyCodeObject創建的,那么PyFrameObject和PyFunctionObject之間有啥關系呢?

如果把PyCodeObject比喻成"妹子"的話,那么PyFunctionObject就是妹子的"備胎",PyFrameObject就是妹子的"心上人"。其實PyEval_EvalFrameEx在棧幀中執行的時候,PyFunctionObject的影響就已經消失了,真正對棧幀產生影響的是PyFunctionObject里面的PyCodeObject對象和global名字空間。也就是說,最終是PyFrameObject對象和PyCodeObject對象兩者如膠似漆,跟PyFunctionObject對象之間沒有關系,所以PyFunctionObject辛苦一場,實際上是為別人做了嫁衣。PyFunctionObject主要是對PyCodeObject和global名字空間的一種打包和運輸方式。

另外我們這里提到了快速通道,那么函數是通過什么來判斷是否可以進入快速通道呢?答案是通過函數參數的形式來決定是否可以進入快速通道,下面我們就來看看函數中參數的實現。

函數參數的實現

函數最大的特點就是可以傳入參數,否則就只能單純的封裝,這樣未免太無趣了。對於Python來說,參數會傳什么對於函數來說是不知道的,函數體內部只是利用參數做一些事情,比如調用參數的get方法,但是到底能不能調用get方法,就取決於你給參數傳的值到底是什么了。因此可以把參數看成是一個占位符,我們假設有這么個東西,直接把它當成已存在的變量或者常量去進行操作,然后調用的時候,將某個值傳進去賦給相應的參數,然后參數對應着傳入的具體的值將邏輯走一遍即可。

參數類別

在Python中,調用函數時所傳遞的參數根據形式的不同可以分為四種類別:

def foo(a, b):
    pass
  • 位置參數(positional argument):foo(a, b), a和b通過位置參數傳遞
  • 關鍵字參數(keyword argument):foo(a=1, b=2), a和b通過關鍵字參數
  • 擴展位置參數(excess positional argument):foo(*args), args通過擴展位置參數傳遞
  • 擴展關鍵字參數(excess keyword argument)foo(**kwargs), kwargs通過擴展位置參數傳遞

我們下面來看一下python的call_function是如何處理函數信息的:

Py_LOCAL_INLINE(PyObject *) _Py_HOT_FUNCTION
call_function(PyThreadState *tstate, PyObject ***pp_stack, Py_ssize_t oparg, PyObject *kwnames)
{	
    PyObject **pfunc = (*pp_stack) - oparg - 1;
    PyObject *func = *pfunc;
    PyObject *x, *w;
    /*當python虛擬機在開始執行MAKE_FUNCTION指令時,會先獲取一個指令參數oparg
    oparg里面記錄函數的參數個數信息,包括位置參數和關鍵字參數的個數。
    雖然擴展位置參數和擴展關鍵字參數是更高級的用法,但是本質上也是由多個位置參數、多個關鍵字參數組成的。
    這就意味着,雖然Python中存在四種參數,但是只要記錄位置參數和關鍵字參數的個數,就能知道一共有多少個參數,進而知道一共需要多大的內存來維護參數。
    */
    //nkwargs就是關鍵字參數的個數,nargs是位置參數的個數 
    Py_ssize_t nkwargs = (kwnames == NULL) ? 0 : PyTuple_GET_SIZE(kwnames);
    Py_ssize_t nargs = oparg - nkwargs;
    PyObject **stack = (*pp_stack) - nargs - nkwargs;

而且Python的每個指令都是兩個字節,第一個字節存放指令序列本身,第二個字節存放參數個數,既然是一個字節,說明最多只允許有255個參數,不過這已經足夠了。但是在Python3.8中,這個限制被打破了。

[root@iZ2ze3ik2oh85c6hanp0hmZ ~]# python3 1.py 
Traceback (most recent call last):
  File "1.py", line 8, in <module>
    print(exec(s))
  File "<string>", line 2
SyntaxError: more than 255 arguments
[root@iZ2ze3ik2oh85c6hanp0hmZ ~]# 

以我阿里雲上的Python3.6為例,發現參數不能超過255個,但是在Python3.8的時候,即使有1000000個參數也是沒問題的。所以Python3.8的源碼變動是有些大的,3.6和3.7實際上是差不多的,虛擬機實現代碼甚至和Python2也高度相似。但是在Python3.8,變動就有點大了。

Python函數內部局部變量信息,可以通過co_nlocals和co_argcount來獲取。從名字也能看出來這個不是PyFunctionObject里面的,而是PyCodeObject里面的。co_nlocals,我們之前說過,這是函數內部局部變量的個數,co_argcount是參數的個數。實際上,函數參數和函數局部變量是非常密切的,某種意義上函數參數就是一種函數局部變量,它們在內存中是連續放置的。當Python需要為函數申請局部變量的內存空間時,就需要通過co_nlocals知道局部變量的總數。不過既然如此,那還要co_argcount干什么呢?別急,看個例子

def foo(a, b, c, d=1):
    pass

print(foo.__code__.co_argcount)  # 4
print(foo.__code__.co_nlocals)  # 4


def foo(a, b, c, d=1):
    a = 1
    b = 1

print(foo.__code__.co_argcount)  # 4
print(foo.__code__.co_nlocals)  # 4


def foo(a, b, c, d=1):
    aa = 1

print(foo.__code__.co_argcount)  # 4
print(foo.__code__.co_nlocals)  # 5

函數的參數也是一個局部變量,因此co_nlocals是參數的個數加上函數體中新創建的局部變量的個數。注意函數參數也是一個局部變量,比如參數有一個a,但是函數體里面的變量還是a,相當於重新賦值了,因此還是相當於一個參數。但是co_argcount則是存儲記錄參數的個數。因此一個很明顯的結論:對於任意一個函數,co_nlocals至少是大於等於co_argcount的

def foo(a, b, c, d=1, *args, **kwargs):
    pass


print(foo.__code__.co_argcount)  # 4
print(foo.__code__.co_nlocals)  # 6

另外我們看到,對於擴展位置參數、擴展關鍵字參數來說,co_argcount是不算在內的,因為你完全可以不傳遞,所以直接當成0來算。而對於co_nlocals來說,我們在函數體內部肯定是能拿到args和kwargs的,而這可以看成是兩個參數。因此co_argcount是4,co_nlocals是6。其實所有的擴展位置參數是存在了一個PyTupleObject對象中的,所有的擴展關鍵字參數是存儲在一個PyDictObject對象中的。而即使我們多傳、或者不傳,對於co_argcount和co_nlocals來說,都不會有任何改變了,因為這兩者的值是在編譯的時候就已經確定了的。

位置參數的傳遞

下面我們就來看看位置參數是如何傳遞的:

s = f"""
def f(name, age):
    age = age + 5
    print(name, age)

age = 5
f("satori", age)
"""

if __name__ == '__main__':
    import dis 
    dis.dis(compile(s, "call_function", "exec"))

字節碼如下,我們來分析一下,當然基礎的就一筆帶過了。

  2           0 LOAD_CONST               0 (<code object f at 0x00000224C3941450, file "call_function", line 2>)
              2 LOAD_CONST               1 ('f')
              4 MAKE_FUNCTION            0
              6 STORE_NAME               0 (f)

  6           8 LOAD_CONST               2 (5)
             10 STORE_NAME               1 (age)

  7          12 LOAD_NAME                0 (f)
             14 LOAD_CONST               3 ('satori')
             16 LOAD_NAME                1 (age)
             18 CALL_FUNCTION            2 //oparg是2, 表示調用的時候傳遞了兩個參數
             20 POP_TOP
             22 LOAD_CONST               4 (None)
             24 RETURN_VALUE

Disassembly of <code object f at 0x00000224C3941450, file "call_function", line 2>:
  3           0 LOAD_FAST                1 (age) //此時age和對應的值已經存在函數的符號表和常量池當中了
              2 LOAD_CONST               1 (5)  //加載常量5
              4 BINARY_ADD
              6 STORE_FAST               1 (age) //相加之后, 重新使用age保存

  4           8 LOAD_GLOBAL              0 (print) //加載print
             10 LOAD_FAST                0 (name) //加載局部變量name和age
             12 LOAD_FAST                1 (age)
             14 CALL_FUNCTION            2  //函數調用,顯然是print, 參數是兩個
             16 POP_TOP
             18 LOAD_CONST               0 (None)
             20 RETURN_VALUE

字節碼雖然解釋完了, 但是最重要的還是沒有說。f(name, age),這里的name和age顯然是外層定義的,但是外層定義的這兩個變量是怎么傳給函數f的。下面我們通過源碼重新分析:

  7          12 LOAD_NAME                0 (f)
             14 LOAD_CONST               3 ('satori')
             16 LOAD_NAME                1 (age)

我們注意到CALL_FUNCTION上面有三條指令,其實當這三條指令執行完畢之后,函數需要的參數已經被壓入了運行時棧中。

通過 _PyFunction_FastCallDict 函數,然后執行function_code_fastcall。

//Objects/call.c
static PyObject* _Py_HOT_FUNCTION
function_code_fastcall(PyCodeObject *co, PyObject *const *args, Py_ssize_t nargs,
                       PyObject *globals)
{
    PyFrameObject *f; //棧幀對象
    PyThreadState *tstate = _PyThreadState_GET(); //線程狀態對象
    PyObject **fastlocals; //f->localsplus, 后面會說
    Py_ssize_t i;
    PyObject *result;

    assert(globals != NULL);
    /* XXX Perhaps we should create a specialized
       _PyFrame_New_NoTrack() that doesn't take locals, but does
       take builtins without sanity checking them.
       */
    assert(tstate != NULL);
    //創建與函數對應的PyFrameObject,我們看到參數是co,所以是根據字節碼指令來創建的
    //然后還有一個globals, 表示global名字空間, 所以我們看到最后實際上沒有PyFunctionObject什么事, 它只是起到一個輸送的作用
    f = _PyFrame_New_NoTrack(tstate, co, globals, NULL);
    if (f == NULL) {
        return NULL;
    }

    fastlocals = f->f_localsplus;

    for (i = 0; i < nargs; i++) {
        Py_INCREF(*args);
        fastlocals[i] = *args++;
    }
    //關鍵:拷貝函數參數,從運行時棧到PyFrameObject.f_localsplus
    result = PyEval_EvalFrameEx(f,0);

    if (Py_REFCNT(f) > 1) {
        Py_DECREF(f);
        _PyObject_GC_TRACK(f);
    }
    else {
        ++tstate->recursion_depth;
        Py_DECREF(f);
        --tstate->recursion_depth;
    }
    return result;
}

從源碼中我們看到通過 _PyFrame_New_NoTrack 創建了函數f對應的PyFrameObject對象,參數是co對應的PyFunctionObject對象中保存的PyCodeObject對象。隨后,Python虛擬機將參數逐個拷貝到新建的PyFrameObject對象的f_localsplus中。可在分析Python虛擬機框架時,我們知道,這個f_localsplus所指向的內存塊里面也存儲了Python虛擬機所使用的那個運行時棧。那么參數所占的內存和運行時棧所占的內存有什么關聯呢?

//frameobject.c

//這個是_PyFrame_New_NoTrack,對外暴露的是PyFrame_New,但是本質上調用了這個
PyFrameObject* _Py_HOT_FUNCTION
_PyFrame_New_NoTrack(PyThreadState *tstate, PyCodeObject *code,
                     PyObject *globals, PyObject *locals)
{
    PyFrameObject *back = tstate->frame;
    PyFrameObject *f;
    PyObject *builtins;
    Py_ssize_t i;

    //...
    //...
        Py_ssize_t extras, ncells, nfrees;
        ncells = PyTuple_GET_SIZE(code->co_cellvars);
        nfrees = PyTuple_GET_SIZE(code->co_freevars);
        extras = code->co_stacksize + code->co_nlocals + ncells + nfrees;
        if (free_list == NULL) {
            //為f_localsplus申請內存空間, 大小為extras, 注意這個extras, 我們看到它實際上分為四個部分
            //分別是: 運行時棧、局部變量、cell對象、free對象, 注意:但在內存中它們可不是這個順序
            f = PyObject_GC_NewVar(PyFrameObject, &PyFrame_Type,
            extras);
            if (f == NULL) {
                Py_DECREF(builtins);
                return NULL;
            }
        }
        else {
            //...
        }
		
        f->f_code = code;
        //獲取局部變量的個數 + cell對象的個數 + free對象的個數
        extras = code->co_nlocals + ncells + nfrees;
        f->f_valuestack = f->f_localsplus + extras;
        for (i=0; i<extras; i++)
            f->f_localsplus[i] = NULL;
        f->f_locals = NULL;
        f->f_trace = NULL;
    }
    //...
    f->f_lasti = -1;
    f->f_lineno = code->co_firstlineno;
    f->f_iblock = 0;
    f->f_executing = 0;
    f->f_gen = NULL;
    f->f_trace_opcodes = 0;
    f->f_trace_lines = 1;

    return f;
}

前面提到,在函數對應的PyCodeObject對象的co_nlocals域中,包含着函數參數的個數,因為函數參數也是局部符號的一種。所以從f_localsplus開始,extras中一定有供函數參數使用的內存。或者說,函數的參數存放在運行時棧之前的那段內存中。

另外從_PyFrame_New_NoTrack當中我們可以看到,在數組f_localsplus中存儲函數參數的空間和運行時棧的空間在邏輯上是分離的,並不是共享同一片內存,盡管它們是連續的,但這兩者是雞犬相聞,但又涇渭分明、老死不相往來。

在處理完參數之后,還沒有進入PyEval_EvalFrameEx,所以此時運行時棧是空的。但是函數的參數已經位於f_localsplus中了。所以這時新建PyFrameObject對象的f_localsplus就是這樣:

位置參數的訪問

當參數拷貝的動作完成之后,就會進入新的PyEval_EvalFrameEx,開始真正的f的調用動作。

  3           0 LOAD_FAST                1 (age) 
              2 LOAD_CONST               1 (5)  
              4 BINARY_ADD
              6 STORE_FAST               1 (age) 

首先對參數的讀寫,肯定是通過LOAD_FAST,LOAD_CONST,STORE_FAST這幾條指令集完成的。

//ceval.c
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag)
{
    ...
    ...
    fastlocals = f->f_localsplus;
    ...
}

//一個宏, 這里的fastlocals顯然就是f -> localsplus
#define GETLOCAL(i)     (fastlocals[i])

        case TARGET(LOAD_FAST): {
            //從fastlocals中獲取索引為oparg的值
            PyObject *value = GETLOCAL(oparg);
            if (value == NULL) {
                format_exc_check_arg(tstate, PyExc_UnboundLocalError,
                                     UNBOUNDLOCAL_ERROR_MSG,
                                     PyTuple_GetItem(co->co_varnames, oparg));
                goto error;
            }
            Py_INCREF(value);
            PUSH(value);
            FAST_DISPATCH();
        }


        case TARGET(STORE_FAST): {
            PREDICTED(STORE_FAST);
            PyObject *value = POP(); //彈出元素
            SETLOCAL(oparg, value);  //將索引為oparg的元素設置為value
            FAST_DISPATCH();
        }

所以我們發現,LOAD_FAST和STORE_FAST這一對指令是以f_localsplus這一片內存為操作目標的,指令0 LOAD_FAST 1 (age)的結果是將f_localsplus[1]對應的對象壓入到運行時棧中。而在完成加法操作之后,又將結果通過STORE_FAST放入到f_localsplus[1]中,這樣就實現了對a的更新,那么以后在print(a)的時候,得到的結果就是10了。

現在關於Python的位置參數在函數調用時是如何傳遞的、在函數執行又是如何被訪問的,已經真相大白了。在調用函數時,Python將函數參數的值從左至右依次壓入到運行時棧中,而在call_function中通過調用 _PyFunction_FastCallDict ,進而調用function_code_fastcall,而在function_code_fastcall中,又將這些參數依次拷貝到和PyFrameObject對象的f_localsplus中。最終的效果就是,Python虛擬機將函數調用時使用的參數,從左至右依次地存放在新建的PyFrameObject對象的f_localsplus中。

因此在訪問函數參數時,python虛擬機並沒有按照通常訪問符號的做法,去查什么名字空間,而是直接通過一個索引(偏移位置)來訪問f_localsplus中存儲的符號對應的值,是的,f_localsplus存儲的是符號(變量名),並不是具體的值。因為我們說Python中的變量只是一個指針,至於值是否改變,則取決於對應的值是可變對象還是不可變對象,而不是像其他編程語言那樣通過傳值或者傳指針來決定是否改變。因此這種通過索引(偏移位置)來訪問參數的方式也正是位置參數的由來。

默認參數

Python函數的一個特點就是支持默認參數,這是非常方便的,我們來看看實現機制。

s = """
def foo(a=1, b=2):
    print(a + b)

foo()
"""

if __name__ == '__main__':
    import dis
    dis.dis(compile(s, "default", "exec"))
  2           0 LOAD_CONST               5 ((1, 2))//我們看到在構造函數的時候就已經把默認值加載進來了
              2 LOAD_CONST               2 (<code object foo at 0x000002076ED83BE0, file "default", line 2>)
              4 LOAD_CONST               3 ('foo')
              6 MAKE_FUNCTION            1 (defaults) 
              8 STORE_NAME               0 (foo)

  5          10 LOAD_NAME                0 (foo)
             12 CALL_FUNCTION            0
             14 POP_TOP
             16 LOAD_CONST               4 (None)
             18 RETURN_VALUE

Disassembly of <code object foo at 0x000002076ED83BE0, file "default", line 2>:
  3           0 LOAD_GLOBAL              0 (print)
              2 LOAD_FAST                0 (a)
              4 LOAD_FAST                1 (b)
              6 BINARY_ADD
              8 CALL_FUNCTION            1
             10 POP_TOP
             12 LOAD_CONST               0 (None)
             14 RETURN_VALUE

我們對比一下開始的沒有默認參數的函數,會發現相比於無默認參數的函數,有默認參數的函數,除了load函數體對應的PyCodeObject、和foo這個符號之外,會先將默認參數的值給load進來,將這三者都壓入運行時棧。但是我們發現這是默認參數是組合成一個元組的形式入棧的,而且我們再來觀察一下MAKE_FUNCTION這個指令,我們發現后面的參數是1 (defaults),之前的都是0,那么這個1是什么呢?而且又提示了我們一個defaults,我們知道PyFunctionObject對象有一個func_defaults,這兩者之間有關系嗎?那么帶着這些疑問再來看看MAKE_FUNCTION指令。

        case TARGET(MAKE_FUNCTION): {
            PyObject *qualname = POP();
            PyObject *codeobj = POP();
            PyFunctionObject *func = (PyFunctionObject *)
                PyFunction_NewWithQualName(codeobj, f->f_globals, qualname);

            Py_DECREF(codeobj);
            Py_DECREF(qualname);
            if (func == NULL) {
                goto error;
            }

            if (oparg & 0x08) {
                assert(PyTuple_CheckExact(TOP()));
                func ->func_closure = POP();
            }
            if (oparg & 0x04) {
                assert(PyDict_CheckExact(TOP()));
                func->func_annotations = POP();
            }
            if (oparg & 0x02) {
                assert(PyDict_CheckExact(TOP()));
                func->func_kwdefaults = POP();
            }
            
            ////默認參數,我們發現確實是存儲在func_defaults里面
            if (oparg & 0x01) {
                assert(PyTuple_CheckExact(TOP()));
                func->func_defaults = POP();
            }

            PUSH((PyObject *)func);
            DISPATCH();
        }

通過以上命令我們很容易看出,MAKE_FUNCTION指令除了創建PyFunctionObject對象,並且還會處理參數的默認值。MAKE_FUNCTION指令參數表示當前運行時棧中是存在默認值的,但是默認值具體多少個通過參數是看不到的,因為默認值都會按照順序塞到一個PyTupleObject對象里面,所以整體相當於是一個。然后會調用PyFunction_SetDefaults將該PyTupleObject對象設置為PyFunctionObject.func_defaults的值,在Python層面可以使用__defaults__訪問。如此一來,函數參數的默認值也成為了PyFunctionObject對象的一部分,函數和其參數的默認值最終被Python虛擬機捆綁在了一起,它和PyCodeObject、global命名空間一樣,也被塞進了PyFunctionObject這個大包袱。所以說PyFunctionObject這個嫁衣做的是很徹底的,工具人PyFunctionObject對象,給個贊。

int
PyFunction_SetDefaults(PyObject *op, PyObject *defaults)
{
    if (!PyFunction_Check(op)) {
        PyErr_BadInternalCall();
        return -1;
    }
    if (defaults == Py_None)
        defaults = NULL;
    else if (defaults && PyTuple_Check(defaults)) {
        Py_INCREF(defaults);
    }
    else {
        PyErr_SetString(PyExc_SystemError, "non-tuple default args");
        return -1;
    }
    //將PyFunctionObject對象的func_defaults成員設置為defaults
    Py_XSETREF(((PyFunctionObject *)op)->func_defaults, defaults);
    return 0;
}

我們還是以這個foo函數為例,看看不同的調用方式對應的底層實現:

def foo(a=1, b=2):
    print(a + b)

不傳入參數,直接執行foo()

PyObject *
_PyFunction_FastCallDict(PyObject *func, PyObject *const *args, Py_ssize_t nargs,
                         PyObject *kwargs)
{	
    //獲取PyFunctionObject的PyCodeObject
    PyCodeObject *co = (PyCodeObject *)PyFunction_GET_CODE(func);
    //獲取PyFunctionObject的global名字空間
    PyObject *globals = PyFunction_GET_GLOBALS(func);
    //獲取PyFunctionObject的默認值
    PyObject *argdefs = PyFunction_GET_DEFAULTS(func);
    //一些額外屬性
    PyObject *kwdefs, *closure, *name, *qualname;
    PyObject *kwtuple, **k;
    PyObject **d;
    Py_ssize_t nd, nk;
    PyObject *result;

    assert(func != NULL);
    assert(nargs >= 0);
    assert(nargs == 0 || args != NULL);
    assert(kwargs == NULL || PyDict_Check(kwargs));
	
    //這里進行判斷能否進入快速通道, 一個函數如果想進入快速通道必須要滿足兩個條件
    //1. 函數定義的時候不可以有默認參數; 2. 函數調用時,必須都通過位置參數指定。
    //所以這里檢測co_kwonlyargcount和kwargs是否均為零
    if (co->co_kwonlyargcount == 0 &&
        (kwargs == NULL || PyDict_GET_SIZE(kwargs) == 0) &&
        (co->co_flags & ~PyCF_MASK) == (CO_OPTIMIZED | CO_NEWLOCALS | CO_NOFREE))
    {
        //然后繼續檢測: 這里的nargs是通過call_function函數傳遞的
        //而這個nargs在call_function函數中是Py_ssize_t nargs = oparg - nkwargs;
        //所以這里的nargs就是傳遞的參數個數減去通過關鍵字參數方式傳遞的參數個數
        //而co_argcount是函數參數的總個數,所以一旦哪怕有一個參數使用了關鍵字參數的方式傳遞,都會造成兩者不相等,從而無法進入快速通道
        if (argdefs == NULL && co->co_argcount == nargs) {
            return function_code_fastcall(co, args, nargs, globals);
        }
        
        
        //但是這樣的條件確實有點苛刻了,畢竟參數哪能沒有默認值呢?所以Python還提供了一種進入快速通道的方式
        //我們發現在有默認的前提下,如果還能滿足nargs==0 && co->co_argcount == PyTuple_GET_SIZE(argdefs)也能進入快速通道
        //co->co_argcount == PyTuple_GET_SIZE(argdefs)是要求函數的參數個數必須等於默認參數的個數,也就是函數參數全是默認參數
        //nargs==0則是需要傳入的參數個數減去通過關鍵字參數傳遞的參數個數等於0,即要么不傳參(都是用默認參數)、要么全部都通過關鍵字參數的方式傳參。
        //這種方式也可以進入快速通道
        else if (nargs == 0 && argdefs != NULL
                 && co->co_argcount == PyTuple_GET_SIZE(argdefs)) {
            args = _PyTuple_ITEMS(argdefs);
            return function_code_fastcall(co, args, PyTuple_GET_SIZE(argdefs),
                                          globals);
        }
    }
	
    
    //如果以上兩點都無法滿足的話,那么就沒辦法了,只能走常規方法了
    //獲取默認參數的信息
    nk = (kwargs != NULL) ? PyDict_GET_SIZE(kwargs) : 0;
    if (nk != 0) {
        Py_ssize_t pos, i;

        kwtuple = PyTuple_New(2 * nk);
        if (kwtuple == NULL) {
            return NULL;
        }

        k = _PyTuple_ITEMS(kwtuple);
        pos = i = 0;
        while (PyDict_Next(kwargs, &pos, &k[i], &k[i+1])) {
            Py_INCREF(k[i]);
            Py_INCREF(k[i+1]);
            i += 2;
        }
        assert(i / 2 == nk);
    }
    else {
        kwtuple = NULL;
        k = NULL;
    }
	
    //這里是獲取函數的一些屬性,默認關鍵字參數、閉包等等
    kwdefs = PyFunction_GET_KW_DEFAULTS(func);
    closure = PyFunction_GET_CLOSURE(func);
    name = ((PyFunctionObject *)func) -> func_name;
    qualname = ((PyFunctionObject *)func) -> func_qualname;
	
    //獲取默認參數的值的地址、以及默認參數的個數
    if (argdefs != NULL) {
        d = _PyTuple_ITEMS(argdefs);
        nd = PyTuple_GET_SIZE(argdefs);
    }
    else {
        d = NULL;
        nd = 0;
    }
	
    //調用_PyEval_EvalCodeWithName, 傳入函數的PyCodeObject對象以及參數信息
    result = _PyEval_EvalCodeWithName((PyObject*)co, globals, (PyObject *)NULL,
                                      args, nargs, 
                                      k, k != NULL ? k + 1 : NULL, nk, 2,
                                      d, nd, kwdefs,
                                      closure, name, qualname);
    Py_XDECREF(kwtuple);
    return result;
}

_PyEval_EvalCodeWithName是一個非常重要的函數,在后面分析擴展位置參數和擴展關鍵字參數是還會遇到。

PyObject *
_PyEval_EvalCodeWithName(PyObject *_co, PyObject *globals, PyObject *locals,
           PyObject *const *args, Py_ssize_t argcount,
           PyObject *const *kwnames, PyObject *const *kwargs,
           Py_ssize_t kwcount, int kwstep,
           PyObject *const *defs, Py_ssize_t defcount,
           PyObject *kwdefs, PyObject *closure,
           PyObject *name, PyObject *qualname)
{	
    //PyCodeObject對象, 通過_PyFunction_FastCallDict中接收的func得到
    PyCodeObject* co = (PyCodeObject*)_co;
    //棧幀
    PyFrameObject *f;
    //返回值
    PyObject *retval = NULL;
    //f -> localsplus, 和co -> co_freevars, 這個co_freevars、以及co_freevars都是與閉包相關的
    PyObject **fastlocals, **freevars;
    PyObject *x, *u;
    //參數總個數: 可以通過位置參數傳遞的參數個數  +  只能通過關鍵字參數傳遞的參數個數
    const Py_ssize_t total_args = co->co_argcount + co->co_kwonlyargcount;
    Py_ssize_t i, j, n;
    PyObject *kwdict;

    PyThreadState *tstate = _PyThreadState_GET();  //獲取線程狀態對象
    assert(tstate != NULL);

    if (globals == NULL) {
        _PyErr_SetString(tstate, PyExc_SystemError,
                         "PyEval_EvalCodeEx: NULL globals");
        return NULL;
    }
	
    //創建棧幀
    f = _PyFrame_New_NoTrack(tstate, co, globals, locals);
    if (f == NULL) {
        return NULL;
    }
    fastlocals = f->f_localsplus;
    freevars = f->f_localsplus + co->co_nlocals;
	
    //還記得這個co_flags嗎? 我們說它是用來判斷參數的, 如果它和0x08進行"與運算"結果為真, 那么說明有**kwargs
    //如果它和0x04進行"與運算"結果為真, 那么說明有*args
    if (co->co_flags & CO_VARKEYWORDS) {
        kwdict = PyDict_New();
        if (kwdict == NULL)
            goto fail;
        i = total_args;
        if (co->co_flags & CO_VARARGS) {
            i++;
        }
        SETLOCAL(i, kwdict);
    }
    else {
        kwdict = NULL;
    }
	
    //argcount是實際傳來的位置參數的個數,co->co_argcount則是可以通過位置參數傳遞的參數個數
    //如果argcount > co->co_argcount,證明有擴展參數,否則沒有    
    if (argcount > co->co_argcount) {
        //所以這里的n等於co->co_argcount
        n = co->co_argcount;
    }
    else {
        //沒有擴展位置參數, 那么調用者通過位置參數的方式傳了幾個、n就是幾
        n = argcount;
    }
    
    
    //然后我們仔細看一下這個n,假設我們定義了一個函數def foo(a, b, c=1,d=2, *args)
    //如果argcount > co->co_argcount, 說明我們傳遞的位置參數的個數超過了4個,但n是4
    //但是如果我們只傳遞了兩個,比如foo('a', 'b'),那么n顯然為2
    //下面就是將已經傳遞的參數的值依次設置到f_localsplus里面去,這里的j就是索引,x就是值。
    for (j = 0; j < n; j++) {
        x = args[j];
        Py_INCREF(x);
        SETLOCAL(j, x);
    }
	
    //下面顯然是擴展位置參數參數的邏輯,我們暫時先跳過,后面會說
    if (co->co_flags & CO_VARARGS) {
        u = _PyTuple_FromArray(args + n, argcount - n);
        if (u == NULL) {
            goto fail;
        }
        SETLOCAL(total_args, u);
    }
	
    //關鍵字參數,同樣后面說
    kwcount *= kwstep;
    for (i = 0; i < kwcount; i += kwstep) {
       //......

    //這里會再進行檢測,argcount > co->co_argcount說明我們多傳遞了, 然后檢測是否存在*args
    //如果co->co_flags & CO_VARARGS為False, 那么直接報錯
    if ((argcount > co->co_argcount) && !(co->co_flags & CO_VARARGS)) {
        too_many_positional(tstate, co, argcount, defcount, fastlocals);
        goto fail;
    }
	
    //如果傳入的參數個數比函數定義的參數的個數少,那么證明有默認參數。
    //defcount表示設置了默認參數的個數
    if (argcount < co->co_argcount) {
        //顯然m = 參數總個數(不包括*args和**kwargs之外的所有形參的個數) - 默認參數的個數
        Py_ssize_t m = co->co_argcount - defcount;
        Py_ssize_t missing = 0;
        //因此m就是需要傳遞的沒有默認值的參數的總個數
        for (i = argcount; i < m; i++) {
            //而i=argcount則是我們調用函數時傳遞的位置參數的總個數,很明顯如果參數足夠,那么 i < m 是不會滿足的
            //比如一個函數接收6個參數,但是有兩個是默認參數,因此這就意味着調用者通過位置參數的方式傳遞的話,需要至少傳遞4個,那么m就是4
            //而如果我們也傳遞了四個,那么初始的i同樣是 4
            if (GETLOCAL(i) == NULL) {
                //但如果我們只傳遞了兩個,那么通過GETLOCAL從f -> f_localsplus中就會獲取不到值
                //而一旦找不到,missing:缺少的參數個數就會+1
                missing++;
            }
        }
        //那么按照我們上面的邏輯,顯然還有兩個沒傳遞,但是它們會使用默認值
        //如果是只傳遞了3個參數, 那么顯然還有3個參數沒有傳, 但默認值只有兩個, 因此missing不為0
        if (missing) {
            //直接拋出異常
            missing_arguments(tstate, co, missing, defcount, fastlocals);
            goto fail;
        }
        
        
        //下面可能難理解,我們說這個m,是需要由調用者傳遞的參數個數
        //而n是以位置參數的形式傳遞過來的參數的個數,如果比函數參數個數少,那么n就是傳來的參數個數,如果比函數參數的個數大,那么n則是函數參數的個數。比如:
        /*
        def foo(a, b, c, d=1, e=2, f=3):
        	pass
        這是一個有6個參數的函數,顯然m是3,實際上函數定義好了,m就是一個不變的值了,就是沒有默認參數的參數總個數
        但是我們調用時可以是foo(1,2,3),也就是只傳遞3個,那么這里的n就是3,
        foo(1, 2, 3, 4, 5),那么顯然n=5,而m依舊是3
        */        
        if (n > m)
            //因此現在這里的邏輯就很好理解了,假設調用時foo(1, 2, 3, 4, 5)
            //由於有3個是默認參數,那么調用只傳遞3個就可以了,但是這里傳遞了5個,前3個是必傳的
            //至於后面兩個,則說明我不想使用默認值,我想重新傳遞,而使用默認值的只有最后一個
            //所以這個i就是明明可以使用默認值、但卻沒有使用的參數的個數            
            i = n - m;
        else
            //另外如果按照位置參數傳遞的話,程序能走到這一步,說明已經不存在少傳的情況了
            //因此這個n至少是>=m的,因此如果n == m的話,那么i就是0
            i = 0;
        for (; i < defcount; i++) {     
            //默認參數的值一開始就已經被壓入棧中,整體作為一個PyTupleObject對象,被設置到了func_defaults這個域中
            //但是對於函數的參數來講,肯定還要設置到f_localsplus里面去,並且它只能是在后面。
            //因為默認參數的順序要在非默認參數之后            
            if (GETLOCAL(m+i) == NULL) {
                //這里是把索引為i對應的值從func_defaults對應的PyTupleObject里面取出來
                //這個i要么是n-m,要么是0。還按照之前的例子,函數接收6個參數,但是我們傳了5個
                //因此我們只需要將最后一個、也就是索引為2的元素拷貝到f_localsplus里面去即可。
                //而n=5,m=3,顯然i = 2
                //那么如果我們傳遞了3個呢?
                //顯然i是0,因為此時n==m嘛,那么就意味着默認參數都使用默認值,既然這樣,那就從頭開始開始拷唄。
                //同理傳了4個參數,證明第一個默認參數的默認值是不需要的,那么就只需要再把后面兩個拷過去就可以了
                //那么顯然要從索引為1的位置拷到結束,而此時n-m、也就是i,正好為1
                //所以,n-m就是"默認參數值組成的PyTupleObject對象中需要拷貝到f_localsplus中的第一個值的索引"
                //然后i < defcount; i++,一直拷到結尾                             
                PyObject *def = defs[i];
                Py_INCREF(def);
                //將值設置到f_localsplus里面,這里顯然索引是m+i
                //比如:def foo(a,b,c,d=1,e=2,f=3)
                //foo(1, 2, 3, 4),顯然d不會使用默認值,那么只需要把后兩個默認值拷給e和f即可
                //顯然e和f根據順序在f_localsplus中對應索引為4、5
                //m是3,i是n-m等於4-3等於1,所以m+i正好是4,
                //f_localsplus: [1, 2, 3, 4]
                //PyTupleObject:(1, 2, 3)
                //因此PyTupleObject中索引為i的元素,拷貝到f_localsplus中正好是對應m+i的位置               
                SETLOCAL(m+i, def);
            }
        }
    }

    //........
    return retval;
}

因此通過以上我們就知道了位置參數的默認值是怎么一回事了。

傳入一個關鍵字參數,執行foo(b=3)

在對foo進行第二次調用的時候,我們指定了b=3,但是調用方式本質是一樣的。在CALL_FUNCTION之前,python虛擬機將PyUnicodeObject對象b和PyLongObject對象3壓入了運行時棧。

PyObject *
_PyEval_EvalCodeWithName(PyObject *_co, PyObject *globals, PyObject *locals,
           PyObject *const *args, Py_ssize_t argcount,
           PyObject *const *kwnames, PyObject *const *kwargs,
           Py_ssize_t kwcount, int kwstep,
           PyObject *const *defs, Py_ssize_t defcount,
           PyObject *kwdefs, PyObject *closure,
           PyObject *name, PyObject *qualname)
{	
    PyCodeObject* co = (PyCodeObject*)_co;
    PyFrameObject *f;
    //.......
    f = _PyFrame_New_NoTrack(tstate, co, globals, locals);
    //......
    if (co->co_flags & CO_VARKEYWORDS) {
        //.......
    }
    else {
        //.......
    }

    if (argcount > co->co_argcount) {
        n = co->co_argcount;
    }
    else {
        n = argcount;
    }
    for (j = 0; j < n; j++) {
        //.......
    }

    if (co->co_flags & CO_VARARGS) {
        //......
    }

    /* 遍歷關鍵字參數,確定函數的def語句中是否出現了關鍵字參數的名字 */
    kwcount *= kwstep;
    for (i = 0; i < kwcount; i += kwstep) {
        PyObject **co_varnames;  //符號表
        PyObject *keyword = kwnames[i]; //獲取參數名
        PyObject *value = kwargs[i];  //獲取參數值
        Py_ssize_t j;
		
        //顯然參數必須是字符串, 所以在字典中你可以這么做: {**{1: "a", 2: "b"}}
        //但你不可以這么做: dict(**{1: "a", 2: "b"})
        if (keyword == NULL || !PyUnicode_Check(keyword)) {
            _PyErr_Format(tstate, PyExc_TypeError,
                          "%U() keywords must be strings",
                          co->co_name);
            goto fail;
        }
		
        //這里的邏輯我們后面會詳細說, 總之核心就是檢測一個參數是否同時通過位置參數和關鍵字參數傳遞了, 也就是判斷是否傳遞了兩次
        co_varnames = ((PyTupleObject *)(co->co_varnames))->ob_item;
        //在函數的符號表中尋找關鍵字參數, 注意: 這里的j不是從0開始的, 而是從posonlyargcount開始
        //因為在Python3.8中引入了/, 在/前面的參數只能通過位置參數傳遞
        for (j = co->co_posonlyargcount; j < total_args; j++) {
            PyObject *name = co_varnames[j];
            if (name == keyword) {
                goto kw_found;
            }
        }

        /* 邏輯和上面一樣 */
        for (j = co->co_posonlyargcount; j < total_args; j++) {
            PyObject *name = co_varnames[j];
            int cmp = PyObject_RichCompareBool( keyword, name, Py_EQ);
            if (cmp > 0) {
                goto kw_found;
            }
            else if (cmp < 0) {
                goto fail;
            }
        }
			
        assert(j >= total_args);
        if (kwdict == NULL) {
			
            //如果符號表中沒有出現指定的符號, 那么表示出現了一個不需要的關鍵字參數(當然**kwargs后面說)
            if (co->co_posonlyargcount
                && positional_only_passed_as_keyword(tstate, co,
                                                     kwcount, kwnames))
            {
                goto fail;
            }

            _PyErr_Format(tstate, PyExc_TypeError,
                          "%U() got an unexpected keyword argument '%S'",
                          co->co_name, keyword);
            goto fail;
        }

        if (PyDict_SetItem(kwdict, keyword, value) == -1) {
            goto fail;
        }
        continue;

      kw_found:
        if (GETLOCAL(j) != NULL) {
            _PyErr_Format(tstate, PyExc_TypeError,
                          "%U() got multiple values for argument '%S'",
                          co->co_name, keyword);
            goto fail;
        }
        Py_INCREF(value);
        SETLOCAL(j, value);
    }

    //.......
    return retval;
}

在編譯時:Python會將函數的def語句中出現的符號都記錄在符號表(co_varnames)里面。由於我們已經看到,在foo(b=3)的指令序列中,Python虛擬機在執行CALL_FUNCTION指令之前會將關鍵字參數的名字都壓入到運行時棧,那么在_PyEval_EvalCodeWithName中就能利用運行時棧中保存的關鍵字參數的名字在Python編譯時得到的co_varnames中進行查找。最妙的是,在co_varnames中定義的變量名的順序是由規律的的。而且經過剛才的分析,我們也知道,在PyFrameObject對象的f_localsplus所維護的內存中,用於存儲函數參數的內存也是按照相同規律排列的。所以在co_varnames中搜索到關鍵字參數的參數名時,我們可以直接根據所得到的序號信息直接設置f_localsplus中的內存,這就為默認參數設置了函數調用者希望的值。

因此我們可以再舉個簡單例子,總結一下。def foo(a, b, c, d=1,e=2, f=3),對於這樣的一個函數。首先Python虛擬機知道調用者至少要給a、b、c傳遞參數。如果是foo(1),那么1會傳遞給a,但是b和c是沒有接受到值的,所以報錯。但如果是foo(1, e=4, c=2, b=3),還是老規矩1傳遞給a,發現依舊不夠,這時候會把希望寄托於關鍵字參數上。並且我們說過f_localsplus維護的內存中存儲的參數的順序、co_varnames中參數的順序都是一致的。所以關鍵字參數是不講究順序的,當找到了e=4,那么Python虛擬機通過co_varnames符號表,就知道把e設置為f_localsplus中索引為4的地方,c=2,設置為索引為2的地方,b=3,設置為索引為1的地方。那么當位置參數和關鍵字參數都是設置完畢之后,python虛擬機會檢測需要傳遞的參數、也就是沒有默認值的參數,調用者有沒有全部傳遞。

但是這里再插一句,我們說關鍵字參數設置具體設置在f_localsplus中的哪一個地方,是通過將關鍵字參數名代入到co_varnames符號表里面查找所得到的的,但是如果這個關鍵字參數的參數名不在co_varnames里面,怎么辦?另外在我們講位置參數的時候,如果傳遞的位置參數,比co_argcount還要多,怎么辦?對,聰明如你,肯定知道了,就是我們下面要介紹擴展關鍵字、擴展位置參數。

擴展位置參數和擴展關鍵字參數

之前我們看到了使用擴展位置參數和擴展關鍵字參數時指令參數個數的值,我們還是再看一遍吧。

def foo(a, b, *args, **kwargs):
    pass


print(foo.__code__.co_nlocals)  # 4
print(foo.__code__.co_argcount)  # 2

我們看到對於co_nlocals來說,它統計的是所有局部變量的個數,結果是4;但是對於co_argcount來說,統計的是不包括*args個**kwargs的所有參數的個數,因此結果是2。既然如此,那么也如我們之前所分析的,*args可以接收多個位置參數,但是最終這些參數都會放在args這個PyTupleObject對象里面;**kwargs可以接收多個關鍵字參數,但是這些關鍵字參數會組成一個PyDictObject對象,由kwargs指向。事實上也確實如此,即使不從源碼的角度來分析,從Python的實際使用中我們也能得出這個結論。

def foo(*args, **kwargs):
    print(args)
    print(kwargs)


foo(1, 2, 3, a=1, b=2, c=3)
"""
(1, 2, 3)
{'a': 1, 'b': 2, 'c': 3}
"""

foo(*(1, 2, 3), **{"a": 1, "b": 2, "c": 3})
"""
(1, 2, 3)
{'a': 1, 'b': 2, 'c': 3}
"""

當然啦,在傳遞的時候如果對一個元組或者列表、甚至是字符串使用*,那么會將這個可迭代對象直接打散,相當於傳遞了多個位置參數。同理如果對一個字典使用**,那么相當於傳遞了多個關鍵字參數。

下面我們就來看看擴展參數是如何實現的,首先還是進入到 _PyEval_EvalCodeWithName 這個函數里面來,當然這個函數應該很熟悉了,我們看看擴展參數的處理。

PyObject *
_PyEval_EvalCodeWithName(PyObject *_co, PyObject *globals, PyObject *locals,
           //位置參數的相關信息
           PyObject *const *args, Py_ssize_t argcount,
           //關鍵字參數的相關信息                 
           PyObject *const *kwnames, PyObject *const *kwargs,
           Py_ssize_t kwcount, int kwstep, //關鍵字參數個數
           PyObject *const *defs, Py_ssize_t defcount,
           //默認值、閉包、函數名、全限定名等信息              
           PyObject *kwdefs, PyObject *closure,
           PyObject *name, PyObject *qualname)
{
    PyCodeObject* co = (PyCodeObject*)_co;//拿到PyFunctionObject的PyCodeObject
    PyFrameObject *f;//聲明一個PyFrameObject
    PyObject *retval = NULL;
    PyObject **fastlocals, **freevars;
    PyObject *x, *u;
    //獲取總參數的個數
    const Py_ssize_t total_args = co->co_argcount + co->co_kwonlyargcount;
    Py_ssize_t i, j, n;
    PyObject *kwdict;
    //........
    //創建一個棧幀
    f = _PyFrame_New_NoTrack(tstate, co, globals, locals);
    if (f == NULL) {
        return NULL;
    }
    
    //函數的所有參數
    fastlocals = f->f_localsplus;
    //閉包
    freevars = f->f_localsplus + co->co_nlocals;

    //判斷是否傳遞擴展關鍵字參數,CO_VARKEYWORDS和下面的CO_VARARGS都是標識符
    //用於判斷是否出現了擴展關鍵字參數和擴展位置參數
    if (co->co_flags & CO_VARKEYWORDS) {
        //創建一個字典
        kwdict = PyDict_New();
        if (kwdict == NULL)
            goto fail;
        //i是參數總個數,假設值foo(a, b, c, *args, **kwargs)
        i = total_args;
        //如果還傳遞了擴展位置參數,那么i要加上1
        //因為即使是擴展,關鍵字參數依舊要在位置參數后面
        if (co->co_flags & CO_VARARGS) {
            i++;
        }
	    //如果沒有擴展位置參數,那么kwdict要處於索引為3的位置
        //有擴展位置參數,那么kwdit處於索引為4的位置,這顯然是合理的
        //然后放到f_localsplus中        
        SETLOCAL(i, kwdict);
    }
    else {
        //如果沒有的話,那么為NULL
        kwdict = NULL;
    }

    /* 這里我們之前介紹了,是將位置參數拷貝到本地(顯然這里不包含擴展位置參數) */
    if (argcount > co->co_argcount) {
        n = co->co_argcount;
    }
    else {
        n = argcount;
    }
    for (j = 0; j < n; j++) {
        x = args[j];
        Py_INCREF(x);
        SETLOCAL(j, x);
    }

    /* 關鍵來了,將多余的位置參數拷貝到*args里面去 */
    if (co->co_flags & CO_VARARGS) {
        //申請一個argcount - n大小的元組
        u = _PyTuple_FromArray(args + n, argcount - n);
        if (u == NULL) {
            goto fail;
        }
        //放到f -> f_localsplus里面去
        SETLOCAL(total_args, u);
    }

    //下面就是拷貝擴展關鍵字參數,但是我們發現這里是從兩個數組中分別得到符號和值的信息的
    //因此再結合最上面的變量聲明,我們就明白了,我們傳遞的關鍵字參數並不是上來就設置到字典里面
    //而是將符號和值各自存儲在對應的數組里面,顯然就是下面的kwnames和kwargs
    //然后使用索引遍歷,按照順序依次取出,通過比較傳遞的關鍵字參數的符號是否已經出現在函數定義的參數中
    //來判斷傳遞的這個參數究竟是普通的關鍵字參數,還是擴展關鍵字參數
    //比如:def foo(a, b, c, **kwargs),那么foo(1, 2, c=3, d=4)
    //那么顯然關鍵字參數有兩個c=3和d=4,那么c已經出現在了函數定義的參數中,所以c就是一個普通的關鍵字參數
    //但是d沒有,所有d同時也是擴展關鍵字參數,因此要設置到kwargs這個字典里面
    kwcount *= kwstep;
    //按照索引,依次遍歷
    for (i = 0; i < kwcount; i += kwstep) {
        PyObject **co_varnames; //符號表
        PyObject *keyword = kwnames[i];//關鍵字參數的key
        PyObject *value = kwargs[i];//關鍵字參數的value
        Py_ssize_t j;

        if (keyword == NULL || !PyUnicode_Check(keyword)) {
            _PyErr_Format(tstate, PyExc_TypeError,
                          "%U() keywords must be strings",
                          co->co_name);
            goto fail;
        }

        //拿到符號表,得到所有的符號,這樣就知道函數參數都有哪些
        co_varnames = ((PyTupleObject *)(co->co_varnames))->ob_item;
        //我們看到內部又是一層for循環
        //首先外層循環是遍歷所有的關鍵字參數,也就是我們傳遞的參數
        //而內層循環則是遍歷函數的除了僅限位置參數之外的所有參數
        for (j = co->co_posonlyargcount; j < total_args; j++) {
            //將我們傳來每一個關鍵字參數的符號都會和符號表中的所有符號進行比對
            PyObject *name = co_varnames[j];
            //如果相等,說明傳遞的是關鍵字參數,並不是擴展關鍵字參數
            if (name == keyword) {
                //然后kw_found這個label中, 會檢測對應的參數有沒有通過位置參數傳遞
                //如果已經位置參數傳遞了, 那么顯然一個參數被傳遞了兩次
                goto kw_found;
            }
        }

        /* 邏輯和上面一樣 */
        for (j = co->co_posonlyargcount; j < total_args; j++) {
            PyObject *name = co_varnames[j];
            int cmp = PyObject_RichCompareBool( keyword, name, Py_EQ);
            if (cmp > 0) {
                goto kw_found;
            }
            else if (cmp < 0) {
                goto fail;
            }
        }

        assert(j >= total_args);
        //走到這里,說明肯定傳入了符號不在符號表co_varnames里面的關鍵字參數
        //如果kwdict是NULL,證明根本函數根本沒定義擴展參數,那么就直接報錯了
        if (kwdict == NULL) {

            if (co->co_posonlyargcount
                && positional_only_passed_as_keyword(tstate, co,
                                                     kwcount, kwnames))
            {
                goto fail;
            }

            _PyErr_Format(tstate, PyExc_TypeError,
                          "%U() got an unexpected keyword argument '%S'",
                          co->co_name, keyword);
            goto fail;
        }
		
        //這里將屬於擴展關鍵字參數的keyword和value都設置到之前創建的字典里面去
        //然后continue進入下一個關鍵字參數邏輯
        if (PyDict_SetItem(kwdict, keyword, value) == -1) {
            goto fail;
        }
        continue;

      kw_found:
        //之前我們說,如果不是擴展,而是普通關鍵字參數那么會走這一步
        //獲取對應的符號,但是發現不為NULL,說明已經通過位置參數傳遞了
        if (GETLOCAL(j) != NULL) {
            //那么這里就報出一個TypeError,表示某個參數接收了多個值
            _PyErr_Format(tstate, PyExc_TypeError,
                          "%U() got multiple values for argument '%S'",
                          co->co_name, keyword);
            //比如說:def foo(a, b, c=1, d=2)
            //如果這樣傳遞的foo(1, 2, c=3),那么肯定沒問題
            /*
            因為開始會把位置參數拷貝到f_localsplus里面,所以此時f_localsplus是[a, b, NULL, NULL]
            然后設置關鍵字參數的時候,此時的j對應索引為2,那么GETLOCAL(j)是NULL,所以不會報錯
            */
            //但如果這樣傳遞,foo(1, 2, 3, c=3)
            //那么不好意思,此時f_localsplus則是[a, b, c, NULL],GETLOCAL(j)是c,不為NULL
            //說明c這個位置已經有人傳遞了,那么關鍵字參數就不能傳遞了
            //還是那句話f_localsplus存儲的是符號,每一個符號都會對應相應的值,這些順序都是一致的            
            goto fail;
        }
        Py_INCREF(value);
        SETLOCAL(j, value);
    }

    /* 這里檢測位置參數是否多傳遞了 */
    if ((argcount > co->co_argcount) && !(co->co_flags & CO_VARARGS)) {
        too_many_positional(tstate, co, argcount, defcount, fastlocals);
        goto fail;
    }

    //......
    return retval;
}

Python在對參數進行處理的時候,機制還是很復雜的。我們知道Python在定義函數的時候,通過/可以使得/前面的參數必須通過位置參數傳遞,通過*可以使得*后面的參數必須通過位置參數傳遞,而我們在分析的時候是沒有考慮這一點的。

其實擴展關鍵字參數的傳遞機制和普通關鍵字參數的傳遞機制有很大的關系,我們之前分析函數參數的默認值機制已經看到了關鍵字參數的傳遞機制,這里我們再次看到了。對於關鍵字參數,不論是否擴展,都會把符號和值分別按照對應順序放在兩個數組里面。然后Python會按照索引的順序,遍歷存放符號的數組,對每一個符號都會和符號表co_varnames里面的符號逐個進行比對,發現在符號表中找不到我們傳遞的關鍵字參數的符號,那么就說明這是一個擴展關鍵字參數。然后就是我們在源碼中看到的那樣,如果函數定義了**kwargs,那么kwdict就不為空,會把擴展關鍵字參數直接設置進去,否則就報錯了,提示接收到了一個不期待的關鍵字參數。

而且Python虛擬機也確實把該PyDictObject對象(kwargs)放到了f_localsplus中,這個f_localsplus里面包含了所有的參數,不管是什么參數,都會在里面。但是kwargs一定是在最后面,至於*args理論上是沒有順序的,你是可以這么定義的:def foo(a, *args, b),這樣定義是完全沒有問題的,只是此時的b就必須要通過關鍵字參數來傳遞了,因為如果不通過關鍵字參數的方式,那么無論多少個位置參數,都會止步於*args。之前也介紹過,假設只需要name,age, gender這三個參數,並且gender必須要通過關鍵字參數指定的話,那么就可以這么設計:def foo(name, age, *, gender),我們看到連args都省去了,只保留一個*,這是因為我們定義了args也用不到,我們只是保證后面的gender必須通過關鍵字方式傳遞,所以只需要一個*就ok了。

另外在Python3.8中,注意只有Python3.8開始才支持,可以強制使用位置參數,語法是通過/

當然訪問傳遞過來的擴展位置參數和擴展關鍵字參數就通過args對應的PyTupleObject和kwargs對應的PyDictObject操作就可以了。

此外,我們在分析參數的時候,一直是截取部分片段,沒有從上到下整體分析,因此可以再對着源碼自己看一遍。當然核心還是Python在處理函數參數時的機制,整體流程如下(先不考慮/和*)

  • 1. 獲取所有通過位置參數傳遞的個數,然后循環遍歷將它們從運行時棧依次拷貝到 f_localsplus 指定的位置中;
  • 2. 計算出可以通過位置參數傳遞的參數個數,如果實際傳遞的位置參數個數大於可以通過位置參數傳遞個數,那么會檢測是否存在 *args,如果存在,那么將多余的位置參數拷貝到一個元組中;不存在,則報錯:TypeError: function() takes 'm' positional argument but 'n' were given,其中 n 大於 m,表示接收了多個位置參數;
  • 3. 如果實際傳遞的位置參數個數小於等於可以通過位置參數傳遞個數,那么程序繼續往下執行,檢測關鍵字參數,它是通過兩個數組來實現的,參數名和值是分開存儲的;
  • 4. 然后進行遍歷,兩層 for 循環,第一層 for 循環遍歷存放關鍵字參數名的數組,第二層遍歷符號表,會將傳遞參數名和符號表中的每一個符號進行比較;
  • 5. 如果指定了不在符號表中的參數名,那么會檢測是否定義了 **kwargs,如果沒有則報錯:TypeError: function() got an unexpected keyword argument 'xxx',接收了一個不期望的參數 xxx;如果定義了 **kwargs,那么會設置在字典中;
  • 6. 如果參數名在符號表中存在,那么跳轉到 kw_found 標簽,然后獲取該符號對應的 value,如果 value 不為 NULL,那么證明該參數已經通過位置參數傳遞了,會報錯:TypeError: function() got multiple values for argument 'xxx',提示函數的參數 xxx 接收了多個值;
  • 7. 最終所有的參數都會存在 f_localsplus 中,然后檢測是否存在對應的 value 為 NULL 的符號,如果存在,那么檢測是否具有默認值,有則使用默認值,沒有則報錯:

所以Python在處理參數的大致流程是上面那樣的,具體細節層面也很好理解,只是要處理各種各樣的情況,導致看起來讓人有點頭疼。當然Python中的生成器和異步生成器的邏輯也在這個函數里面,我們后續系列中會分析。

小結

這一次我們分析了函數調用時候的場景,以及如何處理不同形式的參數,重點還是有一個整體性的認識。下一篇,我們將來分析閉包。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM