变量赋值

我们在第一章最开始的打印"hello, world!"的程序中,就支持了全局变量,即print函数。但是只支持访问,而不支持赋值创建,现在唯一的全局变量print还是在创建虚拟机的时候,手动加到全局变量表里的。我们上一节里又实现了定义和访问局部变量,但是也不支持赋值。本节就来实现全局变量和局部变量的赋值

单纯变量的赋值比较简单,但Lua中完整的赋值语句就很复杂,比如t[f()] = 123。我们这里先实现变量赋值,然后简单介绍下完整的赋值语句的区别。

赋值的组合

本节要支持的变量赋值语句表示如下:

Name = exp

等号=左边(左值)目前就是2类,局部变量和全局变量;右边就是前面章节的表达式exp,大致分为3类:常量、局部变量、和全局变量。所以这就是一个2*3的组合:

  • local = const,把常量加载到栈上指定位置,对应字节码LoadNilLoadBoolLoadIntLoadConst等。

  • local = local,复制栈上值,对应字节码Move

  • local = global,把栈上值赋值给全局变量,对应字节码GetGlobal

  • global = const,把常量赋值给全局变量,需要首先把常量加到常量表中,然后通过字节码SetGlobalConst完成赋值。

  • global = local,把局部变量赋值给全局变量,对应字节码SetGlobal

  • global = global,把全局变量赋值给全局变量,对应字节码SetGlobalGlobal

这6种情况中,前3种是赋值给局部变量,在上一节的load_exp()函数已经实现,这里不再介绍。后面3种是赋值给全局变量,相应新增了3个字节码。这3个字节码的参数格式类似,都是2个参数,分别是:

  1. 目标全局变量的名字在常量表中的索引,类似之前的GetGlobal字节码的第2个参数。所以这3种情况下,都需要先把全局变量的名字加入到常量表中。
  2. 源索引,3个字节码分别是:在常量表中的索引、在栈上地址、全局变量的名字在常量表中的索引。

上述的第4种情况,即global = const,只用一个字节码就处理了全部常量类型,而没有像之前局部变量那样针对部分类型设置不同的字节码(比如LoadNilLoadBool等)。这是因为局部变量是直接通过栈上的索引来定位的,虚拟机执行其赋值是很快的,如果把源数据能内联进字节码,减少一次常量表的访问,可以明显比例的提升性能。但是访问全局变量是需要查表的,虚拟机执行较慢,此时内联源数据带来的性能提升相对而言非常小,就没必要了。毕竟多几个字节码,在解析和执行阶段都会带来复杂度。

词法分析

原来支持函数调用和定义局部变量语句,现在要新增变量赋值语句。如下:

Name String
Name ( exp )
local Name = exp
Name = exp   # 新增

这里有个问题,新增的变量赋值语句也是以Name开头,跟函数调用一样。所以根据开头第一个Token无法区分,就需要再向前“看”一个Token:如果是等号=就是变量赋值语句,否则就是函数调用语句。这里的“看”打了引号,是为了强调是真的看一下,而不能“取”出来,因为后续的语句分析还需要用到这个Token。为此,词法分析还要新增一个peek()方法:

    pub fn next(&mut self) -> Token {
        if self.ahead == Token::Eos {
            self.do_next()
        } else {
            mem::replace(&mut self.ahead, Token::Eos)
        }
    }

    pub fn peek(&mut self) -> &Token {
        if self.ahead == Token::Eos {
            self.ahead = self.do_next();
        }
        &self.ahead
    }

其中的ahead是在Lex结构体中新增的字段,用以保存从字符流中解析出来但又不能返回出去的Token。按照Rust语言的惯例,这个ahead应该是Option<Token>类型,Some(Token)代表有提前读取的Token,None代表没有。但鉴于跟next()返回值类型同样的原因,这里直接使用Token类型,而用Token::Eos来代表没有提前读取到Token。

原来的对外next()函数改成do_next()内部函数,被新增的peek()和新的next()函数调用。

新增的peek()函数返回的是&Token而非Token,是因为这个Token的所有者还是Lex,而并没有交给调用者。只是“借给”调用者“看”一下。如果调用者不仅要“看”,还要“改”,那就需要&mut Token了,但我们这里只需要看,并不需要改。既然出现了&借用,那就涉及到生命周期lifetime。由于这个函数只有一个输入生命周期参数,即&mut self,按照省略规则,它被赋予所有输出生命周期参数,这种情况下就可以省略生命周期的标注。这种默认生命周期向编译器传递的意思是:返回的引用&Token的合法周期,小于等于输入参数即&mut self,也就是Lex本身。

我个人认为变量的所有者、借用(引用)、可变借用,是Rust语言最核心的概念。概念本身是很简单的,但是需要跟编译器深入斗争一段时间,才能深刻理解。而生命周期这个概念是基于上述几个核心概念的,也稍微复杂些,更需要在实践中理解。

新的next()是对原来的do_next()函数的简单包装,处理了可能存储在ahead中的、之前peek的Token:如果存在,则直接返回这个Token,而无需调用do_next()。但在Rust中这个“直接返回”并不能很直接。由于Token类型不是Copy的(因为其String(String)类型不是Copy的),所以不能直接返回。简单的解决方法是使用Clone,但Clone的意思就是告诉我们:这是需要付出代价的,比如对于字符串,就需要复制一份;而我们并不需要2份字符串,因为把Token返回后,我们就不需要这个Token了。所以我们现在需要的结果是:返回ahead中的Token,并同时清理ahead(这里自然是设置为代表“没有”的Token::Eos)。这个场景非常像网上流传甚广的那个《夺宝奇兵》的gif(直接搜索“夺宝奇兵gif”即可),把手中的沙袋“替换”机关上的宝物。这里的“替换”就是个关键词,这个需求可以用标准库中的std::mem::replace()函数完成。这个需求感觉是很常见的(至少在C语言的项目里很常见),所以需要用这么一个函数来完成,感觉有些小题大做。不过也正是因为这些限制,才保证了Rust承诺的安全性。不过如果aheadOption<Token>类型,那么就可以用Optiontake()方法了,看上去简单一些,功能完全一样。

语法分析

随着功能的增加,语法分析的这个大循环内部代码会越来越多,所以我们先把每种语句都放到独立的函数中,即function_call()local(),然后再增加变量赋值语句assignment()。这里用到了刚才词法分析中增加的peek()函数:

    fn chunk(&mut self) {
        loop {
            match self.lex.next() {
                Token::Name(name) => {
                    if self.lex.peek() == &Token::Assign {
                        self.assignment(name);
                    } else {
                        self.function_call(name);
                    }
                }
                Token::Local => self.local(),
                Token::Eos => break,
                t => panic!("unexpected token: {t:?}"),
            }
        }
    }

然后看assignment()函数:

    fn assignment(&mut self, var: String) {
        self.lex.next(); // `=`

        if let Some(i) = self.get_local(&var) {
            // local variable
            self.load_exp(i);
        } else {
            // global variable
            let dst = self.add_const(var) as u8;

            let code = match self.lex.next() {
                // from const values
                Token::Nil => ByteCode::SetGlobalConst(dst, self.add_const(Value::Nil) as u8),
                Token::True => ByteCode::SetGlobalConst(dst, self.add_const(Value::Boolean(true)) as u8),
                Token::False => ByteCode::SetGlobalConst(dst, self.add_const(Value::Boolean(false)) as u8),
                Token::Integer(i) => ByteCode::SetGlobalConst(dst, self.add_const(Value::Integer(i)) as u8),
                Token::Float(f) => ByteCode::SetGlobalConst(dst, self.add_const(Value::Float(f)) as u8),
                Token::String(s) => ByteCode::SetGlobalConst(dst, self.add_const(Value::String(s)) as u8),

                // from variable
                Token::Name(var) =>
                    if let Some(i) = self.get_local(&var) {
                        // local variable
                        ByteCode::SetGlobal(dst, i as u8)
                    } else {
                        // global variable
                        ByteCode::SetGlobalGlobal(dst, self.add_const(Value::String(var)) as u8)
                    }

                _ => panic!("invalid argument"),
            };
            self.byte_codes.push(code);
        }
    }

对于左值是局部变量的情况,调用load_exp()处理。对于全局变量的情况,按照右边表达式的类型,分别生成SetGlobalConstSetGlobalSetGlobalGlobal字节码。

测试

使用下列代码测试上述6种变量赋值的情况:

local a = 456
a = 123
print(a)
a = a
print(a)
a = g
print(a)
g = 123
print(g)
g = a
print(g)
g = g2
print(g)

执行符合预期。不再贴出具体执行结果。

完整的赋值语句

上述变量赋值的功能很简单,但Lua完整的赋值语句很复杂。主要表现在下面两个地方:

首先,等号=左边现在只支持局部变量和全局变量,但完整的赋值语句中还要支持表字段的赋值,比如t.k = 123,或者更复杂的t[f()+g()] = 123。而上述的assignment()函数是很难增加表的支持的。为此,是要增加一个中间表达层的,即后续TODO引入的ExpDesc结构。

其次,等号=后面的表达式现在分为3类,对于3个字节码。后续如果要引入其他类型的表达式,比如upvalue、表索引(比如t.k)、或者运算结果(比如a+b),那是要给每个类型都增加一个字节码吗?答案是,不能。但这会涉及到一些现在还没遇到的问题,所以也不好解释。如果不能的话,那需要怎么办?这也涉及到上面提到的ExpDesc

我们在后续会实现Lua完整的赋值语句,届时现在的赋值代码就会被完全舍弃。