Skip to content

Commit

Permalink
函数是如何解析关键字参数的
Browse files Browse the repository at this point in the history
  • Loading branch information
satori1995 committed Dec 27, 2024
1 parent 80d2896 commit dfd1016
Showing 1 changed file with 214 additions and 0 deletions.
214 changes: 214 additions & 0 deletions src/69.函数是如何解析关键字参数的?.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
## 楔子

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

## 函数的默认值

简单看一个函数:

~~~python
import dis

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

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

字节码指令如下:

~~~C
// 构造函数的时候,默认值会被提前压入运行时栈
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 有什么关系吗?带着这些疑问,我们再来回顾一下这个指令:

~~~C
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,给个赞。
~~~python
def foo(a=1, b=2):
print(a + b)
~~~

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

## 执行 foo()

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

~~~C
// 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 函数中,我们看一下它的逻辑。注意:该函数的逻辑较为复杂,理解起来会比较累,可能需要多读几遍。

0 comments on commit dfd1016

Please sign in to comment.