环境 _ENV

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

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,更完整的转换结果如下:

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,作为第一个参数。其实就是把之前对ExeStateglobal成员的初始化,转移到了栈上。代码如下:

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::IndexFieldExpDesc::IndexUpField。然后在对ExpDesc::IndexUpField的读写处理时生成上面新增的3个字节码即可。

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