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官方实现,共有SETTABLE
、SETFIELD
和SETI
这三个字节码,分别对应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
。我在第一遍看这本书时,被里面无数的新概念搞得晕头转向,所以完全没有印象看过这句话,即便看到了也理解不了记不住。我这个项目写了一多半时,把这本书又完整地看了一遍,这次对里面的大部分概念理解都很顺畅了,对于函数指针这种微言大义的介绍也能注意到了。而且刚好可以用到,多么美好的发现!