跳转至

11 函数的参数:赋予函数意义的关键特性

你好,我是海纳。

在前面的课程中,我们实现了函数的基本功能。其中第 9 节课我们讲解了如何定义一个函数对象,第 10 节课实现了调用函数的功能,但是第 10 节课的函数调用是不支持传递参数的。然而函数的最重要功能就是接收参数,进行运算并返回计算结果。上一节课我们展示了函数如何创建栈帧,进行运算并且返回计算结果。那么这一节课我们来关注如何向一个函数传递参数。

Python 的传参机制

Python 中传递参数的机制比很多语言都要复杂,所以我们把参数的实现放在最后来讲。和以前的方法一样,我们先写测试用例,再观察例子所对应的字节码。

创建 test_param.py:

# test_param.py
def add(a, b): 
    return a + b 

print(add(1, 2))

然后通过 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 的语法规定默认参数必须定义在非默认参数之前。比如,以下的代码是不合法的。

//SyntaxError: non-default argument follows default argument
def foo(a = 1, b): 
    return a + b 

foo(2)

Python 的编译器会对这个方法定义报错,提示无默认值参数不能出现在默认值参数之后。这样的语法保证了我们在处理默认值的时候,从后往前填入默认值的做法是绝对正确的。

一个复杂的例子

最后,我们来运行一个更加复杂的例子。

def make_func(x):
    def add(a, b = x): 
        return a + b 

    return add

add5 = make_func(5)
print add5(10)

在这个例子里,我们向 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 语句。欢迎你把你试验的结果分享到评论区,也欢迎你把这节课分享给其他朋友,邀他们一起学习,我们下节课再见!

精选留言(2)
  • ifelse 👍(0) 💬(0)

    学习打卡

    2024-10-26

  • 冯某 👍(0) 💬(0)

    这里没有留言

    2024-10-21