楔子
函数最大的特点就是可以接收参数,如果只是单纯的封装,未免太无趣了。对于函数来说,参数会传什么,事先是不知道的,函数体内部只是利用参数做一些事情,比如调用参数的 get 方法。但是到底能不能调用 get 方法,就取决于给参数传的值是什么了。
因此可以把参数看成是一个占位符,调用的时候,将值传进去赋给相应的参数,然后将函数内部的逻辑走一遍即可。
参数的类别
调用函数时传递的参数,根据形式的不同可以分为四种类别:
- 位置参数(positional argument);
- 关键字参数(keyword argument);
- 扩展位置参数(excess positional argument);
- 扩展关键字参数(excess keyword argument);
参数分为形参和实参,在英文中形参叫做 parameter,实参叫做 argument。但在中文里区分的不是那么明显,我们一般统一称为参数。
然后我们看一下 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;
Py_ssize_t nkwargs = (kwnames == NULL) ? 0 : PyTuple_GET_SIZE(kwnames);
Py_ssize_t nargs = oparg - nkwargs;
PyObject **stack = (*pp_stack) - nargs - nkwargs;
// ...
}
CALL_FUNCTION 指令的 oparg 记录了函数的参数个数,包括位置参数和关键字参数。虽然扩展位置参数和扩展关键字参数是更高级的用法,但本质上也是由多个位置参数、多个关键字参数组成的。这就意味着,虽然函数中存在四种参数,但是只要记录位置参数和关键字参数的个数,就能知道一共有多少个参数,进而知道一共需要多大的内存来维护。
因此 call_function 里面的 nkwargs 就是调用函数时传递的关键字参数的个数,nargs 就是传递的位置参数的个数,两者加起来等于 oparg。然后是函数内部的局部变量的个数,可以通过 co_nlocals 来获取。
注意:局部变量包括了参数,因为函数参数也是局部变量,它们在内存中是连续放置的,局部变量的名称都存储在符号表 co_varnames 中。当虚拟机为函数申请局部变量的内存空间时,就需要通过 co_nlocals 知道局部变量的总数。
可能会有人将 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):
e = 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 来算。但我们在函数体内部肯定能拿到 args 和 kwargs,这也是两个局部变量,因此 co_argcount 是 4,co_nlocals 是 6。
所有的扩展位置参数都存储在一个 PyTupleObject 对象中,所有的扩展关键字参数都存储在一个 PyDictObject 对象中。
co_argcount 和 co_nlocals 的值在编译的时候就已经确定。
位置参数的传递
下面来看看位置参数是如何传递的:
import dis
code = """
def foo(name, age):
gender = "female"
print(name, age)
foo("satori", 17)
"""
dis.dis(compile(code, "<func>", "exec"))
相信对于现在的我们来说,下面的字节码已经没有任何难度了。
0 LOAD_CONST 0 (<code object foo at 0x7f3>)
2 LOAD_CONST 1 ('foo')
4 MAKE_FUNCTION 0
6 STORE_NAME 0 (foo)
8 LOAD_NAME 0 (foo)
10 LOAD_CONST 2 ('satori')
12 LOAD_CONST 3 (17)
14 CALL_FUNCTION 2
16 POP_TOP
18 LOAD_CONST 4 (None)
20 RETURN_VALUE
Disassembly of <code object foo at 0x7f3...>:
0 LOAD_CONST 1 ('female')
2 STORE_FAST 2 (gender)
4 LOAD_GLOBAL 0 (print)
6 LOAD_FAST 0 (name)
8 LOAD_FAST 1 (age)
10 CALL_FUNCTION 2
12 POP_TOP
14 LOAD_CONST 0 (None)
16 RETURN_VALUE
这里我们先看 foo("satori", 17) 的字节码:
8 LOAD_NAME 0 (foo)
10 LOAD_CONST 2 ('satori')
12 LOAD_CONST 3 (17)
14 CALL_FUNCTION 2
16 POP_TOP
首先将函数以及相关参数压入运行时栈:
然后执行 CALL_FUNCTION 指令,由于在调用时全部都是位置参数,那么根据之前介绍的函数调用链路,我们知道它最终会执行 function_code_fastcall,即快速通道。这个函数之前介绍过了,这里再拿出来解释一遍。
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();
// f->localsplus
PyObject **fastlocals;
Py_ssize_t i;
PyObject *result;
assert(globals != NULL);
assert(tstate != NULL);
// 为调用的函数创建 PyFrameObject,参数是 PyCodeObject 和 global 空间
// 因此最后执行的时候其实没有 PyFunctionObject 什么事,它只是起到一个打包和输送的作用
f = _PyFrame_New_NoTrack(tstate, co, globals, NULL);
if (f == NULL) {
return NULL;
}
// 获取函数栈帧的 f_localsplus
fastlocals = f->f_localsplus;
// 调用函数时传递的参数会被提前压入运行时栈,注意:此时的运行时栈是模块的运行时栈
// 因为加载参数入栈时,函数还没调用呢。所以对于当前来说,参数被压入了模块的运行时栈
// 其中 nargs 表示参数个数,args 指向运行时栈的第一个参数
// 然后将运行时栈中的参数拷贝到局部变量对应的内存中
for (i = 0; i < nargs; i++) {
Py_INCREF(*args);
fastlocals[i] = *args++;
}
// 调用 PyEval_EvalFrameEx、进而调用 _PyEval_EvalFrameDefault
// 以新创建的栈帧为执行环境,执行内部的字节码,执行完毕后将返回值赋给 result
result = PyEval_EvalFrameEx(f,0);
// 如果 f 的引用计数大于 1,说明栈帧被保存起来了
// 引用计数减一之后,由于不会被销毁,所以还要被 GC 跟踪
if (Py_REFCNT(f) > 1) {
Py_DECREF(f);
_PyObject_GC_TRACK(f);
}
else {
++tstate->recursion_depth;
Py_DECREF(f);
--tstate->recursion_depth;
}
// 返回 result
return result;
}
从源码中可以看到,虚拟机首先通过 _PyFrame_New_NoTrack 创建了函数 foo 对应的栈帧对象。随后将参数逐个拷贝到新创建的栈帧对象的 f_localsplus 中,f_localsplus 是一个数组,在概念上被分成了四部分,而源码中的索引是从 0 开始的,所以运行时栈中的参数被拷贝到了局部变量对应的内存中。
再次强调:上面说的运行时栈指的是模块栈帧的运行时栈,因为加载参数的时候还没有涉及函数的调用。
// 函数、以及参数都位于模块栈帧的运行时栈里面
8 LOAD_NAME 0 (foo)
10 LOAD_CONST 2 ('satori')
12 LOAD_CONST 3 (17)
// 加载完毕之后,在模块的栈帧中调用函数
14 CALL_FUNCTION 2
16 POP_TOP
调用函数 foo 时,为其创建新的栈帧,并将参数从模块栈帧的运行时栈拷贝到函数栈帧的 f_localsplus(局部变量对应的内存)里面。而在拷贝之后,函数 foo 栈帧的 f_localsplus 布局如下:
栈帧 f_localsplus 的第一段内存用于存储局部变量,不管是函数参数,还是函数内部新创建的局部变量,它们都是局部变量,都保存在 f_localsplus 的第一段内存中。其中 name 和 age 是参数,它们在创建栈帧之后、执行帧评估函数之前,就已经被设置在函数栈帧的 f_localsplus 中了。
至于图中的第三个位置,显然它用于存储局部变量 gender 的值,只不过 gender 是函数内部创建的局部变量,它需要等到函数执行时才会设置。当执行到 gender = "female"
时,通过 f->f_localsplus[2] = "female"
进行设置。
总结:在调用函数时,要提前确定参数,而参数会被压入运行时栈,由于此时函数还没有调用,所以这里的运行时栈是模块栈帧的 f_localsplus 的运行时栈。当参数确定完毕后,开始执行 CALL_FUNCTION 指令,经过一系列操作之后,最终会为调用的函数创建一个新的栈帧。
然后是参数拷贝,因为参数还位于模块栈帧的 f_localsplus 的运行时栈里面,所以要将它们拷贝到函数栈帧的 f_localsplus 的局部变量对应的内存里面。这样的话,函数在执行时就可以通过 f_localsplus[0] 和 f_localsplus[1] 获取变量 name 和 age 的值了,我们看一下函数对应的字节码。
// 此时开启了函数 foo 内部代码的执行
// 将字符串常量压入运行时栈
0 LOAD_CONST 1 ('female')
// 将元素从栈顶弹出,并和变量 gender 进行绑定
// 由于 "gender" 位于符号表中索引为 2 的位置
// 所以执行 f_localsplus[2] = "female"
2 STORE_FAST 2 (gender)
4 LOAD_GLOBAL 0 (print)
// 将局部变量 name 和 age 压入运行时栈
// 或者说将 f_localsplus[0] 和 f_localsplus[1] 压入运行时栈
// 那么问题来了,这两个变量是什么时候创建的呢?
// 很明显,在执行帧评估函数之前,name 和 age 的值就已经被设置在函数栈帧的 f_localsplus 中了
6 LOAD_FAST 0 (name)
8 LOAD_FAST 1 (age)
10 CALL_FUNCTION 2
12 POP_TOP
14 LOAD_CONST 0 (None)
16 RETURN_VALUE
所以函数的参数在执行帧评估函数之前就确定了,它们的值位于函数栈帧的 f_localsplus 里面。
位置参数的访问
当参数拷贝的动作完成之后,就会进入 PyEval_EvalFrameEx,然后进入 _PyEval_EvalFrameDefault 真正开始 foo 的调用动作。会抽出栈帧里的 f_code,对指令逐条执行,而这个过程会涉及参数的访问。当然这就很简单了,我们之前介绍过局部变量是如何创建和访问的,而参数也是局部变量,这里我们再来复习一下。
case TARGET(LOAD_FAST): {
// oparg 表示变量名在符号表中的索引
// 同时也是变量值在 f_localsplus 中的索引,这两者是对应的
// 所以这行代码等价于 PyObject *value = f_localsplus[oparg]
PyObject *value = GETLOCAL(oparg);
// f_localsplus 里面的元素初始为 NULL,当创建局部变量时,会修改 f_localsplus
// 所以如果获取到的 value 为 NULL,这就说明在访问变量时,它还没有完成赋值
// 此时会抛出 UnboundLocalError
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);
// STORE_FAST 用于变量赋值,但在赋值之前,它的值一定已经被压入了运行时栈
// 所以要将值从栈顶弹出
PyObject *value = POP();
// oparg 表示变量名在符号表中的索引,那么它也是变量值在 f_localsplus 中的索引
// 所以这行代码等价于 f_localsplus[oparg] = value
SETLOCAL(oparg, value);
FAST_DISPATCH();
}
// 然后我们看一下 GETLOCAL 和 SETLOCAL 这两个宏
// 在帧评估函数中,会创建变量 fastlocals,并将其赋值为 f->f_localsplus
#define GETLOCAL(i) (fastlocals[i])
#define SETLOCAL(i, value) do { PyObject *tmp = GETLOCAL(i); \
GETLOCAL(i) = value; \
Py_XDECREF(tmp); } while (0)
非常简单,都是之前说过的内容。我们再总结一下变量的创建和访问:
- 全局变量是通过字典存储的,这个字典也叫 global 名字空间,变量名就是里面的 key,变量值就是里面的 value。创建一个全局变量,本质上就是往 global 空间中添加一个键值对;访问一个全局变量,本质上就是将变量名作为 key、从 global 空间中查询 value。
- 局部变量是通过数组静态存储的,函数内部的局部变量有哪些在编译时就确定了,变量的名称都保存在符号表中,变量的值都保存在 f_localsplus 中,并且变量名在符号表中的索引,和变量值在 f_localsplus 中的索引是一致的。创建一个局部变量,本质上就是基于变量名在符号表中的索引去修改 f_localsplus;访问一个局部变量,本质上就是基于变量名在符号表中的索引去查询 f_localsplus,如果查询到的结果为 NULL,说明该局部变量在赋值之前就被访问了,于是会抛出 UnboundLocalError。
前面说了,函数参数和函数内部新创建的变量都属于局部变量,它们的访问逻辑是完全一致的,没有任何区别。只是创建的时候,函数参数在执行帧评估函数之前就已经创建了,被设置在了 f_localsplus 中,执行的时候直接访问即可。
小结
关于位置参数在函数调用时是如何传递的、在函数执行时又是如何被访问的,现在已经真相大白了。
在调用函数时,虚拟机将函数和参数依次压入调用者栈帧的运行时栈中,而在 function_code_fastcall 里面会为函数创建新的栈帧,也就是被调用者栈帧。然后将调用者栈帧的运行时栈中的参数依次拷贝到被调用者栈帧的 f_localsplus 中。
所以在访问函数参数时(或者说局部变量),虚拟机并没有按照通常访问符号的做法,去查什么名字空间,而是根据索引访问 f_localsplus 中和符号绑定的值(指针)。而这种基于索引(偏移位置)来访问参数的方式也正是位置参数的由来,并且这种访问方式的速度也是最快的。
调用函数 foo 时会创建新的栈帧,而等函数 foo 执行完之后,也会回退到模块的栈帧中,并拿到函数 foo 的返回值。然后再将运行时栈里的函数参数清空,回到 CALL_FUNCTION 指令,通过 PUSH(res) 将函数的返回值入栈,接着在模块栈帧中继续执行下一条指令。
欢迎大家关注我的公众号:古明地觉的编程教室。
如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。