更多类型

这一节增加简单的类型,包括布尔、整数和浮点数。其他类型比如表和UserData在后续章节中实现。

我们首先完善词法分析以支持这些类型对应的Token,然后语法分析生成对应的字节码,并在虚拟机也增加这些字节码的支持。最后修改函数调用,支持打印这些类型。

完善词法分析

上一章的词法分析只支持2个Token。所以现在无论增加什么特性,都要先改词法分析增加对应的Token。为了避免后面每个章节都零零碎碎地加Token,现在这里一次性加完。

Lua官网列出了完整的词法约定。包括:

  • name,之前已经实现,用于变量等。

  • 常量,包括字符串、整数和浮点数常量。

  • 关键字:

  1. and break do else elseif end
  2. false for function goto if in
  3. local nil not or repeat return
  4. then true until while
  • 符号:
  1. + - * / % ^ #
  2. & ~ | << >> //
  3. == ~= <= >= < > =
  4. ( ) { } [ ] ::
  5. ; : , . .. ...

对应的Token定义为:

  1. {{#include ../listing/ch02.variables/src/lex.rs:token}}

具体实现无非繁琐的字符串解析,这里略过。为了简单起见,这次的实现只是支持了大部分简单的类型,而对于复杂类型比如长字符串、长注释、字符串转义、16进制数字暂不支持,浮点数也只不支持科学计数法。这些并不影响后续要增加的主要特性。

值的类型

词法分析支持了更多类型后,接下来在Value中增加这些类型:

  1. {{#include ../listing/ch02.variables/src/value.rs:value}}

其中有点特别的地方是,对于浮点数的输出用了debug模式:{:?}。因为Rust对浮点数的普通输出格式{}是整数+小数格式的,而更合理的方式应该是在“整数小数”和“科学计数法”两者中选择更适合的,即C语言printf里对应的%g。比如对于数字1e-10仍然输出"0.000000"就太不合理了。这似乎是Rust的历史问题。为了兼容等原因,只能使用debug模式{:?}来对应%g。这里不深究。

另外,为了便于区分“整数”和“没有小数部分的浮点数”,Lua的官方实现里,对于后者会在后面添加.0。比如对于浮点数2会输出为2.0。如下代码。这个太贴心了。而这也是Rust的{:?}模式的默认行为,所以不需要我们为此特殊处理。

  1. if (buff[strspn(buff, "-0123456789")] == '\0') { /* looks like an int? */
  2. buff[len++] = lua_getlocaledecpoint();
  3. buff[len++] = '0'; /* adds '.0' to result */
  4. }

Lua 5.3之前,Lua只有一种数字类型,默认是浮点型。我理解这是因为Lua最初的目的是用于配置文件,面向的是用户而非程序员。对普通用户而言,是不区分整数和浮点数概念的,配置10秒10.0秒是没有区别的;另外对于一些计算,比如7/2的结果显而易见是3.5而不是3。但随着Lua使用范围的扩大,比如作为很多大型程序间的粘合语言,对整数的需求日益强烈,于是在语言层面区分了整数和浮点数。

语法分析

然后在语法分析中增加这些类型的支持。由于目前只支持函数调用这一种语句,即函数 参数的格式;而其中“函数”只支持全局变量,所以这次只需要“参数”部分支持这些新类型。Lua语音中的函数调用,参数如果是字符串常量或者表构造,那么就可以省略括号(),如上一章的”hello, world!”例子。但对于其他情况,比如这次添加的几个新类型,就必须要求有括号()了。于是对参数部分的修改如下:

  1. Token::Name(name) => {
  2. // function, global variable only
  3. let ic = add_const(&mut constants, Value::String(name));
  4. byte_codes.push(ByteCode::GetGlobal(0, ic as u8));
  5. // argument, (var) or "string"
  6. match lex.next() {
  7. Token::ParL => { // '('
  8. let code = match lex.next() {
  9. Token::Nil => ByteCode::LoadNil(1),
  10. Token::True => ByteCode::LoadBool(1, true),
  11. Token::False => ByteCode::LoadBool(1, false),
  12. Token::Integer(i) =>
  13. if let Ok(ii) = i16::try_from(i) {
  14. ByteCode::LoadInt(1, ii)
  15. } else {
  16. load_const(&mut constants, 1, Value::Integer(i))
  17. }
  18. Token::Float(f) => load_const(&mut constants, 1, Value::Float(f)),
  19. Token::String(s) => load_const(&mut constants, 1, Value::String(s)),
  20. _ => panic!("invalid argument"),
  21. };
  22. byte_codes.push(code);
  23. if lex.next() != Token::ParR { // ')'
  24. panic!("expected `)`");
  25. }
  26. }
  27. Token::String(s) => {
  28. let code = load_const(&mut constants, 1, Value::String(s));
  29. byte_codes.push(code);
  30. }
  31. _ => panic!("expected string"),
  32. }
  33. }

这段代码首先解析函数,跟上一章代码一样,依然只支持全局变量。然后解析参数,除了对字符串常量的支持外,增加了更通用的括号()的方式。其中处理了各种类型常量:

  • 浮点数常量,跟字符串常量类似,调用load_const()函数,在编译时放到常量表里,然后执行时通过LoadConst字节码来加载。

  • Nil和Boolean类型,就没必要把Nil、true和false也放到常量表里了。直接编码到字节码里更方便,在执行的时候(因为少读一次内存)也更快。所以新增LoadNilLoadBool字节码。

  • 整数常量,则结合了上述两种做法。因为一个字节码4字节,其中opcode占1字节,目的地址占1字节,还剩下2字节,可以存储i16的整数。所以对于在i16范围内的数字(这也是大概率事件),可以直接编码到字节码里,为此新增LoadInt字节码;如果超过i16范围,则存在常量表里。这个也是参考的Lua官方实现。由此可以看到Lua对性能的追求,为了减少一次内存访问,而增加一个字节码和代码逻辑。后续也会看到很多这种情况。

由于目前还是只支持函数调用语言,所以执行时函数固定在栈的0位置,参数固定在1位置。上述的字节码的目标地址也都固定填的1

主要代码介绍完毕。下面再列下用于生成LoadConst字节码的函数load_const()定义:

```rust, ignore fn add_const(constants: &mut Vec, c: Value) -> usize { constants.push(c) }

fn load_const(constants: &mut Vec, dst: usize, c: Value) -> ByteCode { ByteCode::LoadConst(dst as u8, add_const(constants, c) as u8) }

  1. ## 测试
  2. 至此,解析过程完成了对新增类型的支持。剩下虚拟机执行部分只是支持新增的几个字节码`LoadInt``LoadBool``LoadNil`即可。这里略过。
  3. 然后就可以测试如下代码:
  4. ```lua
  5. {{#include ../listing/ch02.variables/test_lua/types.lua}}

输出结果如下:

  1. [src/parse.rs:64] &constants = [
  2. print,
  3. print,
  4. print,
  5. print,
  6. 123456,
  7. print,
  8. 123456.0,
  9. ]
  10. byte_codes:
  11. GetGlobal(0, 0)
  12. LoadNil(1)
  13. Call(0, 1)
  14. GetGlobal(0, 0)
  15. LoadBool(1, false)
  16. Call(0, 1)
  17. GetGlobal(0, 0)
  18. LoadInt(1, 123)
  19. Call(0, 1)
  20. GetGlobal(0, 0)
  21. LoadConst(1, 1)
  22. Call(0, 1)
  23. GetGlobal(0, 0)
  24. LoadConst(1, 2)
  25. Call(0, 1)
  26. nil
  27. false
  28. 123
  29. 123456
  30. 123456.0

执行正常,但有个小问题,也是上一章就遗留下来的,即print在常量表里出现了多次。这里需要修改为,在每次添加常量时检查是否已经存在。

添加常量

把上面的add_const()函数修改如下:

```rust, ignore fn add_const(constants: &mut Vec, c: Value) -> usize { constants.iter().position(|v| v == &c) .unwrap_or_else(|| { constants.push(c); constants.len() - 1 }) }

  1. `constants.iter().position()`定位索引。其参数是一个[闭包](https://kaisery.github.io/trpl-zh-cn/ch13-01-closures.html),需要比较两个`Value`,为此需要给`Value`实现`PartialEq` trait:
  2. ```rust,ignore
  3. {{#include ../listing/ch02.variables/src/value.rs:peq}}

这里我们认为数值相等的两个整数和浮点数是不同的,比如Integer(123456)Float(123456.0),因为这确实是两个值,在处理常量表时,不能合并这两个值,否则上一节的测试代码里,最后一行就也是加载整数123456了。

但在Lua执行过程中,这两个值是相等的,即123 == 123.0的结果是true。我们会在后面章节处理这个问题。

回到position()函数,其返回值是Option<usize>Some(i)代表找到,直接返回索引;而None代表没有找到,需要先添加常量,再返回索引。按照C语言的编程习惯就是下面的if-else判断,但这里尝试用了更函数化的方式。个人感觉这种方式并没有更加清晰,但既然是在学习Rust,就先尽量优先用Rust的方式。

```rust, ignore if let Some(i) = constants.iter().position(|v| v == &c) { i } else { constants.push(c); constants.len() - 1 }

  1. 完成对`add_const()`函数的改造后,常量表里就可以避免出现重复的值。相关输出截取为:

[src/parse.rs:64] &constants = [ print, 123456, 123456.0, ] ```

上述虽然在添加常量时会检查重复,但检查是通过遍历数组做的。添加所有常量的时间复杂度是O(N^2)。如果一个Lua代码段中包含的常量特别多,比如有1百万个,就解析太慢了。为此我们需要一个哈希表来提供快速的查找。TODO。