变量赋值
我们在第一章最开始的打印"hello, world!"的程序中,就支持了全局变量,即print
函数。但是只支持访问,而不支持赋值或创建,现在唯一的全局变量print
还是在创建虚拟机的时候,手动加到全局变量表里的。我们上一节里又实现了定义和访问局部变量,但是也不支持赋值。本节就来实现全局变量和局部变量的赋值。
单纯变量的赋值比较简单,但Lua中完整的赋值语句就很复杂,比如t[f()] = 123
。我们这里先实现变量赋值,然后简单介绍下完整的赋值语句的区别。
赋值的组合
本节要支持的变量赋值语句表示如下:
Name = exp
等号=
左边(左值)目前就是2类,局部变量和全局变量;右边就是前面章节的表达式exp
,大致分为3类:常量、局部变量、和全局变量。所以这就是一个2*3的组合:
-
local = const
,把常量加载到栈上指定位置,对应字节码LoadNil
、LoadBool
、LoadInt
和LoadConst
等。 -
local = local
,复制栈上值,对应字节码Move
。 -
local = global
,把栈上值赋值给全局变量,对应字节码GetGlobal
。 -
global = const
,把常量赋值给全局变量,需要首先把常量加到常量表中,然后通过字节码SetGlobalConst
完成赋值。 -
global = local
,把局部变量赋值给全局变量,对应字节码SetGlobal
。 -
global = global
,把全局变量赋值给全局变量,对应字节码SetGlobalGlobal
。
这6种情况中,前3种是赋值给局部变量,在上一节的load_exp()
函数已经实现,这里不再介绍。后面3种是赋值给全局变量,相应新增了3个字节码。这3个字节码的参数格式类似,都是2个参数,分别是:
- 目标全局变量的名字在常量表中的索引,类似之前的
GetGlobal
字节码的第2个参数。所以这3种情况下,都需要先把全局变量的名字加入到常量表中。 - 源索引,3个字节码分别是:在常量表中的索引、在栈上地址、全局变量的名字在常量表中的索引。
上述的第4种情况,即global = const
,只用一个字节码就处理了全部常量类型,而没有像之前局部变量那样针对部分类型设置不同的字节码(比如LoadNil
、LoadBool
等)。这是因为局部变量是直接通过栈上的索引来定位的,虚拟机执行其赋值是很快的,如果把源数据能内联进字节码,减少一次常量表的访问,可以明显比例的提升性能。但是访问全局变量是需要查表的,虚拟机执行较慢,此时内联源数据带来的性能提升相对而言非常小,就没必要了。毕竟多几个字节码,在解析和执行阶段都会带来复杂度。
词法分析
原来支持函数调用和定义局部变量语句,现在要新增变量赋值语句。如下:
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承诺的安全性。不过如果ahead
是Option<Token>
类型,那么就可以用Option
的take()
方法了,看上去简单一些,功能完全一样。
语法分析
随着功能的增加,语法分析的这个大循环内部代码会越来越多,所以我们先把每种语句都放到独立的函数中,即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()
处理。对于全局变量的情况,按照右边表达式的类型,分别生成SetGlobalConst
、SetGlobal
和SetGlobalGlobal
字节码。
测试
使用下列代码测试上述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完整的赋值语句,届时现在的赋值代码就会被完全舍弃。