楔子
上一篇文章介绍了位置参数,下面来看一看关键字参数。另外函数还支持默认值,我们就放在一起介绍吧。
函数的默认值
简单看一个函数:
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♪(・ω・)ノ。