本篇文章再来补充一下扩展位置参数和扩展关键字参数,即 *args 和 **kwargs。

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

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

对于 co_nlocals 来说,它统计的是所有局部变量的个数,而当前的 foo 函数内部存在 4 个局部变量:a、b、args、kwargs,所以结果是 4。但对于 co_argcount 来说,统计的结果不包括 args 和 kwargs,因此结果是 2。

然后 *args 可以接收多个位置参数,这些位置参数会放在一个由 args 指向的元组中;**kwargs 则可以接收多个关键字参数,而这些关键字参数(名字和值)会放在一个由 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)  // 函数的名称信息
{
    // ...
    // 判断是否出现了 **kwargs
    if (co->co_flags & CO_VARKEYWORDS) {
        // 创建一个字典,用于 kwargs
        kwdict = PyDict_New();
        if (kwdict == NULL)
            goto fail;
        // i 是参数总个数
        i = total_args;
        // 如果还有 *args,那么 i 要加上 1,因为 **kwargs 要定义在 *args 的后面
        if (co->co_flags & CO_VARARGS) {
            i++;
        }
        // 如果没有 *args,那么 kwdict 要位于索引为 i 的位置
        // 如果有 *args,那么 kwdit 位于索引为 i + 1 的位置
        SETLOCAL(i, kwdict);
    }
    else {
        // 如果没有 **kwargs 的话,那么 kwdict 就是 NULL
        kwdict = NULL;
    }
    // 这段逻辑之前介绍了,是将位置参数(不包含扩展位置参数)拷贝到 f_localsplus 中
    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 里面去,索引为 total_args
        SETLOCAL(total_args, u);
    }

    // 下面就是拷贝扩展关键字参数,使用索引遍历,按照顺序依次取出
    // 通过判断传递的关键字参数的符号是否出现在函数定义的参数中
    // 来判断传递的这个参数究竟是普通的关键字参数,还是扩展关键字参数
    // 比如 def foo(a, b, c, **kwargs),调用方式为 foo(1, 2, 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];
        PyObject *value = kwargs[i];
        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,将参数值设置在 f_localsplus 中索引为 j 的位置
                // 并且在标签内部,还会检测该参数有没有通过位置参数传递
                // 如果已经通过位置参数传递了,那么显然该参数就被传递了两次
                goto kw_found;
            }
        }

        /* Slow fallback, just in case */
        /* 逻辑和上面一样 */
        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);
        // 走到这里,说明上面的 for 循环不成立,参数不在符号表中,也就是传入了不存在的关键字参数
        // 那么这时候要检测 **kwargs,如果 kwdict 是 NULL,说明函数没有 **kwargs,那么直接报错
        if (kwdict == NULL) {
            if (co->co_posonlyargcount
                && positional_only_passed_as_keyword(tstate, co,
                                                     kwcount, kwnames))
            {
                goto fail;
            }
            // 也就是下面这个错误,{func} 收到了一个预料之外的关键字参数
            _PyErr_Format(tstate, PyExc_TypeError,
                          "%U() got an unexpected keyword argument '%S'",
                          co->co_name, keyword);
            goto fail;
        }
        // kwdict 不为 NULL,证明定义了 **kwargs,那么将参数名和参数值设置到这个字典里面去
        // 然后 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(第一段内存)是 [1, 2, NULL, NULL]
            // 然后设置关键字参数的时候,j 对应的索引为 2
            // 那么 GETLOCAL(j) 就是 NULL,上面的 if 不成立,所以不会报错            
            // 但如果这样传递:foo(1, 2, 3, c=3),那么 f_localsplus 就是 [1, 2, 3, NULL]
            // 而 GETLOCAL(j) 就是 3,不为 NULL,说明 j 这个位置已经通过位置参数传递了
            // 既然有值了,那么关键字参数就不能传递了,否则就重复了
            goto fail;
        }
        // 将 value 设置在 f_localsplus 中索引为 j 的位置
        // 还是那句话,f_localsplus 存储的值(PyObject *)和符号表存储的符号,在顺序上是一致的
        // 比如变量 c 在符号表中索引为 2 的位置,那么 f_localsplus[2] 保存的就是变量 c 的值   
        Py_INCREF(value);
        SETLOCAL(j, value);
    }

    // ...
}

总的来说,虚拟机对参数进行处理的时候,机制还是有点复杂的。其实扩展关键字参数的传递机制和普通关键字参数有很大的关系,我们之前分析参数的默认值时,已经看到了关键字参数的传递机制,这里又再次看到了。

对于关键字参数,不论是否扩展,都会把符号和值按照对应顺序分别放在两个数组里面。然后虚拟机按照索引依次遍历存放符号的数组,对遍历出的每一个符号都会和符号表 co_varnames 中的符号逐个进行比对,如果发现在符号表中找不到传递的关键字参数的符号,那么就说明这是一个扩展关键字参数。然后就是我们在源码中看到的那样,如果函数定义了 **kwargs,那么 kwdict 就不为空,会把扩展关键字参数直接设置进去,否则报错:提示接收到了一个不期待的关键字参数。

_PyEval_EvalCodeWithName 里面的内容还是蛮多的,我们每次都是截取指定的部分进行分析,可以自己再对着源码仔细读一遍。总之核心逻辑如下:

  • 1)获取所有通过位置参数传递的参数个数,然后循环遍历将它们从运行时栈依次拷贝到 f_localsplus 中;
  • 2)计算出可以通过位置参数传递的参数个数,如果"实际传递的位置参数的个数" 大于 "可以通过位置参数传递的参数个数",那么会检测是否存在 *args。如果存在,那么将多余的位置参数拷贝到 args 指向的元组中;如果不存在,则报错:TypeError: function() takes 'm' positional argument but 'n' were given,其中 n 大于 m,表示接收了多个位置参数;
  • 3)如果"实际传递的位置参数的个数" 小于等于 "可以通过位置参数传递的参数个数",那么程序继续往下执行,检测关键字参数,它是通过两个数组来实现的,参数名和参数值是分开存储的;
  • 4)然后进行遍历,两层 for 循环,第一层 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 的符号,如果存在,那么检测是否具有默认值,有则使用默认值,没有则报错;

以上就是函数参数的处理流程,用起来虽然简单,但分析具体实现时还是有点头疼的。当然啦,这部分内容其实也没有深挖的必要,大致了解就好。


 

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

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