环境 _ENV

回到最开始第一章里的”hello, world!”的例子。当时展示的luac -l的输出中,关于全局变量print的读取的字节码如下:

  1. 2 [1] GETTABUP 0 0 0 ; _ENV "print"

看这字节码复杂的名字和后面奇怪的_ENV注释,就觉得不简单。当时并没有介绍这个字节码,而是重新定义了GetGlobal这个更直观的字节码来读取全局变量。本节,就来补上_ENV的介绍。

目前对全局变量的处理方式

我们目前对全局变量的处理是很直观的:

  • 语法分析阶段,把不是局部变量和Upvalue的变量认为是全局变量,并生成对应的字节码,包括GetGlobalSetGlobalSetGlobalConst

  • 虚拟机执行阶段,在执行状态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,更完整的转换结果如下:

  1. local _ENV = XXX -- 预定义的全局变量表
  2. return function (...)
  3. _ENV.g1 = _ENV.g2
  4. end

所有“全局变量”都变成了_ENV的索引,而_ENV本身也是一个Upvalue,于是,就不存在全局变量了!另外,关键的地方还在于_ENV本身除了是提前预置的之外,并没有其他特别之处,就是一个普通的变量。这就意味着可以像普通变量一样操作他,这就带来了很大的灵活性,比如可以很方便地实现一个沙箱。具体的使用场景这里不做展开,感兴趣可以参考《Lua程序设计》。

_ENV的实现

按照上面的介绍,用_ENV改造全局变量。

首先,在语法分析阶段,把全局变量改造为对_ENV的索引。相关代码如下:

  1. fn simple_name(&mut self, name: String) -> ExpDesc {
  2. // 省略对局部变量和Upvalue的匹配,如果匹配上则直接返回。
  3. // 如果匹配不上,
  4. // - 之前就认为是全局变量,返回 ExpDesc::Global(name)
  5. // - 现在改造为 _ENV.name,代码如下:
  6. let env = self.simple_name("_ENV".into()); // 递归调用,查找_ENV
  7. let ienv = self.discharge_any(env);
  8. ExpDesc::IndexField(ienv, self.add_const(name))
  9. }

上述代码中,先是对变量name尝试从局部变量和Upvalue中匹配,这部分在之前Upvalue中有详细介绍,这里省略。这里只看如果都匹配失败的情况。这种情况下,之前就认为name是全局变量,返回ExpDesc::Global(name)。现在要改造为_ENV.name,这就要首先定位_ENV。由于_ENV也是一个普通的变量,所以用_ENV做参数递归调用simple_name()函数。为了确保这个调用不会无限递归下去,就需要在语法分析的准备阶段,就预先设置_ENV。所以这次递归调用中,_ENV肯定会匹配为局部变量或者Upvalue,就不会再次递归调用。

那要如何预置_ENV呢?在上面的介绍中,_ENV是作为整个代码块的Upvalue。但我们这里为了实现方便,在load()函数中把_ENV作为参数,也可以实现同样的效果:

  1. pub fn load(input: impl Read) -> FuncProto {
  2. let mut ctx = ParseContext { /* 省略 */ };
  3. // _ENV 作为第一个参数,也是唯一一个参数
  4. chunk(&mut ctx, false, vec!["_ENV".into()], Token::Eos)
  5. }

这样一来,在解析代码块最外层的代码时,调用simple_name()函数时,对于全局变量都会匹配到一个_ENV的局部变量;而对于函数内的代码,则会匹配到一个_ENV的Upvalue。

这里只是承诺说肯定有一个_ENV变量。而这个承诺的兑现,就需要在虚拟机执行阶段了。在创建一个执行状态ExeState时,紧跟在函数入口之后要向栈上压入_ENV,作为第一个参数。其实就是把之前对ExeStateglobal成员的初始化,转移到了栈上。代码如下:

  1. impl ExeState {
  2. pub fn new() -> Self {
  3. // 全局变量表
  4. let mut env = Table::new(0, 0);
  5. env.map.insert("print".into(), Value::RustFunction(lib_print));
  6. env.map.insert("type".into(), Value::RustFunction(lib_type));
  7. env.map.insert("ipairs".into(), Value::RustFunction(ipairs));
  8. env.map.insert("new_counter".into(), Value::RustFunction(test_new_counter));
  9. ExeState {
  10. // 栈上压入2个值:虚拟的函数入口,和全局变量表 _ENV
  11. stack: vec![Value::Nil, Value::Table(Rc::new(RefCell::new(env)))],
  12. base: 1, // for entry function
  13. }
  14. }

这样,就基本完成了_ENV的改造。这次改造非常简单,而带来的功能却很强大,所以说_ENV是个很漂亮的设计。

另外,由于没有了全局变量的概念,之前跟全局变量相关的代码,比如ExpDesc::Global和全局变量相关的3个字节码的生成和执行,就都可以删掉了。注意,为了实现_ENV,并没有引入新的ExpDesc或字节码。不过只是暂时没有。

优化

上面的改造虽然功能完整,但是有个性能上的问题。由于_ENV大部分情况下都是Upvalue,那么对于全局变量,在上述simple_name()函数中会生成两个字节码:

  1. GetUpvalue ($tmp_table, _ENV) # 先把 _ENV 加载到栈上
  2. GetField ($dst, $tmp_table, $key) # 然后才能索引

而原来不用_ENV的方案中,只需要一条字节码GetGlobal即可。这新方案明显是降低了性能。为了弥补这里的性能损失,只需要提供能够直接对Upvalue表进行索引的字节码。为此,新增3个字节码:

  1. pub enum ByteCode {
  2. // 删除的3个旧的直接操作全局变量表的字节码
  3. // GetGlobal(u8, u8),
  4. // SetGlobal(u8, u8),
  5. // SetGlobalConst(u8, u8),
  6. // 新增3个对应的操作Upvalue表的字节码
  7. GetUpField(u8, u8, u8),
  8. SetUpField(u8, u8, u8),
  9. SetUpFieldConst(u8, u8, u8),

相应的也要增加Upvalue表索引的表达:

  1. enum ExpDesc {
  2. // 删除的全局变量
  3. // Global(usize),
  4. // 新增的对Upvalue表的索引
  5. IndexUpField(usize, usize),

这里对Upvalue表的索引,只支持字符串常量,这也是全局变量的场景。这个IndexUpField虽然是针对全局变量优化而添加的,但是对于普通的Upvalue表索引也是可以应用的。所以在解析表索引的函数中,也可以增加IndexUpField优化。这里省略具体代码。

在定义了IndexUpField后,就可以对原来的变量解析函数进行改造:

  1. fn simple_name(&mut self, name: String) -> ExpDesc {
  2. // 省略对局部变量和Upvalue的匹配,如果匹配上则直接返回。
  3. // 如果匹配不上,
  4. // - 之前就认为是全局变量,返回 ExpDesc::Global(name)
  5. // - 现在改造为 _ENV.name,代码如下:
  6. let iname = self.add_const(name);
  7. match self.simple_name("_ENV".into()) {
  8. ExpDesc::Local(i) => ExpDesc::IndexField(i, iname),
  9. ExpDesc::Upvalue(i) => ExpDesc::IndexUpField(i, iname), // 新增的IndexUpField
  10. _ => panic!("no here"), // because "_ENV" must exist!
  11. }
  12. }

跟之前一样,一个变量在局部变量和Upvalue都匹配失败后,仍然用_ENV做参数递归调用simple_name()函数。但这里我们知道_ENV返回的结果肯定是局部变量或者Upvalue,这两种情况下分别生成ExpDesc::IndexFieldExpDesc::IndexUpField。然后在对ExpDesc::IndexUpField的读写处理时生成上面新增的3个字节码即可。

这样一来,就相当于是用ExpDesc::IndexUpField代替了ExpDesc::Global。之前删掉了对ExpDesc::Global的处理,现在都由从ExpDesc::IndexUpField身上加了回来。