楔子

上一篇文章介绍了位置参数,下面来看一看关键字参数。另外函数还支持默认值,我们就放在一起介绍吧。

函数的默认值

简单看一个函数:

import dis

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

dis.dis(compile(code, "<func>", "exec"))

字节码指令如下:

  // 构造函数的时候,默认值会被提前压入运行时栈
  0 LOAD_CONST               5 ((1, 2))
  2 LOAD_CONST               2 (<code object foo at 0x7f3...>)
  4 LOAD_CONST               3 ('foo')
  6 MAKE_FUNCTION            1 (defaults)
  8 STORE_NAME               0 (foo)
  // ...

相比无默认值的函数,有默认值的函数在加载 PyCodeObject 和函数名之前,会先将默认值以元组的形式给加载进来。

然后再来观察一下构建函数用的 MAKE_FUNCTION 指令,我们发现指令参数是 1,而之前都是 0,那么这个 1 代表什么呢?根据提示,我们看到了一个 defaults,它和函数的 func_defaults 有什么关系吗?带着这些疑问,我们再来回顾一下这个指令:

case TARGET(MAKE_FUNCTION): {
    // 对于当前例子来说,栈里面有三个元素
    // 从栈顶到栈底分别是:函数名、PyCodeObject、默认值
    PyObject *qualname = POP();  // 弹出函数名
    PyObject *codeobj = POP();  // 弹出 PyCodeObject
  
    // ...
    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();
    }
    // 当前 oparg 是 1,和 0x01 按位与的结果为真,所以知道函数有默认值
    // 于是将其从栈顶弹出,保存在函数的 func_defaults 字段中
    if (oparg & 0x01) {
        assert(PyTuple_CheckExact(TOP()));
        func->func_defaults = POP();
    }

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

通过以上命令可以很容易看出,该指令创建函数对象时,还会处理参数的默认值、以及类型注解等。另外当前 MAKE_FUNCTION 的指令参数只能表示要构建的函数存在默认值,但具体有多少个是看不到的,因为所有的默认值会按照顺序塞到一个 PyTupleObject 对象里面。

然后将默认值组成的元组用 func_defaults 字段保存,在 Python 层面可以通过 __defaults__ 访问。如此一来,默认值也成为了 PyFunctionObject 对象的一部分,它和 PyCodeObject 对象、global 名字空间一样,也被塞进了 PyFunctionObject 这个大包袱。

所以说 PyFunctionObject 这个嫁衣做的是很彻底的,工具人 PyFunctionObject,给个赞。

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

然后我们还是以这个 foo 函数为例,看看不同的调用方式对应的底层实现。

执行 foo()

由于函数参数都有默认值,此时可以不传参,看看这种方式在底层是如何处理的?

// Objects/call.c

PyObject *
_PyFunction_Vectorcall(PyObject *func, PyObject* const* stack,
                       size_t nargsf, PyObject *kwnames)
{
    // ...
    
    // 判断能否进入快速通道,首先要满足函数定义时,参数不可以出现 / 和 *,并且内部不能出现闭包变量
    // 然后调用时不能使用关键字参数
    if (co->co_kwonlyargcount == 0 && nkwargs == 0 &&
        (co->co_flags & ~PyCF_MASK) == (CO_OPTIMIZED | CO_NEWLOCALS | CO_NOFREE))
    {
        // 上面的 if 虽然满足了,但是还不够,还要保证函数参数不能有默认值
        if (argdefs == NULL && co->co_argcount == nargs) {
            return function_code_fastcall(co, stack, 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 .*/
            stack = _PyTuple_ITEMS(argdefs);
            return function_code_fastcall(co, stack, PyTuple_GET_SIZE(argdefs),
                                          globals);
        }
        // 总的来说,以上两个条件都挺苛刻的
    }

    // ...
    // 否则进入通用通道
    return _PyEval_EvalCodeWithName((PyObject*)co, globals, (PyObject *)NULL,
                                    stack, nargs,
                                    nkwargs ? _PyTuple_ITEMS(kwnames) : NULL,
                                    stack + nargs,
                                    nkwargs, 1,
                                    d, (int)nd, kwdefs,
                                    closure, name, qualname);
}

对于当前执行的 foo() 来说,由于参数都有默认值,并且此时也没有传参,因此会进入快速通道。而快速通道之前已经介绍过了,这里就不再说了,总之想要进入快速通道,条件还是蛮苛刻的。

执行 foo(1)

显然此时就走不了快速通道了,会进入通用通道。此时重点就落在了 _PyEval_EvalCodeWithName 函数中,我们看一下它的逻辑。注意:该函数的逻辑较为复杂,理解起来会比较累,可能需要多读几遍。

// Python/ceval.c

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 对象
    PyCodeObject* co = (PyCodeObject*)_co;
    // 栈桢对象
    PyFrameObject *f;
    // 函数的返回值
    PyObject *retval = NULL;
    // 和闭包相关,暂时不做讨论
    PyObject **fastlocals, **freevars;
    PyObject *x, *u;
    // co->co_argcount:可以通过位置参数(或关键字参数)传递的参数个数
    // co->co_kwonlyargcount:只能通过关键字参数传递的参数个数
    // 两者相加便是参数总个数
    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);
    // global 名字空间不能为 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;
    }
    // 获取 f_localsplus
    fastlocals = f->f_localsplus;
    // 闭包相关,后续再聊
    freevars = f->f_localsplus + co->co_nlocals;

    // 还记得这个 co_flags 吗? 
    // 如果它和 0x08 按位与的结果为真,说明参数定义了 **kwargs
    // 如果它和 0x04 按位与的结果为真,说明参数定义了 *args
    if (co->co_flags & CO_VARKEYWORDS) {
        // 申请字典,用于 kwargs
        kwdict = PyDict_New();
        if (kwdict == NULL)
            goto fail;
        i = total_args;
        // 参数是有顺序的,*args 和 **kwargs 在最后面
        // 如果不存在 *args,那么将 fastlocals[total_args] 设置为 kwdict
        // 如果存在 *args,那么将 fastlocals[total_args + 1] 设置为 kwdict
        if (co->co_flags & CO_VARARGS) {
            i++;
        }
        // 所以如果 co->co_flags & CO_VARARGS 为真,那么 i++
        // 然后将 kwdict 设置在 fastlocals 中索引为 i 的位置
        SETLOCAL(i, kwdict);
    }
    else {
        kwdict = NULL;
    }

    // argcount 是实际传递的位置参数的个数,co->co_argcount 是可以通过位置参数传递的参数个数
    // 如果 argcount > co->co_argcount,证明有扩展位置参数,即 *args,否则没有 
    if (argcount > co->co_argcount) {
        // 如果有 *args,那么让 n 等于 co->co_argcount
        n = co->co_argcount;
    }
    else {
        // 没有 *args, 那么调用者通过位置参数的方式传了几个参数,n 就是几
        n = argcount;
    }
    // 然后我们仔细看一下这个 n,假设有一个函数 def bar(a, b, c=1, d=2, *args)
    // 如果 argcount > co->co_argcount,说明传递的位置参数的个数超过了 4,于是 n 为 4
    // 但如果我们只传递了两个,比如 bar('a', 'b'),那么 n 显然为 2

    // 下面就是将已经传递的参数的值依次设置到 f_localsplus 里面去
    for (j = 0; j < n; j++) {
        x = args[j];
        Py_INCREF(x);
        SETLOCAL(j, x);
    }

    // 如果有 *args
    if (co->co_flags & CO_VARARGS) {
        u = _PyTuple_FromArray(args + n, argcount - n);
        if (u == NULL) {
            goto fail;
        }
        // 设置在索引为 total_args 的位置,也就是 **kwargs 的前面
        SETLOCAL(total_args, u);
    }

    // 关键字参数,后面说
    kwcount *= kwstep;
    for (i = 0; i < kwcount; i += kwstep) {
        // ...
    }

    // 条件判断:如果 argcount > co->co_argcount,并且还没有定义 *args
    // 说明我们传递了超过指定数量的位置参数
    if ((argcount > co->co_argcount) && !(co->co_flags & CO_VARARGS)) {
        // 那么会直接报错:takes m positional arguments but n were given
        too_many_positional(tstate, co, argcount, defcount, fastlocals);
        goto fail;
    }

    // 如果 argcount < co->co_argcount,说明传递的参数不够,那么证明有默认值
    if (argcount < co->co_argcount) {
        // defcount 表示设置了默认值的参数个数,显然 m 就是需要传递的没有默认值的参数的个数
        // 比如一个函数接收 6 个参数,但是有两个参数有默认值
        // 这就意味着调用者通过位置参数的方式传递的话,需要至少传递 4 个,那么 m 就是 4
        Py_ssize_t m = co->co_argcount - defcount;
        Py_ssize_t missing = 0;
        // i = argcount 是我们调用函数时传递的位置参数的总个数
        // 很明显如果参数足够,那么 i < m 是不会满足的
        for (i = argcount; i < m; i++) {
            // 但如果传递的参数不足,那么 GETLOCAL 从 f_localsplus 中就获取不到值
            // 而一旦找不到,missing++,缺少的参数个数加一
            if (GETLOCAL(i) == NULL) {
                missing++;
            }
        }
        // 如果 missing 不为 0,表示缺少参数,直接抛出异常
        if (missing) {
            // {func} missing {n} required positional arguments:
            missing_arguments(tstate, co, missing, defcount, fastlocals);
            goto fail;
        }
        // 下面可能有点难理解,m 是调用者使用位置参数的方式至少需要传递的参数个数
        // 而 n 是使用位置参数的方式实际传递的参数个数,比如:
        /*
        def bar(a, b, c, d=1, e=2, f=3):
            pass

        函数有 6 个参数,其中 3 个有默认值,显然 m 是 3,因为使用位置参数的方式至少要传递 3 个参数
        实际上函数定义好了,m 就是一个不变的值了,就是没有默认值的参数个数
        但我们调用时可以是 bar(1,2,3),也就是只传递 3 个,那么这里的 n 就是 3
        也可以是 bar(1, 2, 3, 4, 5),那么显然 n = 5,而 m 依旧是 3
        */         
        if (n > m)
            // 因此现在这里的逻辑就很好理解了,假设调用的是 bar(1, 2, 3, 4, 5)
            // 由于其中 3 个参数有默认值,那么调用时只传递 6 - 3 = 3 个就可以了,但这里传递了 5 个
            // 说明有两个参数我们不想使用默认值,想重新传递,而使用默认值的只有最后一个参数
            // 因此这个 i 就是明明可以使用默认值、但却没有使用的参数的个数            
            i = n - m;
        else
            // 如果按照位置参数传递能走到这一步,说明已经不存在少传的情况了
            // 因此这个 n 至少是 >= m 的,如果 n == m 的话,那么 i 就是 0
            i = 0;
        for (; i < defcount; i++) {
            // 默认参数的值一开始就已经被压入栈中,整体作为一个元组,赋值给了 func_defaults 字段
            // 但对于函数的参数来讲,肯定还要设置到 f_localsplus 里面
            // 并且要在后面,因为默认参数的顺序在非默认参数之后       
            // 所以要从索引 i 开始,将 func_defaults 内部的元素,拷贝到 f_localsplus 中
            if (GETLOCAL(m+i) == NULL) {
                // 还是之前的例子,假设函数接收 6 个参数,其中三个有默认值,但是我们传了 5 个
                // 说明 n = 5,m = 3,那么 i 就等于 n - m = 2,因此有两个参数可以使用默认值,但我们没有使用
                // 所以只需从索引 i 开始,将 func_defaults 里的元素拷贝到 f_localsplus 即可,显然此时只会拷贝最后一个
                // 那么问题来了,如果我们传递了 3 个位置参数呢?显然此时 i 是 0,因为 n == m
                // 这就意味着参数都使用默认值,既然这样,那就从头开始拷
                // 同理如果传了 4 个参数,证明第一个参数的默认值是不需要的,只把后面两个拷贝过去就可以了
                // 显然要从索引为 1 的位置开始拷贝,而此时 n - m、也就是 i,正好为 1
                // 所以 n - m 就是"默认值组成的元组中需要拷贝到 f_localsplus 的第一个值的索引"
                // 然后 i < defcount; i++,一直拷贝到结尾    
                PyObject *def = defs[i];
                Py_INCREF(def);
                // 将值设置到 f_localsplus 里面,因为已经传了 n 个参数
                // 所以要从 f_localsplus[n] 开始设置,而 n 初始正好是 m + i,然后不断执行 i++
                // 因此当前这个 for 循环做的事情就是将 func_defaults[i] 赋值给 f_localsplus[m + i]
                SETLOCAL(m+i, def);
            }
        }
    }

    // 关键字参数,稍后说
    if (co->co_kwonlyargcount > 0) {
        // ...
    }
    // 闭包相关,后续再聊
    for (i = 0; i < PyTuple_GET_SIZE(co->co_cellvars); ++i) {
        // ...
    }
    for (i = 0; i < PyTuple_GET_SIZE(co->co_freevars); ++i) {
        // ...
    }

    // 生成器、协程、异步生成器相关,后续再聊
    if (co->co_flags & (CO_GENERATOR | CO_COROUTINE | CO_ASYNC_GENERATOR)) {
        // ...
    }
    
    // 到此函数参数就已经设置完毕,拷贝到了栈桢的 f_localsplus 中
    // 然后执行帧评估函数,之后会在 CALL_FUNCTION 指令中拿到返回值,压入运行时栈
    retval = PyEval_EvalFrameEx(f,0);
fail:
    assert(tstate != NULL);
    if (Py_REFCNT(f) > 1) {
        Py_DECREF(f);
        _PyObject_GC_TRACK(f);
    }
    else {
        ++tstate->recursion_depth;
        Py_DECREF(f);
        --tstate->recursion_depth;
    }
    return retval;
}

以上我们就知道了位置参数的默认值是怎么一回事了,还是那句话,逻辑理解起来不是很容易。主要是因为涉及到默认值的处理,但核心就是先将调用者传递的参数拷贝到 f_localsplus 中,然后判断传递的参数个数和默认值个数之间的关系,再将默认值从 func_defaults 拷贝到 f_localsplus 中。

所以快速通道和通用通道做的事情是一样的,都是创建栈桢、修改栈桢字段(主要是修改 f_localsplus)、执行帧评估函数,但通用通道在处理函数参数方面要复杂很多,因为要考虑多种情况。

执行 foo(b=2)

这里我们传递了一个关键字参数,此时也会走通用通道。并且在调用函数之前,会先将符号 b对象 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);
    // ...
    // 遍历关键字参数
    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++) {
            // 比如传递了 b=3,那么要保证符号表中存在 "b" 这个符号
            // 如果有,那么该参数就是合法的关键字参数,如果没有,再看是否存在 **kwargs
            // 要是没有 **kwargs,报错:got an unexpected keyword argument
            PyObject *name = co_varnames[j];
            if (name == keyword) {
                // 找到了,跳转到 kw_found 标签
                goto kw_found;
            }
        }

        /* Slow fallback, just in case */
        // 逻辑和上面一样,只是比较符号时用的是 PyObject_RichCompareBool
        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;
        }
        // 到这里说明虽然符号表中不存在指定的符号,但函数定义了 **kwargs
        // 那么将参数名和参数值设置到字典 kwargs 中
        if (PyDict_SetItem(kwdict, keyword, value) == -1) {
            goto fail;
        }
        continue;

      kw_found:
        // 索引 j 就是该参数在 f_localsplus 中的索引
        // 但如果 GETLOCAL(j) != NULL,说明已经通过位置参数指定了
        if (GETLOCAL(j) != NULL) {
            _PyErr_Format(tstate, PyExc_TypeError,
                          "%U() got multiple values for argument '%S'",
                          co->co_name, keyword);
            goto fail;
        }
        // 否则增加引用计数,设置在 f_localsplus 中
        Py_INCREF(value);
        SETLOCAL(j, value);
    }

    // 判断函数是否定义了仅限关键字参数
    // 而仅限关键字参数的默认值是不包含在 func_defaults 里面的,它位于 func_kwdefaults 里面
    if (co->co_kwonlyargcount > 0) {
        Py_ssize_t missing = 0;
        // 同样是遍历符号表,获取默认值,如果有,设置在 f_localsplus 中
        for (i = co->co_argcount; i < total_args; i++) {
            PyObject *name;
            if (GETLOCAL(i) != NULL)
                continue;
            name = PyTuple_GET_ITEM(co->co_varnames, i);
            if (kwdefs != NULL) {
                PyObject *def = PyDict_GetItemWithError(kwdefs, name);
                if (def) {
                    Py_INCREF(def);
                    SETLOCAL(i, def);
                    continue;
                }
                else if (_PyErr_Occurred(tstate)) {
                    goto fail;
                }
            }
            missing++;
        }
        if (missing) {
            missing_arguments(tstate, co, missing, -1, fastlocals);
            goto fail;
        }
    }
    
    // ...
    return retval;
}

总结一下,虚拟机会将函数中出现的符号都记录在符号表(co_varnames)里面。对于 foo(b=2) 来说,虚拟机在执行 CALL_FUNCTION 指令之前会将关键字参数的名字都压入到运行时栈,那么在执行 _PyEval_EvalCodeWithName 时就能利用运行时栈中保存的关键字参数的名字在 co_varnames 里面进行查找。

最妙的是,变量名在 co_varnames 中的索引,和变量值在 f_localsplus 中的索引是一致的。所以在 co_varnames 中搜索到关键字参数的名字时,就可以根据对应的索引直接修改 f_localsplus,这就为默认参数设置了函数调用者希望的值。

为了理解清晰,我们再举个简单例子,总结一下。

def foo(a, b, c, d=1, e=2, f=3):
    pass

对于上面这个函数,首先虚拟机知道调用者至少要给 a、b、c 传递参数。如果是 foo(1),那么 1 会传递给 a,但是 b 和 c 是没有接收到值的,所以报错。

如果是 foo(1, e=11, c=22, b=33),还是老规矩先将 1 传递给 a,发现依旧不够,这时就会把希望寄托在关键字参数上。并且由于 f_localsplus 中变量值的顺序,和 co_varnames 中变量名的顺序是一致的,所以关键字参数是不讲究顺序的。当找到了 e=11,那么虚拟机通过符号表,就知道把 e 的值设置在 f_localsplus 中索引为 4 的地方。为什么索引是 4 呢?因为符号 e 在符号表中的索引是 4。而 c=22,显然设置在索引为 2 的地方,b=3,设置在索引为 1 的地方。等位置参数和关键字参数都设置完毕之后,虚拟机会再检测需要传递的参数、也就是没有默认值的参数,调用者有没有全部传递。

小结

这一篇的内容稍微有点枯燥,因为从 Python 的角度来看的话,就是一个传参罢了。

参数的传递可以使用位置参数、也可以使用关键字参数;如果带有默认值,我们也可以只给一部分参数传值,然后没收到值的参数使用默认值,收到值的参数使用我们传递的值。而我们这里所做的事情,就是在看这些参数解析具体是怎么实现的。

最后再给出两个思考题:

  • 1)经过分析我们知道,关键字参数具体设置在 f_localsplus 中的哪一个地方,是通过将参数名代入到 co_varnames 里面查找所得到的。但如果这个关键字参数的参数名不在 co_varnames 里面,怎么办?
  • 2)如果传递的位置参数的个数比 co_argcount 还要多,怎么办?

这里直接给出答案,首先是问题一,如果出现这种情况,说明指定了不存在的关键字参数,会报错,如果不想报错,意味着函数要定义 **kwargs。然后是问题二,说明位置参数传多了,显然也会报错,如果不想报错,意味着函数要定义 *args。

关于 *args 和 **kwargs,我们下一篇文章介绍。


 

欢迎大家关注我的公众号:古明地觉的编程教室。

如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。