if语句

条件判断语句跟之前已经实现的语句最大的不同是:不再顺序执行字节码了,可能出现跳转。为此,我们新增字节码Test,关联2个参数:

  • 第一个参数,u8类型,判断条件在栈上的位置;
  • 第二个参数,u16类型,向前跳转字节码条数。

这个字节码的语义是:如果第一个参数代表的语句为假,那么向前跳转第二个参数指定条数的字节码。控制结构图如下:

+-------------------+
| if condition then |---\ 如果condition为假,则跳过block
+-------------------+   |
                        |
    block               |
                        |
+-----+                 |
| end |                 |
+-----+                 |
<-----------------------/

Test字节码的定义如下:

pub enum ByteCode {
    // condition structures
    Test(u8, u16),

第二个参数是跳转的字节码条数,即相对位置。如果使用绝对位置,那么解析和执行的代码都会稍微简单一些,但表达能力就会差一些。16bit的范围是65536。如果使用绝对位置,那么一个函数内超过65536之后的代码,就不能使用跳转字节码了。而如果使用相对位置,那么就支持在字节码本身的65536范围内跳转,就可以支持很长的函数了。所以我们使用相对位置。这也引入了一个一直忽略的问题,就是字节码中参数的范围,比如栈索引参数都是用的u8类型,那么如果一个函数中超过了256个局部变量,就会溢出,造成bug。后续需要特别处理参数的范围问题。

按照上面的控制结构图,完成if语句的语法分析代码如下:

    fn if_stat(&mut self) {
        let icond = self.exp_discharge_top();  // 读取判断语句
        self.lex.expect(Token::Then);  // 语法:then关键字

        self.byte_codes.push(ByteCode::Test(0, 0)); // 生成Test字节码占位,两个参数后续补上
        let itest = self.byte_codes.len() - 1;

        // 解析语法块!并预期返回end关键字,暂时不支持elseif和else分支
        assert_eq!(self.block(), Token::End);

        // 修复Test字节码参数。
        // iend为字节码序列当前位置,itest为Test字节码位置,两者差就是需要跳转的字节码条数。
        let iend = self.byte_codes.len() - 1;
        self.byte_codes[itest] = ByteCode::Test(icond as u8, (iend - itest) as u16);
    }

代码流程已经在注释里逐行说明。这里需要详细说明的是递归调用的block()函数。

block的结束

原来的block()函数实际是整个语法分析的入口,只执行一次(而没有递归调用),一直读到源代码末尾Token::Eos作为结束:

    fn block(&mut self) {
        loop {
            match self.lex.next() {
                // 这里省略其他语句解析
                Token::Eos => break, // Eos则退出
            }
        }
    }

现在要支持的if语句中的代码块,预期的结束是关键字end;后续还会包括elseifelse等其他关键字。代码块的结束并不只是Token::Eos了,就需要修改block()函数,把不是合法语句开头的Token(比如Eos,关键字end等)都认为是block的结束,并交由调用者判断是否为预期的结束。具体编码有2种修改方法:

  • lex.peek()代替上述代码中的lex.next(),如果看到的Token不是合法语句开头,则退出循环,此时这个Token还没有被消费读取。外部调用者再调用lex.next()读取这个Token做判断。如果这么做,那么目前所有的语句处理代码,都要在最开始加上一个lex.next()来跳过看到的Token,比较啰嗦。比如上一段里的if_stat()函数,就要用lex.next()跳过关键字if

  • 仍然用lex.next(),对于读到的不是合法语句开头的Token,作为函数返回值,返回给调用者。我们采用这种方法,代码如下:

    fn block(&mut self) -> Token {
        loop {
            match self.lex.next() {
                // 这里省略其他语句解析
                t => break t, // 返回t
            }
        }
    }

所以上面的if_stat()函数中,就要判断block()的返回值为Token::End

        // 解析语法块!并预期返回end关键字,暂时不支持elseif和else分支
        assert_eq!(self.block(), Token::End);

而原来的语法分析入口函数chunk()也要增加对block()返回值的判断:

    fn chunk(&mut self) {
        assert_eq!(self.block(), Token::Eos);
    }

block的变量作用域

block()函数另外一个需要改动的地方是局部变量的作用域。即在block内部定义的局部变量,在外部是不可见的。

这个功能很核心!不过实现却非常简单。只要在block()入口处记录当前局部变量的个数,然后在退出前清除新增的局部变量即可。代码如下:

    fn block(&mut self) -> Token {
        let nvar = self.locals.len(); // 记录原始的局部变量个数
        loop {
            match self.lex.next() {
                // 这里省略其他语句解析
                t => {
                    self.locals.truncate(nvar); // 失效block内部定义的局部变量
                    break t;
                }
            }
        }
    }

在后续介绍了Upvalue后,还需要做其他处理。

do语句

上面两小节处理了block的问题。而最简单的创建block的语句是do语句。因为太简单了,就在这里顺便介绍。语法分析代码如下:

    // BNF:
    //   do block end
    fn do_stat(&mut self) {
        assert_eq!(self.block(), Token::End);
    }

虚拟机执行

之前的虚拟机执行是顺序依次执行字节码,用Rust的for语句循环遍历即可:

    pub fn execute<R: Read>(&mut self, proto: &ParseProto<R>) {
        for code in proto.byte_codes.iter() {
            match *code {
                // 这里省略所有字节码的预定义逻辑
            }
        }
    }

现在要支持Test字节码的跳转,就需要在循环遍历字节码序列期间,能够修改下一次遍历的位置。Rust的for语句不支持在循环过程中修改遍历位置,所以需要手动控制循环:

    pub fn execute<R: Read>(&mut self, proto: &ParseProto<R>) {
        let mut pc = 0;  // 字节码索引
        while pc < proto.byte_codes.len() {
            match proto.byte_codes[pc] {
                // 这里省略其他字节码的预定义逻辑

                // condition structures
                ByteCode::Test(icond, jmp) => {
                    let cond = &self.stack[icond as usize];
                    if matches!(cond, Value::Nil | Value::Boolean(false)) {
                        pc += jmp as usize; // jump if false
                    }
                }
            }

            pc += 1; // 下一条字节码
        }
    }

通过字节码位置pc来控制循环执行。所有字节码执行后pc自增1,指向下一个字节码;对于跳转字节码Test则额外会修改pc。由于Test字节码最后也会执行pc自增,所以其跳转的位置其实是目标地址减去1。其实可以在这里加一条continue;语句,跳过最后面的pc自增。不知道这两种做法哪个更好。

上述代码的判断中可以看到,Lua语言中的假值只有2个:nil和false。其他值比如0,空表等,都是真值。

测试

至此我们实现了最简单的if条件判断语句。

由于我们目前还不支持关系运算,所以if后面的判断条件只能用其他语句。测试代码如下:

if a then
    print "skip this"
end
if print then
    local a = "I am true"
    print(a)
end

print (a) -- should be nil

第一个判断语句中的条件语句a是未定义全局变量,值为nil,是假,所以内部的语句不执行。

第二个判断语句中的条件语句print是已经定义的全局变量,是真,所以内部语句会执行。block内部定义了局部变量a,在内部正常执行,但在block结束后,a就无效了,再引用就是作为未定义全局变量了,打印就是nil