跳转至

09 函数对象 :函数是依赖什么成为第一类公民的?

你好,我是海纳。

上一节课我们介绍了函数的静态代码和动态记录之间的区别,以及通过递归函数的执行过程,深入介绍了栈帧的组织结构,这一节课我们就通过编写代码实现相应的功能。

在 Python 中,函数(function)和方法(method)的意义是不同的。类中定义的成员函数被称为方法,不在类中定义的函数才是我们平常所说的狭义的函数。方法是建立在类机制上的,所以函数比方法要简单一些,这节课我们就从函数开始实现。

实现函数功能

我们先从一个最简单的例子开始,定义一个函数,让它打印一个字符串。

def foo():
    print("hello")

foo()

将这段代码存为一个文件,名为 func.py,然后使用 python -m compileall func.py 命令,把这个文件编译成 func.pyc 文件。再使用 show_file.py 工具,查看 func.pyc 文件的构造。

  1           0 LOAD_CONST               0 (<code object fo>)
              2 LOAD_CONST               1 ('foo')
              4 MAKE_FUNCTION            0
              6 STORE_NAME               0 (foo)

  4           8 LOAD_NAME                0 (foo)
             10 CALL_FUNCTION            0
             12 POP_TOP
             14 LOAD_CONST               2 (None)
             16 RETURN_VALUE

这里展示的是定义 foo 函数和调用 foo 函数的字节码。这一段里有两个字节码是我们之前没有实现的,就是 MAKE_FUNCTION 和 CALL_FUNCTION。前者用于定义函数,后者用于调用函数。这一节课的关键就是实现这两个字节码。

第 4 节课使用 print 函数的时候,实际上我们就已经遇到过 CALL_FUNCTION 指令了,但是因为当时没有函数的功能,所以我们就使用了一些特殊的手段绕过去了,现在就应该正常地实现这条指令了。

除了上述两个字节码,你还要注意第一条指令和第二条指令,第一个是LOAD_CONST,从常量表里加载第一项,而这一项是一个 code object。在这里code object 的定义出现了嵌套,也就是说 foo 所对应的 code object 出现在了主模块的常量表里。

第二个 LOAD_CONST从常量表里加载了一个字符串,代表函数的名字。这是与Python 2.7有区别的地方。在Python 2中,MAKE_FUNCTION 指令并不需要函数名字。

接下来,我们重点查看主模块的 consts 表的内容。

   consts
      code
         argcount 0
         nlocals 0
         stacksize 2
         flags 0043
         code 740064018301010064005300
  2           0 LOAD_GLOBAL              0 (print)
              2 LOAD_CONST               1 ('hello')
              4 CALL_FUNCTION            1
              6 POP_TOP
              8 LOAD_CONST               0 (None)
             10 RETURN_VALUE
         consts
            None
            'hello'
         names ('print',)
         varnames ()
         freevars ()
         cellvars ()
         filename 'test_func.py'
         name 'foo'
         firstlineno 1
         lnotab 0001
      'foo'
      None

这就很清楚了,consts 表包含了三项。其中的第一项,就是一个 code object,它的 name 就是 foo。前边我们在讲解 code object 的结构的时候也提到过,code object 是可以嵌套定义的,在二进制文件中用字母 'c' 代表 code object。在实现 BinaryFileParser 的时候,我们已经提前支持了这一特性。这里就不用再修改了。

实现函数栈帧

上一节课我们讲过,每一次函数调用都会有一个活动记录与之对应。这个活动记录也被叫做栈帧(frame)。

在当前的虚拟机执行器里,还没有栈帧的概念,因此我们要实现一种数据结构,来记录函数的调用过程,这个数据结构就是 FrameObject。每一次调用一个函数,就有一个这次调用的活动记录,也就是说每次函数调用,都会创建一个 FrameObject。每次函数执行结束,相应的 FrameObject 也会被销毁。

在 Interpreter 里,有很多变量是为函数执行时的活动记录服务的。例如,pc 记录了程序当前执行到的位置;locals 表记录了变量的值等等。本质上这些变量是与函数的执行绑定的,所以它们应该被封装到 FrameObject 里,而不是 Interpreter 中。

所以,接下来我们就应该重构 Interpreter,把所有这些与执行状态相关的量移到 FrameObject 中。

首先,我们来定义 FrameObject。

class FrameObject {
public:
    FrameObject(CodeObject* codes);
    ~FrameObject();

    ArrayList<HiObject*>* _stack;
    ArrayList<Block*>*    _loop_stack;

    ArrayList<HiObject*>* _consts;
    ArrayList<HiObject*>* _names;

    Map<HiObject*, HiObject*>* _locals;

    CodeObject*           _codes;
    int                   _pc;

public:
    void set_pc(int x)              { _pc = x; }
    int  get_pc()                   { return _pc; }

    ArrayList<HiObject*>* stack()                 { return _stack; }
    ArrayList<Block*>* loop_stack()               { return _loop_stack; }
    ArrayList<HiObject*>* consts()                { return _consts; }
    ArrayList<HiObject*>* names()                 { return _names; }
    Map<HiObject*, HiObject*>* locals()           { return _locals; }

    bool has_more_codes();
    unsigned char get_op_code();
    int  get_op_arg();
};

上述代码已经把 Interpreter 中的相关变量都转移到 FrameObject 中了。注意第 3 行的构造方法,以前虚拟机要执行一段字节码的时候,是通过直接调用 Interpreter 的 run 方法来实现的,其中 CodeObject 是 run 方法的参数。现在则要先创建一个 FrameObject,代码执行时,就只会影响 FrameObject 中的 pc、locals 等变量。

这段代码的逻辑很简单,FrameObject 只是对一些状态变量的封装,包括代码第 27 至 29 行所声明的三个方法,也不过是对一些简单逻辑的封装。具体内容你可以看我给出的代码。

// this constructor is used for module only.
FrameObject::FrameObject(CodeObject* codes) {
    _consts  = codes->_consts;
    _names   = codes->_names;
    
    _locals  = new Map<HiObject*, HiObject*>();

    _stack  = new ArrayList<HiObject*>();
    _loop_stack  = new ArrayList<Block*>();

    _codes = codes;
    _pc    = 0;
}

int FrameObject::get_op_arg() {
    int byte1 = _codes->_bytecodes->value()[_pc++] & 0xff;
    int byte2 = _codes->_bytecodes->value()[_pc++] & 0xff;
    return byte2 << 8 | byte1;
}

unsigned char FrameObject::get_op_code() {
    return _codes->_bytecodes->value()[_pc++];
}

bool FrameObject::has_more_codes() {
    return _pc < _codes->_bytecodes->length();
}

和它相应的,Interpreter的run方法也发生了很多变化。

// [runtime/interpreter.cpp]
// stack has been moved into FrameObject.
#define PUSH(x)       _frame->stack()->add((x))
#define POP()         _frame->stack()->pop()
#define STACK_LEVEL() _frame->stack()->size()
...
void Interpreter::run(CodeObject* codes) {
    _frame = new FrameObject(codes);
    while (_frame->has_more_codes()) {
        unsigned char op_code = _frame->get_op_code();
        bool has_argument = (op_code & 0xFF) >= ByteCode::HAVE_ARGUMENT;

        int op_arg = -1;
        if (has_argument) {
            op_arg = _frame->get_op_arg();
        }
        ...
    }
}

除了上述代码所展示的修改以外,run 方法里还有很多处修改,这里我就不一一列出了,你可以通过工程提交记录自己比较代码还有哪些变化。

有了 FrameObject 这个基础结构以后,我们终于可以把注意力转向 MAKE_FUNCTION  和 CALL_FUNCTION 这两个字节码了。

创建 FunctionObject

有了代表栈帧的 FrameObject 和代表代码的 CodeObject,分别用于描述动态的活动记录和静态的代码信息,对于普通的编程语言就已经够了。但是在 Python 中,函数是第一类公民,所以虚拟机中还需要一个表示函数的对象。我们先来看一个例子。

def make_func(a):
    def add(b):
        return a + b

    return add

add3 = make_func(3)
add5 = make_func(5)

print(add3(2))
print(add5(2))

在这个例子里,add 函数所对应的代码只有一份,也就是说,虚拟机在执行的过程中只需要一个 CodeObject 就可以了。但是,add3 和 add5 这两个函数却明显不相同,虽然它们的代码是相同的,但是因为绑定的参数不同,所以就成为了两个不同的函数。

从这个例子中,我们可以看出,CodeObject 所代表的静态代码和 FunctionObject 所代表的运行时构建出来的函数是有区别的。当我们把一个函数像变量一样用于函数返回值、参数等场景中,实际上使用的是 FunctionObject,而不是 CodeObject。

不同于 C 语言中的函数定义,FunctionObject 是一个真正的虚拟机对象。它可以被变量引用,也可以被添加到列表中。总之,所有可以对普通对象进行的操作,都可以施加到 FunctionObject 上。一个 CodeObject 可以对应多个 FunctionObject。

图片

如上图所示,经过这些分析,我们可以这样定义 FunctionObject:

class FunctionKlass : public Klass {
private:
    FunctionKlass();
    static FunctionKlass* instance;

public:
    static FunctionKlass* get_instance();

    virtual void print(HiObject* obj);
};

class FunctionObject : public HiObject {
friend class FunctionKlass;
friend class FrameObject;

private:
    CodeObject* _func_code;
    HiString*   _func_name;

    unsigned int _flags;

public:
    FunctionObject(HiObject* code_object);
    FunctionObject(Klass* klass) {
        _func_code = NULL;
        _func_name = NULL;
        _flags     = 0;

        set_klass(klass);
    }
    
    HiString*  func_name()   { return _func_name; }
    int  flags()             { return _flags; }
};

FunctionObject 与普通的 Object 相比,并没有什么特别之处,所以它也要遵守 Klass-Oop 的二元结构。上述程序开始的地方定义的 FunctionKlass 用来指示对象的类型。

接下来定义的 FunctionObject 的属性也很简单,一个是指向自己所对应的 CodeObject 指针,还有一个代表了方法名称的字符串,最后一个属性 _flags,用于指示函数的类型,我们现在先不用,留到以后扩展。你可以看一下FunctionKlass 和 FunctionObject 中定义的方法实现。

FunctionKlass* FunctionKlass::instance = NULL;

FunctionKlass* FunctionKlass::get_instance() {
    if (instance == NULL)
        instance = new FunctionKlass();

    return instance;
}

FunctionKlass::FunctionKlass() {
}

void FunctionKlass::print(HiObject* obj) {
    printf("<function : ");
    FunctionObject* fo = static_cast<FunctionObject*>(obj);

    assert(fo && fo->klass() == (Klass*) this);
    fo->func_name()->print();
    printf(">");
}

FunctionObject::FunctionObject(HiObject* code_object) {
    CodeObject* co = (CodeObject*) code_object;

    _func_code = co;
    _func_name = co->_co_name;
    _flags     = co->_flag;

    set_klass(FunctionKlass::get_instance());
}

FunctionKlass 和其他的 Klass 一样,也采用了单例的实现方式。FunctionObject 的 print 方法,主要用于打印方法名。在 FrameObject 的构造函数里,把该对象的 klass 设置为 FunctionKlass。这就是上述代码的全部逻辑。

有了 FunctionObject,我们再来看 MAKE_FUNCTION 的具体实现,这个字节码的任务是通过 CodeObject,创建一个 FunctionObject。与这个功能相关的类和构造函数都已经实现好了,所以 MAKE_FUNCTION 的实现就很简单了。

void Interpreter::run(CodeObject* codes) {
    _frame = new FrameObject(codes);
    while (_frame->has_more_codes()) {
        unsigned char op_code = _frame->get_op_code();
        //...
        FunctionObject* fo;
        //...
        switch (op_code) {
        //...
            case ByteCode::MAKE_FUNCTION:
                v = POP();
                fo = new FunctionObject(v);
                PUSH(fo);
                break;
        //...
        }
    }
}

这里需要注意的是,MAKE_FUNCTION 指令本身是带有参数的,它是一个整数,代表了这个函数有多少个默认参数。我们现在还没有关心函数调用传参的问题,所以就先把 MAKE_FUNCTION 的参数忽略掉。

执行函数

有了函数对象,接下来我们就可以研究函数是如何被执行的了。

当函数被调用时,最关键的是正确地维护与这个函数相对应的 FrameObject。这节课开头我们已经介绍了,FrameObject 中存储了程序运行时所需要的所有信息,例如程序计数器、局部变量表等等。

当虚拟机执行一个函数的时候,需要为这个函数创建对应的 FrameObject;当一个函数的执行结束,也就是执行到 return 指令时,虚拟机就应该销毁对应的 FrameObject,然后回到调用者的栈帧中去。

为了维护这种函数调用时的栈帧切换,我们可以在 FrameObject 里增加一个链表项,将所有的 FrameObject 串起来,每次新增的 FrameObject 只能增加到链表的头部。同理,销毁时也只能从链表的头部进行删除。这样的话,FrameObject 的实现必须有所调整。

// runtime/frameObject.hpp
class FrameObject {
public:
    FrameObject(CodeObject* codes);
    FrameObject(FunctionObject* func);
    ...
    FrameObject*          _sender;
    ...
    void set_sender(FrameObject* x) { _sender = x; }
    FrameObject* sender()           { return _sender;}
    ...
};

// runtime/frameObject.cpp
FrameObject::FrameObject (FunctionObject* func) {
    _codes   = func->_func_code;
    _consts  = _codes->_consts;
    _names   = _codes->_names;

    _locals  = new Map<HiObject*, HiObject*>();

    _stack   = new ArrayList<HiObject*>();
    _loop_stack  = new ArrayList<Block*>();

    _pc      = 0;
    _sender  = NULL;
}

FrameObject 中的构造函数发生了变化,新的实现中,它的参数变成了 FunctionObject。目前看来,这个构造函数与第一个版本,即以CodeObject为参数的那个构造函数,并没有太大的差别,但当后面我们的函数中有参数和返回值的时候,这两个构造函数就会产生差异了。

FrameObject 里还新增了 sender 这个域,这个域里会记录调用者的栈帧,当函数执行结束的时候,就会通过这个域返回到调用者的栈帧里。

前面我们介绍过,帧是用链表串起来的,创建的时候挂到链表头上,销毁的时候从链表头上的第一帧开始销毁。满足后进先出的特性,所以这是一个典型的栈结构,这再次表明了函数调用的活动记录被叫做栈帧的原因。

最后,我们可以实现 CALL_FUNCTION 这个字节码了。我们在 Interpreter 里增加一个 build_frame 方法,这个方法用于创建新的 FrameObject,被调用的方法的内部状态全部由新的 FrameObject 维护。这些内部状态包括程序计数器 pc、局部变量表 locals 等。

void Interpreter::run(CodeObject* codes) {
    _frame = new FrameObject(codes);
    while (_frame->has_more_codes()) {
        unsigned char op_code = _frame->get_op_code();
        ...
        FunctionObject* fo;
        ...
        switch (op_code) {
        ...
            case ByteCode::CALL_FUNCTION:
                build_frame(POP());
                break;
        ...
        }
    }
}

void Interpreter::build_frame(HiObject* callable) {
    FrameObject* frame = new FrameObject((FunctionObject*) callable);
    frame->set_sender(_frame);
    _frame = frame;
}

这段代码里定义的 build_frame,将 FrameObject 切换到新函数后就立即退出,返回到 run 方法里继续执行。与调用 build_frame 方法之前不同的是,_frame 变量已经发生了变化。_frame里的程序计数器已经指向要调用的目标方法里了。

如上图所示,作为调用者,老的程序计数器还在它所对应的 FrameObject 里保存着,并且指向了CALL_FUNCTION 的下一条指令。而 _frame 变量现在指向了 foo 所对应的栈帧,当被调用的函数结束时,虚拟机就会把 _frame 变量重新指回调用者的 FrameObject,从而回到调用者的栈帧里继续执行。

所以,我们可以这样实现 RETURN_VALUE 字节码。

void Interpreter::run(CodeObject* codes) {
    _frame = new FrameObject(codes);
    eval_frame();
    destroy_frame();
}

void Interpreter::eval_frame() {
    ...
    while (_frame->has_more_codes()) {
        unsigned char op_code = _frame->get_op_code();
        ...
        FunctionObject* fo;
        ...
        switch (op_code) {
        ...
            case ByteCode::RETURN_VALUE:
                _ret_value = POP();
                if (_frame->is_first_frame())
                    return;
                leave_frame();
                break;
        ...
        }
    }
}

void Interpreter::leave_frame() {
    destroy_frame();
    PUSH(_ret_value);
}

void Interpreter::destroy_frame() {
    FrameObject* temp = _frame;
    _frame = _frame->sender();

    delete temp;
}

class FrameObject {
    ...
    bool is_first_frame()           { return _sender == NULL; }
};

我们来分析一下RETURN_VALUE 字节码的具体实现,首先它将被调用函数的返回值赋给了 ret_value 变量(第 17 行),然后调用 leave_frame 方法离开当前函数栈帧。
leave_frame 方法做了三件事情。

  1. 使用 destroy_frame 方法将被调用者的 FrameObject 销毁(第36行)。
  2. 将 _frame 变量切换为自己的调用者的栈帧(第34行)。
  3. 将返回值 push 到调用者的栈帧中去(第29行)。

注意,这是我们的项目中第一次使用 delete 来销毁和释放一个对象,其他对象都没有通过这种方法释放。这是因为后面虚拟机的内部对象都会使用垃圾回收器进行自动管理,但是我们并不打算将 FrameObject 也纳入自动内存管理中。FrameObject 的生命周期是确定的,所以虚拟机就在能够释放的时候尽早释放,这样做对自动内存管理的性能是有好处的。

自动内存管理的相关知识,我们会在后面的章节中继续介绍。

如果某个 FrameObject 的 sender 为 NULL,就代表它是第一个栈帧,是程序开始的地方,或者说是“主程序”,因为它没有调用者。这种情况下,只需要直接结束 run 的逻辑即可,也就是直接通过 return 结束(第 19 行)。

同时,我们做一点代码的重构,将 run 方法的逻辑改得更清晰一点。就是把原来在 run 方法中的那些解释执行的逻辑搬到 eval_frame 方法中。而 run 方法则简化为创建 frame,对 frame 进行解释执行以及销毁 frame 三个步骤。这样可以使代码的可读性更高一些。

我们执行以下测试用例就会发现,函数的返回值机制已经完美地实现了。

def hello():
    print("hello")
    return "world"

print(hello())

增加了函数以后,变量的定义和作用域受到了很大的影响。下一节课,我们就将重新审视变量的实现方式。

总结

上一节课介绍了函数的静态信息和动态信息之后,我们这一节课就实现了相关的功能。这涉及到了2个关键的结构,CodeObject和FrameObject。其中,CodeObject 用于描述静态信息,FrameObject 用于描述动态信息。

由于 Python 语言中函数是第一类公民,这就意味着函数可以像变量一样作为返回值和参数进行传递,所以即使是同一份代码,也可以创建出多个不同的函数对象。这就要求虚拟机中要提供可以描述函数的对象,也就是 FunctionObject

在实现了上述三个关键的数据结构以后,就可以实现 MAKE_FUNCTION 和 CALL_FUNCTION 两条字节码了。MAKE_FUNCTION 的作用是使用 CodeObject 创建 FunctionObject,CALL_FUNCTION 则是创建新的函数栈帧,并转入新的帧里执行。

当函数执行结束以后,遇到 RETURN_VALUE 指令。这个时候,虚拟机就退出当前栈帧,返回到调用者的栈帧中,并且把返回值带回到调用者栈帧中,这样就完成了一次函数调用。

定义函数会创建不同的变量的作用域,这对变量的使用产生了比较大的影响,所以下节课我们就将重新梳理和实现变量的相关功能。

思考题

请你多设计几个测试用例,验证把函数作为变量进行赋值是否可以正确执行。欢迎你把你验证后的结果分享到评论区,也欢迎你把这节课的内容分享给其他朋友,我们下节课再见!

精选留言(3)
  • 骨汤鸡蛋面 👍(1) 💬(1)

    续一个上次的评论,尝试总结下:字节码可以视为一个dsl文件,然后用c++写了一个程序/引擎去执行这个文件 ==> 这个c++程序有一些基本设计 Klass-Oop(包括内建class 与自定义class)来支持一个基本流程Interpreter.run,每一个字节码都对应一段代码的执行,也就是涉及的内建class与自定义class 新增、删除、方法的调用。

    2024-05-26

  • qinsi 👍(0) 💬(1)

    示例代码似乎混进了旧版虚拟机的代码...根据代码库里的代码才跑起来. 测试程序生成的字节码里似乎有后面才会讲到的内容, 可能需要先囤几节课再一起看

    2024-05-26

  • ifelse 👍(0) 💬(0)

    学习打卡

    2024-10-24