定义和调用

我们的解释器最开始只支持顺序执行,后来加入控制结构后支持条件跳转,并且block也有控制变量作用域的功能。而函数在解析、执行或作用域等方面都是更加独立的存在。为此,需要修改当前的语法分析和虚拟机执行的框架。

改造ParseProto

函数的定义是可以嵌套的,即可以在函数内部再次定义函数。如果把整个代码看做是主函数,那么我们当前的语法分析相当于只支持这一个函数。为了支持函数的嵌套定义,需要对语法分析进行改造。首先改造数据结构。

当前,语法分析过程的上下文结构体是ParseProto,同时这也是返回给虚拟机执行的结构体。定义如下:

pub struct ParseProto<R: Read> {
    pub constants: Vec<Value>,
    pub byte_codes: Vec<ByteCode>,

    sp: usize,
    locals: Vec<String>,
    break_blocks: Vec<Vec<usize>>,
    continue_blocks: Vec<Vec<(usize, usize)>>,
    gotos: Vec<GotoLabel>,
    labels: Vec<GotoLabel>,
    lex: Lex<R>,
}

每个字段的具体含义之前已经详细介绍过,这里忽略。这里只按照函数的独立性来区分这些字段:

  • 最后的lex字段是贯穿整个代码解析的;
  • 其余所有字段都是函数内部的数据。

为了支持函数的嵌套定义,需要把全局部分(lex字段)和函数部分(其他字段)拆开。新定义函数解析的数据结构PerFuncProto_(因为我们最终不会采用这个方案,所以结构体名字最后加了_),包括原来的ParseProto中去掉lex后剩下的其他字段:

struct PerFuncProto_ {
    pub constants: Vec<Value>,
    pub byte_codes: Vec<ByteCode>,
    sp: usize,
    ... // 省略更多字段
}

为了支持函数的嵌套,就需要支持同时有多个函数解析体。最直观的思路是定义一个函数体列表:

struct ParseProto<R: Read> {
    funcs: Vec<PerFuncProto_>,  // 刚才定义的函数解析体PerFuncProto_的列表
    lex: Lex<R>,  // 全局数据
}

每次新嵌套一层函数,就向funcs字段中压入一个新成员;解析完函数后弹出。funcs的最后一个成员就代表当前函数。这样定义很直观,但是有个问题,访问当前函数的所有字段都很麻烦,比如要访问constants字段,就需要 self.funcs.last().unwrap().constants 读取或者 self.funcs.last_mut().unwrap().constants 写入。太不方便了,执行效率应该也受影响。

如果是C语言,那么这个问题很好解决:在ParseProto中再新增一个PerFuncProto_类型的指针成员,比如叫current,指向funcs的最后一个成员。每次压入或弹出函数体时,都更新这个指针。然后就可以直接使用这个指针来访问当前函数了,比如 self.current.constants 。这个做法很方便但是Rust认为这是不“安全”的,因为在Rust的语法层面无法保证这个指针的有效性。虽然对这个指针的更新只有两个地方,相对安全,但是既然用了Rust,就要按照Rust的规矩。

对于Rust而言,可行的解决方案是增加一个索引(而非指针),比如叫icurrent,指向funcs的最后一个成员。同样也是每次在压入或弹出函数体时,都更新这个索引。而在访问当前函数信息时就可以用 self.funcs[icurrent].constants 。虽然Rust语言允许这么做,但这其实只是上面指针方案的变种,仍然可能由于索引的错误更新导致bug。比如索引超过funcs长度则会panic,而如果小于预期则会出现更难调试的代码逻辑bug。另外在执行时,Rust的列表索引会跟列表长度做比较,也会稍微影响性能。

还有一个不那么直观但没有上述问题的方案:利用递归。在解析嵌套的函数时,最自然的方法就是递归调用解析函数的代码,那么每次调用都会有独立的栈(Rust的调用栈),于是可以每次调用时都创建一个函数解析体并用于解析当前Lua函数,在调用结束后就返回这个解析体供外层函数处理。这个方案中,解析过程中只能访问当前函数的信息,不能访问外层函数的信息,自然也就没有刚才说的访问当前函数信息不方便的问题了。比如访问constants依然是用self.constants,甚至无需修改现有代码。唯一要解决的是全局数据Lex,这个可以作为解析函数的参数一直传递下去。

这个方案中,无需定义新的数据结构,只需要把原来的ParseProto中的lex字段从Lex类型修改为&mut Lex即可。解析Lua函数的语法分析函数定义原来是ParseProto的方法,定义为:

impl<'a, R: Read> ParseProto<'a, R> {
    fn chunk(&mut self) {
        ...
    }

现在改为普通函数,定义为:

fn chunk(lex: &mut Lex<impl Read>) -> ParseProto {
    ...
}

其中参数lex是全局数据,每次递归调用都直接传入下一层。返回值是在chunk()内创建的当前Lua函数的解析信息。

另外,chunk()函数内部调用block()函数解析代码,后者返回block的结束Token。之前chunk()函数只用来处理整个代码块,所以结束Token只可能是Token::Eos;而现在也可能被用来解析其他的内部函数,此时预期的结束Token就是Token::End。所以chunk()函数要新增一个参数,表示预期的结束Token。于是定义改成:

fn chunk(lex: &mut Lex<impl Read>, end_token: Token) -> ParseProto {
    ...
}

新增FuncProto

刚才改造了ParseProto,修改了lex的类型。现在顺便再做个小的优化。ParseProto中前面两个pub修饰的字段同时也是返回给虚拟机执行使用的;后面的大部分字段只是语法分析时使用的,是内部数据,并不需要返回给虚拟机。可以把这两部分拆开,从而只返回给虚拟机需要的部分。为此,新增FuncProto数据结构:

// 返回给虚拟机执行的信息
pub struct FuncProto {
    pub constants: Vec<Value>,
    pub byte_codes: Vec<ByteCode>,
}

#[derive(Debug)]
struct ParseProto<'a, R: Read> {
    // 返回给虚拟机执行的信息
    fp: FuncProto,

    // 语法分析内部数据
    sp: usize,
    locals: Vec<String>,
    break_blocks: Vec<Vec<usize>>,
    continue_blocks: Vec<Vec<(usize, usize)>>,
    gotos: Vec<GotoLabel>,
    labels: Vec<GotoLabel>,
    lex: Lex<R>,

    // 全局数据
    lex: &'a mut Lex<R>,
}

于是chunk()函数的返回值就从ParseProto改为FuncProto。其完整定义如下:

fn chunk(lex: &mut Lex<impl Read>, end_token: Token) -> FuncProto {
    // 生成新的ParseProto,用以解析当前新的Lua函数
    let mut proto = ParseProto::new(lex);

    // 调用block()解析函数
    assert_eq!(proto.block(), end_token);
    if let Some(goto) = proto.gotos.first() {
        panic!("goto {} no destination", &goto.name);
    }

    // 只返回FuncProto部分
    proto.fp
}

这样,在语法分析Lua内嵌函数时,只要递归调用chunk(self.lex, Token::End)即可。下面介绍具体的语法分析。

语法分析

上面介绍了解析Lua函数的大致流程,现在来看具体的语法分析。到现在语法分析应该已经轻车熟路了,按照BNF执行即可。Lua的函数定义有3个地方:

  1. 全局函数;
  2. 局部函数:
  3. 匿名函数,是表达式exp语句的一种情况。

其BNF规则分别如下:

stat :=
    `function` funcname funcbody | # 1.全局函数
    `local` `function` Name funcbody | # 2.局部函数
    # 省略其他情况 

exp := functiondef | 省略其他情况
functiondef := `function` funcbody # 3.匿名函数

funcbody ::= ‘(’ [parlist] ‘)’ block end # 函数定义

由上述规则可见这3种定义的区别只是在开头,而最后都是归于funcbody。这里只介绍最简单的第2种情况,局部函数。

    fn local_function(&mut self) {
        self.lex.next(); // 跳过关键字`function`
        let name = self.read_name(); // 函数名,或者称为局部变量名
        println!("== function: {name}");

        // 暂时不支持参数,跳过 `()`
        self.lex.expect(Token::ParL);
        self.lex.expect(Token::ParR);

        // 调用chunk()解析函数
        let proto = chunk(self.lex, Token::End);

        // 把解析的结果FuncProto,放入常量表中
        let i = self.add_const(Value::LuaFunction(Rc::new(proto)));
        // 通过LoadConst字节码加载函数
        self.fp.byte_codes.push(ByteCode::LoadConst(self.sp as u8, i as u16));

        // 创建局部变量
        self.locals.push(name);
    }

解析过程很简单。需要说明的是,对chunk()函数返回的函数原型FuncProto的处理方法,是作为一个常量放到常量表中。可以对比字符串是由一系列字符序列组成的常量;而函数原型FuncProto就是由一系列常量表和字节码序列组成的常量。同样也是存在常量表中,同样也是用LoadConst字节码来加载。

为此,需要新增一种Value类型LuaFunction来代表Rust函数,并把原来代表Lua函数的类型从Function改为RustFunction

pub enum Value {
    LongStr(Rc<Vec<u8>>),
    LuaFunction(Rc<FuncProto>),
    RustFunction(fn (&mut ExeState) -> i32),

LuaFunction关联的数据类型是Rc<FuncProto>,从这里也可以看到跟字符串常量的相似。

以上完成了“定义函数”的语法分析,跟函数相关的还有“调用函数”的语法分析。但是在“调用函数”的时候,Lua函数和Rust函数是同等对待的,Lua程序员在调用函数时甚至不知道这个函数是用什么实现的;由于之前已经完成了Rust函数print()调用的语法分析,所以这里无需特定为Lua函数的调用再做语法分析。

虚拟机执行

跟语法分析一样,我们之前的虚拟机执行部分也是只支持一层Lua函数。为了支持函数调用,最简单的办法就是递归调用虚拟机执行,即execute()函数。代码如下:

    ByteCode::Call(func, _) => {
        self.func_index = func as usize;
        match &self.stack[self.func_index] {
            Value::RustFunction(f) => { // 之前就支持的Rust函数
                f(self);
            }
            Value::LuaFunction(f) => { // 新增的Lua函数
                let f = f.clone();
                self.execute(&f); // 递归调用虚拟机!
            }
            f => panic!("invalid function: {f:?}"),
        }
    }

但是,需要对栈做特殊处理。语法分析时,每次解析新函数,栈指针(ParseProto结构中的sp字段)都是从0开始。因为在语法分析时,并不知道在虚拟机执行时栈的绝对起始地址。那么,在虚拟机执行的时候,在访问栈时,使用的字节码中的栈索引,需要加上当前函数的栈起始地址的偏移。比如对于如下Lua代码:

local a, b = 1, 2
local function foo()
    local x, y = 1, 2
end
foo()

在语法分析foo()函数定义时,局部变量x和y的栈地址分别是0和1。在执行最后一行代码,调用foo()函数时,函数foo放在栈的绝对索引2处,此时局部变量x和y的绝对索引就是3和4。那么虚拟机执行的时候,就需要把相对地址0和1,转换为3和4。

   绝对地址     相对地址
        +-----+ <---主函数的base
      0 |  a  | 0
        +-----+
      1 |  b  | 1
        +-----+
      2 | foo | 2
        +-----+ <---foo()函数的base
      3 |  x  | 0
        +-----+
      4 |  y  | 1
        +-----+
        |     |

之前在执行Rust函数print()时,为了让print()函数能读取到参数,所以在ExeState中设置了func_index成员,用来指向函数在栈上的地址。现在调用Lua函数,依然如此。只不过,这里把func_index改名为base,并指向函数的下一个地址。

    ByteCode::Call(func, _) => {
        self.base += func as usize + 1; // 设置函数在栈上的绝对地址
        match &self.stack[self.base-1] {
            Value::RustFunction(f) => {
                f(self);
            }
            Value::LuaFunction(f) => {
                let f = f.clone();
                self.execute(&f);
            }
            f => panic!("invalid function: {f:?}"),
        }
        self.base -= func as usize + 1; // 恢复
    }

之前所有对栈的写操作,都是调用的set_stack()方法,现在需要加上self.base偏移:

    fn set_stack(&mut self, dst: u8, v: Value) {
        set_vec(&mut self.stack, self.base + dst as usize, v);  // 加上self.base
    }

之前所有对栈的读操作都是直接self.stack[i],现在也要提取一个新函数get_stack(),并在访问栈时加上self.base偏移:

    fn get_stack(&self, dst: u8) -> &Value {
        &self.stack[self.base + dst as usize]  // 加上self.base
    }

至此,我们完成了Lua函数的最基本的定义和调用。有赖于递归的力量,代码改动并不大。但距离完整的函数功能,这只是一个起步。下一节增加参数和返回值的支持。