更多类型

这一节增加简单的类型,包括布尔、整数和浮点数。其他类型比如表和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也放到常量表里了。直接编码到字节码里更方便,在执行的时候(因为少读一次内存)也更快。所以新增LoadNilLoadBool字节码。

  • 整数常量,则结合了上述两种做法。因为一个字节码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)
}

测试

至此,解析过程完成了对新增类型的支持。剩下虚拟机执行部分只是支持新增的几个字节码LoadIntLoadBoolLoadNil即可。这里略过。

然后就可以测试如下代码:

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。