环境 _ENV
回到最开始第一章里的"hello, world!"的例子。当时展示的luac -l
的输出中,关于全局变量print
的读取的字节码如下:
2 [1] GETTABUP 0 0 0 ; _ENV "print"
看这字节码复杂的名字和后面奇怪的_ENV
注释,就觉得不简单。当时并没有介绍这个字节码,而是重新定义了GetGlobal
这个更直观的字节码来读取全局变量。本节,就来补上_ENV
的介绍。
目前对全局变量的处理方式
我们目前对全局变量的处理是很直观的:
-
语法分析阶段,把不是局部变量和Upvalue的变量认为是全局变量,并生成对应的字节码,包括
GetGlobal
、SetGlobal
和SetGlobalConst
; -
虚拟机执行阶段,在执行状态
ExeState
数据结构中定义global: HashMap<String, Value>
来表示全局变量表。后续对全局变量的读写都是操作这个表。
这种做法很直观,也没什么缺点。但是,有其他做法可以带来更强大的功能,就是Lua 5.2版本中引入的环境_ENV
。《Lua程序设计》中对_ENV
有很详细的描述,包括为什么要用_ENV
来代替全局变量以及应用场景。我们这里就不赘述了,而是直接介绍其设计和实现。
_ENV的原理
_ENV
的实现原理:
-
在语法分析阶段,把所有的全局变量都转换为对_ENV的索引,比如
g1 = g2
就转换为_ENV.g1 = _ENV.g2
; -
那
_ENV
自己又是什么呢?由于所有Lua代码段可以认为是一个函数,所以_ENV
就可以认为是这个代码段外层的局部变量,也就是Upvalue。比如对于上述代码段g1 = g2
,更完整的转换结果如下:
local _ENV = XXX -- 预定义的全局变量表
return function (...)
_ENV.g1 = _ENV.g2
end
所有“全局变量”都变成了_ENV
的索引,而_ENV
本身也是一个Upvalue,于是,就不存在全局变量了!另外,关键的地方还在于_ENV
本身除了是提前预置的之外,并没有其他特别之处,就是一个普通的变量。这就意味着可以像普通变量一样操作他,这就带来了很大的灵活性,比如可以很方便地实现一个沙箱。具体的使用场景这里不做展开,感兴趣可以参考《Lua程序设计》。
_ENV的实现
按照上面的介绍,用_ENV
改造全局变量。
首先,在语法分析阶段,把全局变量改造为对_ENV
的索引。相关代码如下:
fn simple_name(&mut self, name: String) -> ExpDesc {
// 省略对局部变量和Upvalue的匹配,如果匹配上则直接返回。
// 如果匹配不上,
// - 之前就认为是全局变量,返回 ExpDesc::Global(name)
// - 现在改造为 _ENV.name,代码如下:
let env = self.simple_name("_ENV".into()); // 递归调用,查找_ENV
let ienv = self.discharge_any(env);
ExpDesc::IndexField(ienv, self.add_const(name))
}
上述代码中,先是对变量name
尝试从局部变量和Upvalue中匹配,这部分在之前Upvalue中有详细介绍,这里省略。这里只看如果都匹配失败的情况。这种情况下,之前就认为name
是全局变量,返回ExpDesc::Global(name)
。现在要改造为_ENV.name
,这就要首先定位_ENV
。由于_ENV
也是一个普通的变量,所以用_ENV
做参数递归调用simple_name()
函数。为了确保这个调用不会无限递归下去,就需要在语法分析的准备阶段,就预先设置_ENV
。所以这次递归调用中,_ENV
肯定会匹配为局部变量或者Upvalue,就不会再次递归调用。
那要如何预置_ENV
呢?在上面的介绍中,_ENV
是作为整个代码块的Upvalue。但我们这里为了实现方便,在load()
函数中把_ENV
作为参数,也可以实现同样的效果:
pub fn load(input: impl Read) -> FuncProto {
let mut ctx = ParseContext { /* 省略 */ };
// _ENV 作为第一个参数,也是唯一一个参数
chunk(&mut ctx, false, vec!["_ENV".into()], Token::Eos)
}
这样一来,在解析代码块最外层的代码时,调用simple_name()
函数时,对于全局变量都会匹配到一个_ENV
的局部变量;而对于函数内的代码,则会匹配到一个_ENV
的Upvalue。
这里只是承诺说肯定有一个_ENV
变量。而这个承诺的兑现,就需要在虚拟机执行阶段了。在创建一个执行状态ExeState
时,紧跟在函数入口之后要向栈上压入_ENV
,作为第一个参数。其实就是把之前对ExeState
中global
成员的初始化,转移到了栈上。代码如下:
impl ExeState {
pub fn new() -> Self {
// 全局变量表
let mut env = Table::new(0, 0);
env.map.insert("print".into(), Value::RustFunction(lib_print));
env.map.insert("type".into(), Value::RustFunction(lib_type));
env.map.insert("ipairs".into(), Value::RustFunction(ipairs));
env.map.insert("new_counter".into(), Value::RustFunction(test_new_counter));
ExeState {
// 栈上压入2个值:虚拟的函数入口,和全局变量表 _ENV
stack: vec![Value::Nil, Value::Table(Rc::new(RefCell::new(env)))],
base: 1, // for entry function
}
}
这样,就基本完成了_ENV
的改造。这次改造非常简单,而带来的功能却很强大,所以说_ENV
是个很漂亮的设计。
另外,由于没有了全局变量的概念,之前跟全局变量相关的代码,比如ExpDesc::Global
和全局变量相关的3个字节码的生成和执行,就都可以删掉了。注意,为了实现_ENV
,并没有引入新的ExpDesc或字节码。不过只是暂时没有。
优化
上面的改造虽然功能完整,但是有个性能上的问题。由于_ENV
大部分情况下都是Upvalue,那么对于全局变量,在上述simple_name()
函数中会生成两个字节码:
GetUpvalue ($tmp_table, _ENV) # 先把 _ENV 加载到栈上
GetField ($dst, $tmp_table, $key) # 然后才能索引
而原来不用_ENV
的方案中,只需要一条字节码GetGlobal
即可。这新方案明显是降低了性能。为了弥补这里的性能损失,只需要提供能够直接对Upvalue表进行索引的字节码。为此,新增3个字节码:
pub enum ByteCode {
// 删除的3个旧的直接操作全局变量表的字节码
// GetGlobal(u8, u8),
// SetGlobal(u8, u8),
// SetGlobalConst(u8, u8),
// 新增3个对应的操作Upvalue表的字节码
GetUpField(u8, u8, u8),
SetUpField(u8, u8, u8),
SetUpFieldConst(u8, u8, u8),
相应的也要增加Upvalue表索引的表达:
enum ExpDesc {
// 删除的全局变量
// Global(usize),
// 新增的对Upvalue表的索引
IndexUpField(usize, usize),
这里对Upvalue表的索引,只支持字符串常量,这也是全局变量的场景。这个IndexUpField
虽然是针对全局变量优化而添加的,但是对于普通的Upvalue表索引也是可以应用的。所以在解析表索引的函数中,也可以增加IndexUpField
优化。这里省略具体代码。
在定义了IndexUpField
后,就可以对原来的变量解析函数进行改造:
fn simple_name(&mut self, name: String) -> ExpDesc {
// 省略对局部变量和Upvalue的匹配,如果匹配上则直接返回。
// 如果匹配不上,
// - 之前就认为是全局变量,返回 ExpDesc::Global(name)
// - 现在改造为 _ENV.name,代码如下:
let iname = self.add_const(name);
match self.simple_name("_ENV".into()) {
ExpDesc::Local(i) => ExpDesc::IndexField(i, iname),
ExpDesc::Upvalue(i) => ExpDesc::IndexUpField(i, iname), // 新增的IndexUpField
_ => panic!("no here"), // because "_ENV" must exist!
}
}
跟之前一样,一个变量在局部变量和Upvalue都匹配失败后,仍然用_ENV
做参数递归调用simple_name()
函数。但这里我们知道_ENV
返回的结果肯定是局部变量或者Upvalue,这两种情况下分别生成ExpDesc::IndexField
和ExpDesc::IndexUpField
。然后在对ExpDesc::IndexUpField
的读写处理时生成上面新增的3个字节码即可。
这样一来,就相当于是用ExpDesc::IndexUpField
代替了ExpDesc::Global
。之前删掉了对ExpDesc::Global
的处理,现在都由从ExpDesc::IndexUpField
身上加了回来。