ExpDesc改造

上一节引入了ExpDesc的概念,并介绍了其作用和角色。这一节就基于ExpDesc对现有代码做改造。这次改造并不会支持新特性,只是为下一节表的读写功能以及后续更多特性打基础。

首先,最主要的就是解析表达式的函数load_exp()。这个函数原来是直接从Token生成字节码。现在要拆成两步:Token到ExpDesc,和从ExpDesc生成字节码。然后,在此基础上,改造表构造函数和变量赋值语句。

exp()

改造load_exp()函数第1步,Token到ExpDesc,新建exp()函数,代码如下:

    fn exp(&mut self) -> ExpDesc {
        match self.lex.next() {
            Token::Nil => ExpDesc::Nil,
            Token::True => ExpDesc::Boolean(true),
            Token::False => ExpDesc::Boolean(false),
            Token::Integer(i) => ExpDesc::Integer(i),
            Token::Float(f) => ExpDesc::Float(f),
            Token::String(s) => ExpDesc::String(s),
            Token::Name(var) => self.simple_name(var),
            Token::CurlyL => self.table_constructor(),
            t => panic!("invalid exp: {:?}", t),
        }
    }

    fn simple_name(&mut self, name: String) -> ExpDesc {
        // search reversely, so new variable covers old one with same name
        if let Some(ilocal) = self.locals.iter().rposition(|v| v == &name) {
            ExpDesc::Local(ilocal)
        } else {
            ExpDesc::Global(self.add_const(name))
        }
    }

比较简单,跟之前的load_exp()函数的主体结构类似,甚至更简单,就是表达式语句支持的几种Token类型转成对应的ExpDesc。其中Name和表构造需要进一步处理。Name要由simple_name()函数区分局部变量还是全局变量。对表构造分支的处理就变合理了很多,之前需要在这个分支里加上一个丑陋的return,现在因为这个函数不生成字节码,所以这个分支也可以自然地结束。但是,虽然不需要字节码了,变成需要ExpDesc了,所以表构造函数table_constructor()需要返回一个ExpDesc。因为是把新建的表最终放到了栈上,所以返回ExpDesc::Local(i)。注意,ExpDesc::Local类型并不仅仅代表“局部变量”,而是“栈上变量”。使用“Local”这个名字是为了跟Lua官方代码一致。

除了不生成字节码,这个函数跟load_exp()相比还有一个变化是,没有了dst参数。大部分情况下没问题,但对于表的构造函数就有问题了。因为表的构造过程,是要先在栈上创建一个表,后续的初始化语句生成的字节码里,都需要带上这个表在栈上的索引作为参数。比如SetTable 3 4 5,第一个参数就是表在栈上的索引。所以原来的table_constructor()函数是需要一个dst参数的。现在没了这个参数,怎么办?我们可以假设所有的表的构造,都是在栈顶创建新表。于是就需要维护当前的栈顶位置。

栈顶sp

要维护当前栈顶位置,首先在ParseProto中增加指示当前栈顶的sp。之前都是在每个需要的地方实时计算当前栈顶位置,现在改成一个全局的变量,就让很多地方突然间都耦合了起来。后面随着特性的增加,这种耦合会越来越大,越来越失控。但通过参数来传递栈顶位置又是太过繁琐。相比而言,还是维护一个全局栈顶委托更方便,但要小心应对。

栈,有3个作用:函数调用、局部变量、和临时变量。前两者都有特定的语句(函数调用语句和定义局部变量语句)做特定的处理。最后一个,临时变量,在很多地方都会用到,比如上面提到的表构造语句,所以在使用时就需要小心管理,不能相互影响。另外,因为局部变量也占据栈空间,所以每次解析一条语句之前,都把栈顶sp的值初始化为当前局部变量的数量,也就是允许临时变量使用的地方。

下面看下表构造函数table_constructor()中对栈顶sp的使用:

    fn table_constructor(&mut self) -> ExpDesc {
        let table = self.sp;  // 新建表放在栈顶位置
        self.sp += 1;  // 更新sp,后续语句如需临时变量,则使用表后面的栈位置

        // 省略中间构造代码

        self.sp = table + 1; // 返回前,设置栈顶sp,只保留新建的表,而清理构造过程中可能使用的其他临时变量
        ExpDesc::Local(table) // 返回表的类型(栈上临时变量)和栈上的位置
     }

在函数开头使用栈顶sp,替代之前版本中传入的dst参数,作为新建表的位置。在函数结束前,重新设置栈顶位置。在下面小节中,会继续介绍这个函数在实际构建表时,对栈顶sp的使用。

discharge()

改造load_exp()函数第2步,从ExpDesc到字节码,其实更准确的说法是把ExpDesc加载到栈上。我们用Lua官方代码里的discharge这个函数名来表示“加载“。

    // discharge @desc into @dst, and set self.sp=dst+1
    fn discharge(&mut self, dst: usize, desc: ExpDesc) {
        let code = match desc {
            ExpDesc::Nil => ByteCode::LoadNil(dst as u8),
            ExpDesc::Boolean(b) => ByteCode::LoadBool(dst as u8, b),
            ExpDesc::Integer(i) =>
                if let Ok(i) = i16::try_from(i) {
                    ByteCode::LoadInt(dst as u8, i)
                } else {
                    self.load_const(dst, i)
                }
            ExpDesc::Float(f) => self.load_const(dst, f),
            ExpDesc::String(s) => self.load_const(dst, s),
            ExpDesc::Local(src) =>
                if dst != src {
                    ByteCode::Move(dst as u8, src as u8)
                } else {
                    return;
                }
            ExpDesc::Global(iname) => ByteCode::GetGlobal(dst as u8, iname as u8),
        };
        self.byte_codes.push(code);
        self.sp = dst + 1;
    }

这个函数也很简单,根据ExpDesc生成对应的字节码,把ExpDesc代表的表达式语句discharge到栈上。注意这个函数最后一行更新了栈顶位置为dst的下一个位置。大部分情况下是符合预期的,如果不符合预期,就需要调用方在函数返回后更新栈顶位置。

除了这个最基本的函数外,还有几个辅助函数。discharge()函数是强制把表达式discharge到栈的dst位置。但有时候只是想把表达式discharge到栈上,如果这个表达式本来就是在栈上,比如ExpDesc::Local类型,那就不需要再discharge了。为此引入新函数discharge_if_need()。大部分情况下甚至不关心加载到哪个位置,所以再创建一个新函数discharge_top(),使用栈顶位置。两个函数代码如下:

    // discharge @desc into the top of stack, if need
    fn discharge_top(&mut self, desc: ExpDesc) -> usize {
        self.discharge_if_need(self.sp, desc)
    }

    // discharge @desc into @dst, if need
    fn discharge_if_need(&mut self, dst: usize, desc: ExpDesc) -> usize {
        if let ExpDesc::Local(i) = desc {
            i // no need
        } else {
            self.discharge(dst, desc);
            dst
        }
    }

另外,还新增discharge_const()函数,对于几种常量类型就添加到常量表中,其他类型则按需discharge。这个函数会下面表的构造和赋值语句中都会用到:

    // for constant types, add @desc to constants;
    // otherwise, discharge @desc into the top of stack
    fn discharge_const(&mut self, desc: ExpDesc) -> ConstStack {
        match desc {
            // add const
            ExpDesc::Nil => ConstStack::Const(self.add_const(())),
            ExpDesc::Boolean(b) => ConstStack::Const(self.add_const(b)),
            ExpDesc::Integer(i) => ConstStack::Const(self.add_const(i)),
            ExpDesc::Float(f) => ConstStack::Const(self.add_const(f)),
            ExpDesc::String(s) => ConstStack::Const(self.add_const(s)),

            // discharge to stack
            _ => ConstStack::Stack(self.discharge_top(desc)),
        }
    }

在完成了exp()discharge()函数后,之前的load_exp()函数,就可以用这两个新函数组合而成:

    fn load_exp(&mut self) {
        let sp0 = self.sp;
        let desc = self.exp();
        self.discharge(sp0, desc);
    }

在本章结束时,语法分析过程中对表达式的解析都会直接调用exp()和discharge的一系列函数,而不再调用load_exp()这个函数了。

table_constructor()

load_exp()函数拆成exp()discharge()两个函数后,就可以改造表的构造函数了。还是以通用式的初始化为例,之前版本中是直接把key和value加载到栈上,无论什么类型。我们现在可以先调用exp()读取key和value,然后再根据类型做不同的处理。具体的处理方法可以参考Lua官方实现,共有SETTABLESETFIELDSETI这三个字节码,分别对应key是栈上变量、字符串常量、和小整数常量这三种类型。另外,这3个字节码都有1bit标记value是栈上变量还是常量。3种key类型和2种value类型,一共就有3*2=6种情况。我们虽然也可以通过在value中保留一个bit来区分栈上变量和常量,但是这样会导致只有7bit的地址空间。所以我们还是通过增加字节码类型来区分栈上变量和常量。最终如下:

   value\key |    栈上变量    |    字符串常量    |  小整数常量
  -----------+---------------+----------------+--------------
   栈上变量   | SetTable      | SetField       | SetInt
  -----------+---------------+----------------+--------------
      常量   | SetTableConst | SetFieldConst  | SetIntConst

另外的一条规则是,nil和浮点数的Nan不允许做key。对key的解析代码如下:

    let entry = match self.lex.peek() {
        Token::SqurL => { // `[` exp `]` `=` exp
            self.lex.next();

            let key = self.exp(); // 读取key
            self.lex.expect(Token::SqurR); // `]`
            self.lex.expect(Token::Assign); // `=`

            TableEntry::Map(match key {
                ExpDesc::Local(i) => // 栈上变量
                    (ByteCode::SetTable, ByteCode::SetTableConst, i),
                ExpDesc::String(s) => // 字符串常量
                    (ByteCode::SetField, ByteCode::SetFieldConst, self.add_const(s)),
                ExpDesc::Integer(i) if u8::try_from(i).is_ok() => // 小整数
                    (ByteCode::SetInt, ByteCode::SetIntConst, i as usize),
                ExpDesc::Nil =>
                    panic!("nil can not be table key"),
                ExpDesc::Float(f) if f.is_nan() =>
                    panic!("NaN can not be table key"),
                _ => // 其他类型,则统一discharge到栈上,转变为栈上变量
                    (ByteCode::SetTable, ByteCode::SetTableConst, self.discharge_top(key)),
            })
        }

上述代码处理了key的三种类型:局部变量、字符串常量、小整数。另外禁止了nil和浮点数Nan。对于其他类型,则统一discharge到栈顶,转换为栈上变量。

然后解析value,区分栈上变量和常量。代码如下:

    match entry {
        TableEntry::Map((op, opk, key)) => {
            let value = self.exp(); // 读取value
            let code = match self.discharge_const(value) {
                // value是常量,则使用opk,如`ByteCode::SetTableConst`
                ConstStack::Const(i) => opk(table as u8, key as u8, i as u8),

                // value不是常量,则discharge到栈上,并使用op,如`ByteCode::SetTable`
                ConstStack::Stack(i) => op(table as u8, key as u8, i as u8),
            };
            self.byte_codes.push(code);

            nmap += 1;
            self.sp = sp0;
        }

上述两段代码本身的逻辑很清晰,但是TableEntry::Map关联的参数类型有些特殊。第一段代码处理了key的类型,确定了2个字节码类型,或者说ByteCode的tag。这个tag要作为TableEntry::Map的关联参数,那是什么类型呢?肯定不能是ByteCode,因为enum类型不仅包括tag,还包括关联的值。如果作为ByteCode类型,那关联的就不是ByteCode::SetTable而是完整的ByteCode::SetTable(table,key,0)了,即先生成完整的字节码,然后在读取到value的时候再修改字节码。这样就太复杂了。

《Rust程序设计语言》里介绍了这些枚举用() 作为初始化语法,看起来像函数调用,它们确实被实现为返回由参数构造的实例的函数。也就是说ByteCode::SetTable就可以看做是一个函数,其参数类型就是fn(u8,u8,u8)->ByteCode。我在第一遍看这本书时,被里面无数的新概念搞得晕头转向,所以完全没有印象看过这句话,即便看到了也理解不了记不住。我这个项目写了一多半时,把这本书又完整地看了一遍,这次对里面的大部分概念理解都很顺畅了,对于函数指针这种微言大义的介绍也能注意到了。而且刚好可以用到,多么美好的发现!