Python虚拟机函数机制之参数类别(三)
参数类别
我们在Python虚拟机函数机制之无参调用(一)和Python虚拟机函数机制之名字空间(二)这两个章节中,分别PyFunctionObject对象和函数执行时的名字空间。本章,我们来剖析一下函数参数的实现。
在Python中,函数的参数根据形势的不同可以分为四种类别:
- 位置参数:如f(a, b),a和b称为位置参数
- 键参数:f(a, b, name="Python"),其中的name="Python"被称为键参数
- 扩展位置参数:f(a, b, *args),其中*args被称为扩展位置参数
- 扩展键参数:f(a, b, **kwargs),其中**kwargs被称为扩展键参数
函数的调用时通过CALL_FUNCTION指令实现的,而CALL_FUNCTION又是通过call_function这个函数来完成函数的调用。之前,在剖析无参函数调用时,我们曾进入到call_function这个函数中,这次,我们要剖析函数的有参调用,依旧要回到call_function函数
ceval.c
static PyObject * call_function(PyObject ***pp_stack, int oparg) { //[1]:处理函数参数信息 int na = oparg & 0xff; int nk = (oparg >> 8) & 0xff; int n = na + 2 * nk; //[2]:获得PyFunctionObject对象 PyObject **pfunc = (*pp_stack) - n - 1; PyObject *func = *pfunc; …… }
当Python函数开始执行CALL_FUNCTION指令时,会首先获得一个指令参数oparg。在这个指令参数oparg中,实际记录的是函数参数的个数信息,包括位置参数的个数和键参数的个数。虽然扩展位置参数和扩展键参数是位置参数和键参数更高级的形式,但是本质上扩展位置参数是由多个位置参数构成的。这意味着,虽然Python中存在四种参数形式,但实际上我们只需要记录位置参数的个数和键参数的个数,就能知道一共有多少个参数,一共需要多大的内存空间来维护参数
CALL_FUNCTION指令参数的长度是两个字节,在低字节,记录着位置参数的个数,在高字节,记录键参数的个数。因为,在理论上,Python中的函数只能有256个位置参数和256个键参数
从call_function中我们可以看到na实际上是位置参数的个数,nk则是键参数的个数。下面,我们修改一下call_function的源码,来观察一下拥有不同种类参数的函数中na和nk究竟是多少。在输出na和nk的同时,我们还输出函数对应的PyCodeObject对象中维护的两个与参数有关的信息:co_argcount(Code Block的位置参数个数,比如说一个函数的位置参数个数)和co_nlocals(Code Block中局部变量的个数,包括其位置参数的个数)
这里有一个问题,既然co_nlocals包含局部变量的个数,和函数位置参数的个数,那co_argcount不是多此一举了吗?实际上,在Python中,函数参数和函数的局部变量关系非常密切,在某种意义上,函数参数就是一种函数局部变量,它们在内存中是连续放置的。当Python需要为函数申请存放局部变量的内存空间时,就需要通过co_nlocals知道局部变量的总数。所以,只有在co_nlocals中包含参数的数量,才能为参数申请内存空间。虽然co_nlocals包含参数的数量,但没有办法从中得知参数的个数,所以必须有一个co_argcount告诉Python函数一共有多少个参数。是不是有点晕了?没关系,下面,我们修改call_function方法,一睹函数参数与局部变量的区别和联系
ceval.c
static PyObject * call_function(PyObject ***pp_stack, int oparg) { int na = oparg & 0xff; int nk = (oparg>>8) & 0xff; int n = na + 2 * nk; PyObject **pfunc = (*pp_stack) - n - 1; PyObject *func = *pfunc; PyObject *x, *w; char *func_name = PyEval_GetFuncName(func); if (strcmp(func_name, "Py_Func") == 0) { printf("[call_function]:na=%d, nk=%d, n=%d\n", na, nk, n); PyCodeObject *co = (PyCodeObject *)PyFunction_GET_CODE(func); printf("[call_function]:co->co_argcount=%d, co->co_nlocals=%d\n", co->co_argcount, co->co_nlocals); } …… }
如上面代码所示,只有当函数名为Py_Func时,才会打印参数信息。编译运行完之后,我们就来花式折腾Py_Func这个函数
def Py_Func(a, b): pass
1.位置参数
Py_Func(1, 2) //字节码指令 9 LOAD_NAME 0 (Py_Func) 12 LOAD_CONST 1 (1) 15 LOAD_CONST 2 (2) 18 CALL_FUNCTION 2 >>> Py_Func(1, 2) [call_function]:na=2, nk=0, n=2 [call_function]:co->co_argcount=2, co->co_nlocals=2
2.位置参数+键参数
Py_Func(1, b=2) //字节码指令 9 LOAD_NAME 0 (Py_Func) 12 LOAD_CONST 1 (1) 15 LOAD_CONST 2 ('b') 18 LOAD_CONST 3 (2) 21 CALL_FUNCTION 257 >>> Py_Func(1, b=2) [call_function]:na=1, nk=1, n=3 [call_function]:co->co_argcount=2, co->co_nlocals=2
从例1和例2的对比可以看出,函数参数中一个参数是位置参数还是键参数实际上仅仅是由函数实参的形式决定的,而与函数定义时的形参没有任何关系。从例1到例2,同样是为第二个参数b传递参数值2,由于采用了不同形式的实参,就从位置参数变为键参数。而(na, nk)对也从(2, 0)变为了(1, 1)。可见,na和nk确实忠实地反应着位置参数和键参数的个数
虽然在例1和例2中,na + nk的值是一样的,都是2,但是我们看到,n的值是不同的。在例1时n为2,在例2时n为3。这个起源于计算n的公式,n = na + 2 * nk。为什么会有这样一个公式呢?这一切都要从n的意义说起
ceval.c
PyObject **pfunc = (*pp_stack) - n - 1; PyObject *func = *pfunc;
call_function中,func指向运行时栈中存放的PyFunctionObject对象。而在这条语句之前有句PyObject **pfunc = (*pp_stack) - n - 1,其中pp_stack是当前运行时栈的栈顶指针。所以,pfunc就是栈顶指针回退(n+1)的结果。从例1和例2的指令序列可以看到,在执行MAKE_FUNCTION指令时,会把PyFunctionObject对象压入运行时栈,接着会将所有与参数相关的信息也压入运行时栈,这些信息的个数因函数的不同而不同。所以,在call_function中,我们想成功回退到PyFunctionObject对象的位置,必须获得参数有关的信息个数,这个个数正是n。之前我们说过,na是位置参数的个数,而nk是键参数个数,由于键参数会比位置参数多执行一条LOAD_CONST,将符号压入运行时栈。因此,n = na + 2 * nk
为什么键参数会导致两条LOAD_CONST指令呢?换句话说,在例2中传递b是否必要呢?考虑一个带有默认值的函数def f(a=1, b=2, c=3),假如我们是这样调用f:f(b=1),表示我们希望替换b的默认值,而保留a和c的默认值,如何不传递b,Python如何知道要替换哪个变量的默认值呢?这正是键参数的作用
3.位置参数+扩展位置参数
def Py_Func(a, b, *args): pass
Py_Func(1, 2, 3, 4) //字节码指令 9 LOAD_NAME 0 (Py_Func) 12 LOAD_CONST 1 (1) 15 LOAD_CONST 2 (2) 18 LOAD_CONST 3 (3) 21 LOAD_CONST 4 (4) 24 CALL_FUNCTION 4 >>> Py_Func(1, 2, 3, 4) [call_function]:na=4, nk=0, n=4 [call_function]:co->co_argcount=2, co->co_nlocals=3
在Python函数的参数表中,非键参数的位置必须在键参数之前,所以Py_Func(1, b=2, 3, 4)这样的函数调用是非法的。从na的值可以看到,扩展位置参数的信息确实被归在位置参数这一类
在[call_function]第二行的输出信息中,我们发现一些特别的地方,在例1和例2中,co_argcount的值和co_nlocals的值是相同的,因为函数内没有局部变量。但是在例3中,函数内同样没有局部变量,co_argcount和co_nlocals的值却是不同的,更奇怪的是,co_argcount作为函数参数的个数,居然是2,明明Py_Func函数中声明了a、b和*args这3个参数。唯一合理的解释是Python内部将扩展位置参数*args作为一个局部变量了,这样,才会有co_argcount=2而co_nlocals=3的结果
我们还能看到,尽管我们调用函数时传递了四个参数,但是这丝毫不能影响co_argcount和co_nlocals的值。实际上,不管我们传多少个参数,都不能影响o_argcount和co_nlocals的值。因为co_argcount和co_nlocals是函数Py_Func编译后产生的PyCodeObject对象的域,也就是说它们的值是在编译期就确定的。从co_argcount=2,co_nlocals=3的结果我们已经可以做一个大胆的猜测,那就是在Python实现Py_Func函数时,所有的扩展位置参数实际上是被存储在一个PyTuppleObject对象中
4.位置参数+扩展键参数
def Py_Func(a, b, **kwargs): pass
Py_Func(1, 2, name="Python", author="Guido") //字节码指令 9 LOAD_NAME 0 (Py_Func) 12 LOAD_CONST 1 (1) 15 LOAD_CONST 2 (2) 18 LOAD_CONST 3 ('name') 21 LOAD_CONST 4 ('Python') 24 LOAD_CONST 5 ('author') 27 LOAD_CONST 6 ('Guido') 30 CALL_FUNCTION 514 >>> Py_Func(1, 2, name="Python", author="Guido") [call_function]:na=2, nk=2, n=6 [call_function]:co->co_argcount=2, co->co_nlocals=3
从co_argcount和co_nlocals的值上来看,扩展键参数在Python内部也是被当做一个局部变量来看
5.位置参数+局部变量
def Py_Func(a, b): c = 1
Py_Func(1, 2) //字节码指令 9 LOAD_NAME 0 (Py_Func) 12 LOAD_CONST 1 (1) 15 LOAD_CONST 2 (2) 18 CALL_FUNCTION 2 >>> Py_Func(1, 2) [call_function]:na=2, nk=0, n=2 [call_function]:co->co_argcount=2, co->co_nlocals=3
这里co_nlocals=3是理所当然的,因为Py_Func函数内部终于有一个局部变量了。从执行函数调用的指令上来看,没有涉及到局部变量的指令,这无疑是正确的,因为局部变量属于另一个PyCodeObject中