局部变量

本节介绍局部变量的定义和访问(下一节介绍赋值)。

简单起见,我们暂时只支持定义局部变量语句的简化格式: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代码中比较常见,即把一个常用的“全局变量”赋值给一个同名的“局部变量”,这样后续在引用此名字时就是访问的局部变量。局部变量比全局变量快很多(局部变量通过栈索引访问,而全局变量要实时查找全局变量表,也就是MoveGetGlobal这两个字节码的区别),这么做会提升性能。

回到刚才的问题,如果在刚解析到变量名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);

这里的情况还属于简单的,因为返回的字节码codeself.constants没有关联,也就跟self没了关联,所以下面才能正常使用self.byte_codes。假如一个方法返回的内容还跟这个数据结构有关联,那解决方法就没这么简单了。后续在虚拟机执行时会遇到这种情况。