11 函数的参数:赋予函数意义的关键特性
你好,我是海纳。
在前面的课程中,我们实现了函数的基本功能。其中第 9 节课我们讲解了如何定义一个函数对象,第 10 节课实现了调用函数的功能,但是第 10 节课的函数调用是不支持传递参数的。然而函数的最重要功能就是接收参数,进行运算并返回计算结果。上一节课我们展示了函数如何创建栈帧,进行运算并且返回计算结果。那么这一节课我们来关注如何向一个函数传递参数。
Python 的传参机制
Python 中传递参数的机制比很多语言都要复杂,所以我们把参数的实现放在最后来讲。和以前的方法一样,我们先写测试用例,再观察例子所对应的字节码。
创建 test_param.py:
然后通过 show_file 来查看它的内容。
// call function
2 0 LOAD_CONST 0 (<code object add at 0x7ff9e40a0710, file "/root/gitee/pythonvm/build/test_param.py", line 2>)
2 LOAD_CONST 1 ('add')
4 MAKE_FUNCTION 0
6 STORE_NAME 0 (add)
5 8 LOAD_NAME 1 (print)
10 LOAD_NAME 0 (add)
12 LOAD_CONST 2 (1)
14 LOAD_CONST 3 (2)
16 CALL_FUNCTION 2
18 CALL_FUNCTION 1
20 POP_TOP
22 LOAD_CONST 4 (None)
24 RETURN_VALUE
// definition of add
3 0 LOAD_FAST 0 (a)
2 LOAD_FAST 1 (b)
4 BINARY_ADD
6 RETURN_VALUE
consts
None
names ()
varnames ('a', 'b')
在上述反编译出的字节码里,CALL_FUNCTION 都是带有参数的。
上一节课在实现 CALL_FUNCTION 的时候,我们并没有关注这个参数的值,因为当时的函数调用都没有带参数。在这一节课 add 的例子里,函数调用传递了两个参数,所以这个字节码的参数是 2(第 11 行)。print 函数则带有 1 个参数(第 12 行)。
实际上,在 CALL_FUNCTION 之前的两个字节码已经把参数送到栈上了(第 9 行和第 10 行的两个 LOAD_CONST),我们所要做的只是根据 CALL_FUNCTION 的参数,把栈上的值取出来,再传给函数栈帧就好了。
另外,add 函数中加载数据也出现了一个我们之前没有见过的字节码:LOAD_FAST。这个字节码用来访问函数的局部变量。局部变量和上一节课我们讲的 LOAD_NAME 不一样,它只依赖于位置下标,而不依赖于变量名称,所以局部变量不必使用 map 来维护,ArrayList 就足够了。
使用下标来访问变量值比使用 map 快多了,这也正是 FAST 这个名字的体现。
所以我们要在 FrameObject 里增加功能,让它可以接受参数,并且可以支持局部变量的快速访问。你可以看一下修改后的代码。
// runtime/FrameObject.cpp
FrameObject::FrameObject (FunctionObject* func, ObjList args) {
_codes = func->_func_code;
_consts = _codes->_consts;
_names = _codes->_names;
_locals = new Map<HiObject*, HiObject*>();
_globals = func->_globals;
_fast_locals = nullptr;
if (args) {
_fast_locals = new ArrayList<HiObject*>();
for (int i = 0; i < args->length(); i++) {
_fast_locals->set(i, args->get(i));
}
}
_stack = new ArrayList<HiObject*>();
_pc = 0;
_sender = NULL;
}
FrameObject 里新增了一个成员变量 _fast_locals(第 9 行),用来处理函数的局部变量。
同时,我们还为 FrameObject 的构造函数增加了新的参数 args,用来表示调用函数时所使用的参数。在这个函数里,会把 args 中的值全部放到 _fast_locals 里去(第11行至第17行)。
和上述代码相匹配,CALL_FUNCTION 的实现也需要处理函数的参数。
// runtime/interpreter.cpp
void Interpreter::build_frame(HiObject* callable, ObjList args) {
FrameObject* frame = new FrameObject((FunctionObject*) callable, args);
frame->set_sender(_frame);
_frame = frame;
}
void Interpreter::run(CodeObject* codes) {
_frame = new FrameObject(codes);
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::CALL_FUNCTION:
if (op_arg > 0) {
args = new ArrayList<HiObject*>(op_arg);
while (op_arg--) {
args->set(op_arg, POP());
}
}
fo = static_cast<FunctionObject*>(POP());
// workaround for 'print'
if (fo == Universe::PrintFunc) {
for (int i = 0; i < args->length(); i++) {
args->get(i)->print();
}
printf("\n");
PUSH(Universe::HiNone);
}
else {
build_frame(fo, args);
}
if (args != NULL) {
delete args;
args = NULL;
}
break;
...
}
}
}
CALL_FUNCTION 总共做了三件事情。
第一,根据 op_arg 去栈里取出参数,然后放到 args 列表里(第 16 至 21 行)。第二,判断是不是 print 函数,因为我们现在还没有实现 print 函数,所以只能以一种特殊的方法把它绕过去(第 25 至 31 行)。如果不是 print 函数,就通过 build_frame 来正常地创建函数栈帧,通过这种方式,就把函数的参数传递到了 FrameObject 中(第32至34行)。最后一步,是清理临时变量(第 36 至 40 行)。
这样,我们就把函数的参数传入到函数栈帧中去了。
当函数参数被传到 _fast_locals 里以后,接着就是执行函数了。前边我们已经分析过了,add 方法的前两条字节码是 LOAD_FAST,而 LOAD_FAST 与 LOAD_GLOBAL、LOAD_CONST 一样,都是往栈上加载一个值(本质上是一个对象)。区别在于,LOAD_CONST 是从常量表里加载, LOAD_GLOBAL 是从全局变量表里加载,而 LOAD_FAST则是从栈帧的局部变量表中加载。
我们来看 LOAD_FAST 的具体实现。
void Interpreter::run(CodeObject* codes) {
_frame = new FrameObject(codes);
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::LOAD_FAST:
PUSH(_frame->fast_locals()->get(op_arg));
break;
...
case ByteCode::STORE_FAST:
_frame->_fast_locals->set(op_arg, POP());
break;
...
}
}
}
添加了这些修改以后,我们就可以进行测试了,这节课刚开始的时候那个 test_param 的例子可以正常执行了。
运行以后可以正确打印出 3。到这里,我们就完成了函数调用的传参功能。
参数默认值
在定义函数的时候,我们可以为函数的参数指定默认值。例如:
# test_default.py
def foo(a, b = 1, c = 2):
return a + b + c
print(foo(10)) # 13
print(foo(100, 20, 30)) # 150
在调用 foo 方法的时候,如果只传一个参数(第5行),这就意味着参数 a 的值为 10,b 的值则取默认值 1,c 取默认值 2。如果传三个参数(第6行),那么默认参数不起作用。
从这个例子可以看出,参数默认值是函数的一个属性,所以它应该是在函数定义的时候就和函数绑在了一起,也就是说,应该在 MAKE_FUNCTION 字节码处实现默认值的功能。
我们在 MAKE_FUNCTION 处创建了 FunctionObject ,所以默认值的最佳载体显然是 FunctionObject。如果你对这一点还有疑问,就跳转到第 7 节课和第 8 节课,搞清楚 CodeObject 和 FunctionObject 之间的区别和联系,再看后面的内容。
这里我们只需要把默认值记录在 FunctionObject 里,当调用的时候再加以处理就可以了。
接下来,我们就在 FunctionObject 里增加一个域,用来记录函数参数的默认值。
// runtime/functionObject.hpp
class FunctionObject : public HiObject {
friend class FunctionKlass;
friend class FrameObject;
private:
...
ObjList _defaults;
public:
FunctionObject(Klass* klass) {
...
_defaults = NULL;
}
...
void set_default(ObjList defaults);
ObjList defaults() { return _defaults; }
};
// runtime/functionObject.hpp
void FunctionObject::set_default(ArrayList<HiObject*>* defaults) {
if (defaults == NULL) {
_defaults = NULL;
return;
}
_defaults = new ArrayList<HiObject*>(defaults->length());
for (int i = 0; i < defaults->length(); i++) {
_defaults->set(i, defaults->get(i));
}
}
这段代码的作用是在创建 FunctionObject 的时候,使用 set_default 方法设置好参数默认值。在这个方法中,我们创建了一个新的 ArrayList 对象,而不是把参数 defaults 的指针值直接赋给 FunctionObject 的 _defaults 域。
这样做是为了方便后面实现自动内存管理,FunctionObject 所指向的对象都在 FunctionObject 的逻辑里创建,遵循这个规则方便我们分析和实现自动内存管理机制。
在基础的数据结构功能完备以后,我们就可以考虑在 MAKE_FUNCTION 的实现中使用 set_default 方法了。
在最早实现 MAKE_FUNCTION 的时候,我们就看到了 MAKE_FUNCTION 这个字节码是带有参数的。但在一开始,我们并没有关心这个参数。实际上,它的参数正是为了指明默认参数的数量。
我们把这节课一开始的那个例子,test_default 的字节码打出来看一下。
2 0 LOAD_CONST 6 ((1, 2))
2 LOAD_CONST 2 (<code object foo>)
4 LOAD_CONST 3 ('foo')
6 MAKE_FUNCTION 1 (defaults)
8 STORE_NAME 0 (foo)
可以看到,在这个例子里MAKE_FUNCTION 指令所带的参数就不再是 0 了,而是变成了 1。
MAKE_FUNCTION 至少使用两个数据,一个是CodeObject(第2行),一个是函数名称(第3行)。而参数默认值则是由指令参数指定的。在这个例子里,参数值为1 就代表构建的这个函数带一个默认值。也就是上述代码里的第一条字节码,第一行的那个 LOAD_CONST。这条指令把常量列表 (1,2)
放到了栈顶。
这里需要注意,不同版本的Python虚拟机MAKE_FUNCTION的实现是不同的,有一些版本里,参数的默认值不是以列表传递的,而是把列表里的值展开放到栈上。栈上有多少个数据则是由MAKE_FUNCTION的指令参数指定的。这种实现和Python 3.8中的实现本质是一样的,只不过区别在于默认值是整体传入还是分别传入的。
经过这样的分析,我们就可以修改 MAKE_FUNCTION的实现了。
void Interpreter::run(CodeObject* codes) {
_frame = new FrameObject(codes);
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::MAKE_FUNCTION:
w = POP(); // function name
v = POP();
fo = new FunctionObject(v);
fo->set_globals(_frame->globals());
if (op_arg == 1) {
HiList* t = (HiList*)POP();
args = new ArrayList<HiObject*>();
for (int i = 0; i < t->length(); i++) {
args->add(t->get(i));
}
}
fo->set_default(args);
PUSH(fo);
if (args != NULL) {
delete args;
args = NULL;
}
break;
...
}
}
}
上述代码先根据 op_arg 的值去栈上获取默认参数,当 op_arg 为 1 的时候,默认参数是一个列表(第 13 行),我们把这个参数从栈上取出来,然后把它的内容都装到 args 数组里(第 14 至 18 行)。然后我们把默认值传递给 FunctionObject 对象(第 20、21 行)。最后再把临时变量释放掉(第23至26行)。
这样,我们就完成了 MAKE_FUNCTION 的所有工作。要让默认参数生效,还差最后一步,那就是当调用者没有传实参的时候,使用默认参数代替实参。这个工作要在函数被调用的时候进行。
传递参数的代码在 build_frame 和 FrameObject 的构造函数里,主要是通过操作_fast_locals 数据结构来传递参数。同时,默认参数也是使用 _fast_locals 传递。所以这里我们就来修改一下 FrameObject 的构造方法,让默认参数起作用。
FrameObject::FrameObject (FunctionObject* func, ObjList args) {
...
_fast_locals = new ArrayList<HiObject*>();
if (func->_defaults) {
int dft_cnt = func->_defaults->length();
int argcnt = _codes->_argcount;
while (dft_cnt--) {
_fast_locals->set(--argcnt, func->_defaults->get(dft_cnt));
}
}
if (args) {
for (int i = 0; i < args->length(); i++) {
_fast_locals->set(i, args->get(i));
}
}
...
}
这个函数做的第一件事情是设置默认参数(第 5 至 11 行)。
我们在分析二进制文件结构时,就简单介绍过 CodeObject 的 argcount 属性了,这里是我们第一次使用这个属性。这个值代表了一个函数的参数个数。请注意,我们以倒序的方式把默认参数送入到 _fast_locals 里去(第 9 至 11 行),这是因为只有位于参数列表后面的几个参数才能使用默认值。
函数做的第二件事情是处理实际传入的参数(第 14 至 18 行)。也就是说,默认参数与实际参数在这里汇合了。你可以通过我给出的示意图理解它们整合的过程。
这里你可能会有疑问,有没有其他特殊情况呢?比如默认参数可不可能在实参之前。其实这是不用担心的,因为 Python 的语法规定默认参数必须定义在非默认参数之前。比如,以下的代码是不合法的。
Python 的编译器会对这个方法定义报错,提示无默认值参数不能出现在默认值参数之后。这样的语法保证了我们在处理默认值的时候,从后往前填入默认值的做法是绝对正确的。
一个复杂的例子
最后,我们来运行一个更加复杂的例子。
在这个例子里,我们向 make_func 函数传递了参数 5,然后又在 make_func 内部定义了一个函数为 add,函数 add 可以计算两个数的和。同时,它的第二个参数是以 5 为默认值的参数,所以 add5 就可以只接受一个参数,计算这个参数与 5 的和了。执行这个例子,结果就会是 15。
在这个例子里,用了一个之前没用过的字节码:BUILD_TUPLE,它的作用是构建默认参数列表。在 test_default 例子里,默认参数列表是以常量值的方式直接加载到栈上,而这里因为 x 是变量,所以只能在运行时通过 BUILD_TUPLE 来构建。
BUILD_TUPLE 做的事情是根据 op_arg 从栈上取参数,然后构造一个 tuple,到目前为止,我们还不能支持元组(tuple),但使用列表(list)来代替也是可以的。所以我们就提供一个简单的实现,等后面完全实现了列表和元组的功能以后,再完善这里的实现。
你可以看一下BUILD_TUPLE 的代码实现。
void Interpreter::run(CodeObject* codes) {
_frame = new FrameObject(codes);
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::BUILD_TUPLE:
lst = new HiList();
while (op_arg--) {
lst->append(POP());
}
PUSH(lst);
break;
...
}
}
}
这段代码做的事情是从栈上取多个对象(数量由op_arg决定),放入到列表中。
补齐了这个字节码以后,make_func 的例子就可以正确执行了。
总结
这节课我们实现了可以在调用函数时,向其传入参数的功能。在过去的两节课里,我们清晰地解释了 CodeObject、FunctionObject 和 FrameObject 之间的关系。
LOAD_CONST 把 CodeObject 加载到栈顶,MAKE_FUNCTION 则负责创建 FunctionObject,所以 CodeObject 和 FunctionObject 是一对多的关系。并且,CodeObject 中记录了代码的静态信息,例如字节码、常量表等等。而 FunctionObject 则多了很多动态信息,例如参数默认值等。
CALL_FUNCTION 负责真正地执行函数,函数的动态记录是使用 FrameObject 维护的,所以 FunctionObject 与 FrameObject 也是一对多的关系。
只要把图里的关系搞清楚了,这两节课的内容就全部掌握了。
从第 4 节课开始,遇到 print 函数的时候,我们都是使用一些手段规避了,作为 Python 语言中最重要的一个函数,下节课我们将正面地实现这个函数。
思考题
函数的相关机制,我们已经实现了很多了。请你自己试验一下,你的虚拟机是否可以支持 Lambda 语句。欢迎你把你试验的结果分享到评论区,也欢迎你把这节课分享给其他朋友,邀他们一起学习,我们下节课再见!
- ifelse 👍(0) 💬(0)
学习打卡
2024-10-26 - 冯某 👍(0) 💬(0)
这里没有留言
2024-10-21