更多类型
这一节增加简单的类型,包括布尔、整数和浮点数。其他类型比如表和UserData在后续章节中实现。
我们首先完善词法分析以支持这些类型对应的Token,然后语法分析生成对应的字节码,并在虚拟机也增加这些字节码的支持。最后修改函数调用,支持打印这些类型。
完善词法分析
上一章的词法分析只支持2个Token。所以现在无论增加什么特性,都要先改词法分析增加对应的Token。为了避免后面每个章节都零零碎碎地加Token,现在这里一次性加完。
Lua官网列出了完整的词法约定。包括:
-
name,之前已经实现,用于变量等。
-
常量,包括字符串、整数和浮点数常量。
-
关键字:
and break do else elseif end
false for function goto if in
local nil not or repeat return
then true until while
- 符号:
+ - * / % ^ #
& ~ | << >> //
== ~= <= >= < > =
( ) { } [ ] ::
; : , . .. ...
对应的Token定义为:
#[derive(Debug, PartialEq)]
pub enum Token {
// keywords
And, Break, Do, Else, Elseif, End,
False, For, Function, Goto, If, In,
Local, Nil, Not, Or, Repeat, Return,
Then, True, Until, While,
// + - * / % ^ #
Add, Sub, Mul, Div, Mod, Pow, Len,
// & ~ | << >> //
BitAnd, BitXor, BitOr, ShiftL, ShiftR, Idiv,
// == ~= <= >= < > =
Equal, NotEq, LesEq, GreEq, Less, Greater, Assign,
// ( ) { } [ ] ::
ParL, ParR, CurlyL, CurlyR, SqurL, SqurR, DoubColon,
// ; : , . .. ...
SemiColon, Colon, Comma, Dot, Concat, Dots,
// constant values
Integer(i64),
Float(f64),
String(String),
// name of variables or table keys
Name(String),
// end
Eos,
}
具体实现无非繁琐的字符串解析,这里略过。为了简单起见,这次的实现只是支持了大部分简单的类型,而对于复杂类型比如长字符串、长注释、字符串转义、16进制数字暂不支持,浮点数也只不支持科学计数法。这些并不影响后续要增加的主要特性。
值的类型
词法分析支持了更多类型后,接下来在Value中增加这些类型:
#[derive(Clone)]
pub enum Value {
Nil,
Boolean(bool),
Integer(i64),
Float(f64),
String(String),
Function(fn (&mut ExeState) -> i32),
}
impl fmt::Debug for Value {
fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
match self {
Value::Nil => write!(f, "nil"),
Value::Boolean(b) => write!(f, "{b}"),
Value::Integer(i) => write!(f, "{i}"),
Value::Float(n) => write!(f, "{n:?}"),
Value::String(s) => write!(f, "{s}"),
Value::Function(_) => write!(f, "function"),
}
}
}
其中有点特别的地方是,对于浮点数的输出用了debug模式:{:?}
。因为Rust对浮点数的普通输出格式{}
是整数+小数格式的,而更合理的方式应该是在“整数小数”和“科学计数法”两者中选择更适合的,即C语言printf
里对应的%g
。比如对于数字1e-10
仍然输出"0.000000"
就太不合理了。这似乎是Rust的历史问题。为了兼容等原因,只能使用debug模式{:?}
来对应%g
。这里不深究。
另外,为了便于区分“整数”和“没有小数部分的浮点数”,Lua的官方实现里,对于后者会在后面添加.0
。比如对于浮点数2
会输出为2.0
。如下代码。这个太贴心了。而这也是Rust的{:?}
模式的默认行为,所以不需要我们为此特殊处理。
if (buff[strspn(buff, "-0123456789")] == '\0') { /* looks like an int? */
buff[len++] = lua_getlocaledecpoint();
buff[len++] = '0'; /* adds '.0' to result */
}
在Lua 5.3之前,Lua只有一种数字类型,默认是浮点型。我理解这是因为Lua最初的目的是用于配置文件,面向的是用户而非程序员。对普通用户而言,是不区分整数和浮点数概念的,配置
10秒
和10.0秒
是没有区别的;另外对于一些计算,比如7/2
的结果显而易见是3.5
而不是3
。但随着Lua使用范围的扩大,比如作为很多大型程序间的粘合语言,对整数的需求日益强烈,于是在语言层面区分了整数和浮点数。
语法分析
然后在语法分析中增加这些类型的支持。由于目前只支持函数调用这一种语句,即函数 参数
的格式;而其中“函数”只支持全局变量,所以这次只需要“参数”部分支持这些新类型。Lua语音中的函数调用,参数如果是字符串常量或者表构造,那么就可以省略括号()
,如上一章的"hello, world!"例子。但对于其他情况,比如这次添加的几个新类型,就必须要求有括号()
了。于是对参数部分的修改如下:
Token::Name(name) => {
// function, global variable only
let ic = add_const(&mut constants, Value::String(name));
byte_codes.push(ByteCode::GetGlobal(0, ic as u8));
// argument, (var) or "string"
match lex.next() {
Token::ParL => { // '('
let code = match lex.next() {
Token::Nil => ByteCode::LoadNil(1),
Token::True => ByteCode::LoadBool(1, true),
Token::False => ByteCode::LoadBool(1, false),
Token::Integer(i) =>
if let Ok(ii) = i16::try_from(i) {
ByteCode::LoadInt(1, ii)
} else {
load_const(&mut constants, 1, Value::Integer(i))
}
Token::Float(f) => load_const(&mut constants, 1, Value::Float(f)),
Token::String(s) => load_const(&mut constants, 1, Value::String(s)),
_ => panic!("invalid argument"),
};
byte_codes.push(code);
if lex.next() != Token::ParR { // ')'
panic!("expected `)`");
}
}
Token::String(s) => {
let code = load_const(&mut constants, 1, Value::String(s));
byte_codes.push(code);
}
_ => panic!("expected string"),
}
}
这段代码首先解析函数,跟上一章代码一样,依然只支持全局变量。然后解析参数,除了对字符串常量的支持外,增加了更通用的括号()
的方式。其中处理了各种类型常量:
-
浮点数常量,跟字符串常量类似,调用
load_const()
函数,在编译时放到常量表里,然后执行时通过LoadConst
字节码来加载。 -
Nil和Boolean类型,就没必要把Nil、true和false也放到常量表里了。直接编码到字节码里更方便,在执行的时候(因为少读一次内存)也更快。所以新增
LoadNil
和LoadBool
字节码。 -
整数常量,则结合了上述两种做法。因为一个字节码4字节,其中opcode占1字节,目的地址占1字节,还剩下2字节,可以存储
i16
的整数。所以对于在i16
范围内的数字(这也是大概率事件),可以直接编码到字节码里,为此新增LoadInt
字节码;如果超过i16
范围,则存在常量表里。这个也是参考的Lua官方实现。由此可以看到Lua对性能的追求,为了减少一次内存访问,而增加一个字节码和代码逻辑。后续也会看到很多这种情况。
由于目前还是只支持函数调用语言,所以执行时函数固定在栈的0
位置,参数固定在1
位置。上述的字节码的目标地址也都固定填的1
。
主要代码介绍完毕。下面再列下用于生成LoadConst
字节码的函数load_const()
定义:
fn add_const(constants: &mut Vec<Value>, c: Value) -> usize {
constants.push(c)
}
fn load_const(constants: &mut Vec<Value>, dst: usize, c: Value) -> ByteCode {
ByteCode::LoadConst(dst as u8, add_const(constants, c) as u8)
}
测试
至此,解析过程完成了对新增类型的支持。剩下虚拟机执行部分只是支持新增的几个字节码LoadInt
、LoadBool
和LoadNil
即可。这里略过。
然后就可以测试如下代码:
print(nil)
print(false)
print(123)
print(123456)
print(123456.0)
输出结果如下:
[src/parse.rs:64] &constants = [
print,
print,
print,
print,
123456,
print,
123456.0,
]
byte_codes:
GetGlobal(0, 0)
LoadNil(1)
Call(0, 1)
GetGlobal(0, 0)
LoadBool(1, false)
Call(0, 1)
GetGlobal(0, 0)
LoadInt(1, 123)
Call(0, 1)
GetGlobal(0, 0)
LoadConst(1, 1)
Call(0, 1)
GetGlobal(0, 0)
LoadConst(1, 2)
Call(0, 1)
nil
false
123
123456
123456.0
执行正常,但有个小问题,也是上一章就遗留下来的,即print
在常量表里出现了多次。这里需要修改为,在每次添加常量时检查是否已经存在。
添加常量
把上面的add_const()
函数修改如下:
fn add_const(constants: &mut Vec<Value>, c: Value) -> usize {
constants.iter().position(|v| v == &c)
.unwrap_or_else(|| {
constants.push(c);
constants.len() - 1
})
}
constants.iter().position()
定位索引。其参数是一个闭包,需要比较两个Value
,为此需要给Value
实现PartialEq
trait:
impl PartialEq for Value {
fn eq(&self, other: &Self) -> bool {
// TODO compare Integer vs Float
match (self, other) {
(Value::Nil, Value::Nil) => true,
(Value::Boolean(b1), Value::Boolean(b2)) => *b1 == *b2,
(Value::Integer(i1), Value::Integer(i2)) => *i1 == *i2,
(Value::Float(f1), Value::Float(f2)) => *f1 == *f2,
(Value::String(s1), Value::String(s2)) => *s1 == *s2,
(Value::Function(f1), Value::Function(f2)) => std::ptr::eq(f1, f2),
(_, _) => false,
}
}
}
这里我们认为数值相等的两个整数和浮点数是不同的,比如Integer(123456)
和Float(123456.0)
,因为这确实是两个值,在处理常量表时,不能合并这两个值,否则上一节的测试代码里,最后一行就也是加载整数123456
了。
但在Lua执行过程中,这两个值是相等的,即123 == 123.0
的结果是true
。我们会在后面章节处理这个问题。
回到position()
函数,其返回值是Option<usize>
,Some(i)
代表找到,直接返回索引;而None
代表没有找到,需要先添加常量,再返回索引。按照C语言的编程习惯就是下面的if-else判断,但这里尝试用了更函数化的方式。个人感觉这种方式并没有更加清晰,但既然是在学习Rust,就先尽量优先用Rust的方式。
if let Some(i) = constants.iter().position(|v| v == &c) {
i
} else {
constants.push(c);
constants.len() - 1
}
完成对add_const()
函数的改造后,常量表里就可以避免出现重复的值。相关输出截取为:
[src/parse.rs:64] &constants = [
print,
123456,
123456.0,
]
上述虽然在添加常量时会检查重复,但检查是通过遍历数组做的。添加所有常量的时间复杂度是O(N^2)。如果一个Lua代码段中包含的常量特别多,比如有1百万个,就解析太慢了。为此我们需要一个哈希表来提供快速的查找。TODO。