局部变量
本节介绍局部变量的定义和访问(下一节介绍赋值)。
简单起见,我们暂时只支持定义局部变量语句的简化格式:local name = expression
,也就是说不支持多变量或者无初始化。目标代码如下:
local a = "hello, local!" -- define new local var 'a'
print(a) -- use 'a'
局部变量如何管理,如何存储,如何访问?先参考下luac的结果:
main <local.lua:0,0> (6 instructions at 0x6000006e8080)
0+ params, 3 slots, 1 upvalue, 1 local, 2 constants, 0 functions
1 [1] VARARGPREP 0
2 [1] LOADK 0 0 ; "hello, world!"
3 [2] GETTABUP 1 0 1 ; _ENV "print"
4 [2] MOVE 2 0
5 [2] CALL 1 2 1 ; 1 in 0 out
6 [2] RETURN 1 1 1 ; 0 out
跟上一章的直接打印"hello, world!"的程序对比,有几个区别:
- 输出的第2行里的
1 local
,说明有1个局部变量。不过这只是一个说明,跟下列字节码没关系。 - LOADK,加载常量到栈的索引0处。对应源码第[1]行,即定义局部变量。由此可见变量是存储在栈上,并在执行过程中赋值。
- GETTABUP的目标地址是1(上一章里是0),也就是把
print
加载到位置1,因为位置0用来保存局部变量。 - MOVE,新字节码,用于栈内值的复制。2个参数分别是目的索引和源索引。这里就是把索引0的值复制到索引2处。就是把局部变量a作为print的参数。
前4个字节码执行完毕后,栈上布局如下:
+-----------------+ MOVE
0 | local a |----\
+-----------------+ |
1 | print | |
+-----------------+ |
2 | "hello, world!" |<---/
+-----------------+
| |
由此可知,执行过程中局部变量存储在栈上。在上一章里,栈只是用于函数调用,现在又多了存储局部变量的功能。相对而言局部变量是更持久的,只有在当前block结束后才失效。而函数调用是在函数返回后就失效。
定义局部变量
增加局部变量的处理。首先定义局部变量表locals
。在值和类型一节里说明,Lua的变量只包含变量名信息,而没有类型信息,所以这个表里只保存变量名即可,定义为Vec<String>
。另外,此表只在语法分析时使用,而在虚拟机执行时不需要,所以不用添加到ParseProto
中。
目前已经支持2种语句(函数调用的2种格式):
Name String
Name ( exp )
其中exp
是表达式,目前支持多种常量,比如字符串、数字等。
现在要新增的定义局部变量语句的简化格式如下:
local Name = exp
这里面也包括exp
。所以把这部分提取为一个函数load_exp()
。那么定义局部变量对应的语法分析代码如下:
Token::Local => { // local name = exp
let var = if let Token::Name(var) = lex.next() {
var // can not add to locals now
} else {
panic!("expected variable");
};
if lex.next() != Token::Assign {
panic!("expected `=`");
}
load_exp(&mut byte_codes, &mut constants, lex.next(), locals.len());
// add to locals after load_exp()
locals.push(var);
}
代码比较简单,无需全部介绍。load_exp()
函数参考下面小节。
需要特别注意的是,最开始解析到变量名var
时,并不能直接加入到局部变量表locals
中,而是要在解析完表达式后才能加入。可以认为解析到var
时,还没有完整局部变量的定义;需要等到整个语句结束后才算完成定义,才能加入到局部变量表中。下面小节说明具体原因。
访问局部变量
现在访问局部变量,即print(a)
这句代码。也就是在exp
中增加对局部变量的处理。
其实,在上一节的函数调用语句的
Name ( exp )
格式里,就可以在exp
里增加全局变量。这样就可以支持print(print)
这样的Lua代码了。只不过当时只顾得增加其他类型常量,就忘记支持全局变量了。这也反应了现在的状态,即加功能特性全凭感觉,完全不能保证完整性甚至正确性。我们会在后续章节里解决这个问题。
于是修改load_exp()
的代码(这里省略原来各种常量类型的处理部分):
fn load_exp(byte_codes: &mut Vec<ByteCode>, constants: &mut Vec<Value>,
locals: &Vec<String>, token: Token, dst: usize) {
let code = match token {
... // other type consts, such as Token::Float()...
Token::Name(var) => load_var(constants, locals, dst, var),
_ => panic!("invalid argument"),
};
byte_codes.push(code);
}
fn load_var(constants: &mut Vec<Value>, locals: &Vec<String>, dst: usize, name: String) -> ByteCode {
if let Some(i) = locals.iter().rposition(|v| v == &name) {
// local variable
ByteCode::Move(dst as u8, i as u8)
} else {
// global variable
let ic = add_const(constants, Value::String(name));
ByteCode::GetGlobal(dst as u8, ic as u8)
}
}
load_exp()
函数中对变量的处理也放到单独的load_var()
函数中,这是因为之前的函数调用语句的“函数”部分也可以调用这个函数,这样就也可以支持局部变量的函数了。
对变量的处理逻辑是:先在局部变量表locals
里查找:
- 如果存在,就是局部变量,生成
Move
字节码。这是一个新字节码。 - 否则,就是全局变量,处理过程之前章节介绍过,这里略过。
可以预见,后续在支持upvalue后,也是在这个函数中判断。
load_var()
函数在变量表中查找变量时,是从后往前查找,即使用.rposition()
函数。这是因为我们在注册局部变量时,并没有检查重名。如果有重名,也会照旧注册,即排到局部变量表的最后。这种情况下,反向查找,就会找到后注册的变量,而先注册的变量就永远定位不到了。相当于后注册的变量覆盖了前面的变量。比如下列代码是合法的,并且输出456
:
local a = 123
local a = 456
print(a) -- 456
我感觉这种做法很巧妙。如果每次添加局部变量时都先判断是否存在的话,必定会消耗性能。而这种重复定义局部变量的情况并不多见(也可能是我孤陋寡闻),为了这小概率情况而去判断重复(无论是报错还是重复利用)都不太值得。而现在的做法(反向查找)即保证了性能,又可以正确支持这种重复定义的情况。
Rust中也有类似的shadow变量。不过我猜Rust应该不能这么简单的忽略处理,因为Rust中一个变量不可见时(比如被shadow了)是要drop的,所以还是要特意判断这种shadow情况并特别处理。
另外一个问题是,在上一段定义局部变量的最后提到,解析到变量名var
时,并不能直接加入到局部变量表locals
中,而是要在解析完表达式后才能加入。当时因为还没有“访问”局部变量,所以没有说明具体原因。现在可以说明了。比如对下列代码:
local print = print
这种语句在Lua代码中比较常见,即把一个常用的“全局变量”赋值给一个同名的“局部变量”,这样后续在引用此名字时就是访问的局部变量。局部变量比全局变量快很多(局部变量通过栈索引访问,而全局变量要实时查找全局变量表,也就是Move
和GetGlobal
这两个字节码的区别),这么做会提升性能。
回到刚才的问题,如果在刚解析到变量名print
时就加入到局部变量表中,那在解析=
后面的表达式print
时,查询局部变量表就会找到刚刚加入的print
,那么就相当于是把局部变量print
赋值给局部变量print
,就循环了,没意义了(真这么做的话,print
会被赋值为nil)。
综上,必须在解析完=
后面表达式后,才能把变量加入到局部变量表中。
函数调用的位置
之前我们的解释器只支持函数调用的语句,所以栈只是函数调用的场所,执行函数调用时,函数和参数分别固定在0和1的位置。现在支持了局部变量,栈就不只是函数调用的场所了,函数和参数的位置也就不固定了,而需要变成栈上的第一个空闲位置,即局部变量的下一个位置。为此:
-
在语法分析时,可以通过
locals.len()
获取局部变量的个数,也就是栈上的第一个空闲位置。 -
在虚拟机执行时,需要在
ExeState
中增加一个字段func_index
,在函数调用前设置此字段来表示这个位置,并在函数中使用。对应的代码分别如下:
ByteCode::Call(func, _) => {
self.func_index = func as usize; // set func_index
let func = &self.stack[self.func_index];
if let Value::Function(f) = func {
f(self);
} else {
panic!("invalid function: {func:?}");
}
}
fn lib_print(state: &mut ExeState) -> i32 {
println!("{:?}", state.stack[state.func_index + 1]); // use func_index
0
}
测试
至此,我们实现了局部变量的定义和访问,并且在这个过程中还整理了代码,使得之前的函数调用语句也变强大了,函数和参数都支持了全局变量和局部全局。所以本文开头的那个只有2行的目标代码太简单了。可以试试下面的代码:
local a = "hello, local!" -- define a local by string
local b = a -- define a local by another local
print(b) -- print local variable
print(print) -- print global variable
local print = print -- define a local by global variable with same name
print "I'm local-print!" -- call local function
执行结果:
[src/parse.rs:71] &constants = [
hello, local!,
print,
I'm local-print!,
]
byte_codes:
LoadConst(0, 0)
Move(1, 0)
GetGlobal(2, 1)
Move(3, 1)
Call(2, 1)
GetGlobal(2, 1)
GetGlobal(3, 1)
Call(2, 1)
GetGlobal(2, 1)
Move(3, 2)
LoadConst(4, 2)
Call(3, 1)
hello, local!
function
I'm local-print!
符合预期!这个字节码有点多了,可以跟luac的输出对比一下。我们之前是只能分析和模仿luac编译的字节码序列,现在可以自主编译并输出字节码了。很大的进步!
语法分析代码的OO改造
功能已经完成。但是随着功能的增加,语法分析部分的代码变的比较乱,比如上述load_exp()
函数的定义,就有一堆的参数。为了整理代码,把语法分析也改造成面向对象模式的,围绕ParseProto
来定义方法,这些方法通过self
就能获取全部信息,就不用很多参数传来传去了。具体改动参见提交f89d2fd。
把几个独立的成员集合在一起,也带来一个小问题,一个Rust语言特有的问题。比如原来读取字符串常量的代码如下,先调用load_const()
生成并返回字节码,然后调用byte_codes.push()
保存字节码。这两个函数调用是可以写在一起的:
byte_codes.push(load_const(&mut constants, iarg, Value::String(s)));
改成面向对象方式后,代码如下:
self.byte_codes.push(self.load_const(iarg, Value::String(s)));
而这是不能编译通过的,报错如下:
error[E0499]: cannot borrow `*self` as mutable more than once at a time
--> src/parse.rs:70:38
|
70 | self.byte_codes.push(self.load_const(iarg, Value::String(s)));
| ---------------------^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^-
| | | |
| | | second mutable borrow occurs here
| | first borrow later used by call
| first mutable borrow occurs here
|
help: try adding a local storing this argument...
--> src/parse.rs:70:38
|
70 | self.byte_codes.push(self.load_const(iarg, Value::String(s)));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: ...and then using that local as the argument to this call
--> src/parse.rs:70:17
|
70 | self.byte_codes.push(self.load_const(iarg, Value::String(s)));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
For more information about this error, try `rustc --explain E0499`.
Rust编译器虽然很严格,但报错信息还是很清晰的,甚至给出了正确的修改方法。
self
被mut引用了2次。虽然self.load_const()
中并没有用到self.byte_codes
,实际中并不会出现冲突,但编译器并不知道这些细节,编译器只知道self
被引用了两次。这就是把多个成员集合在一起的后果。解决方法是,按照Rust给出的建议,引入一个局部变量,然后把这行代码拆成两行:
let code = self.load_const(iarg, Value::String(s));
self.byte_codes.push(code);
这里的情况还属于简单的,因为返回的字节码code
和self.constants
没有关联,也就跟self
没了关联,所以下面才能正常使用self.byte_codes
。假如一个方法返回的内容还跟这个数据结构有关联,那解决方法就没这么简单了。后续在虚拟机执行时会遇到这种情况。