前言
这系列文章介绍用Rust语言从零开始实现一个Lua解释器。
Rust语言个性鲜明,也广受欢迎,然而学习曲线陡峭。我在读完《Rust程序设计语言》并写了些练习代码后,深感必须通过一个较大的项目实践才能理解和掌握。
实现一个Lua解释器就很适合作为这个练习项目。因为其规模适中,足够涉及Rust的大部分基础特性而又不至于难以企及;目标明确,无需花费精力讨论需求;另外Lua语言本身也是一门设计优秀且应用广泛的语言,实现一个Lua解释器不仅可以实践Rust语言技能,还能深入了解Lua语言。
这个系列的文章记录了在这个项目中的学习和探索过程。与其他从零开始Build your own X的项目类似,这个项目也有明确的大目标、未知的探索过程以及持续的成就感,但也有一些不同之处:
-
其他项目的作者大都在相关领域浸淫多年,而我的工作并不是编程语言或编译原理方向,对于实现一个解释器而言并没有完整的理论知识,纯粹摸着石头过河。不过凡事要往好处想,这也提供了一个真正的初学者视角。
-
其他项目大都是以学习或教学为目的,去繁从简,实现一个只具备最基本功能的原型。而我的目标是实现一个生产级别的Lua解释器,追求稳定、完整、和性能。
另外,由于项目的初衷是学习Rust语言,所以文章中也会有一些Rust语言的学习笔记和使用心得。
内容
内容安排如下。第1章实现一个最小的、只能解析 print "hello, world!"
语句的解释器。虽然简单,但是包括了解释器的完整流程,构建了基本框架。后续章节就以Lua语言特性为出发点,在这个最小解释器上逐渐地增加功能。
第2章介绍编程语言中最基本的类型和变量的概念。第3章以完善字符串类型为目标,介绍Rust语言的几个特性。第4章实现Lua中的表结构,并引入语法分析中关键的ExpDesc概念。第5章是繁琐的数值计算。
第6章控制结构,事情开始变得有趣起来,根据判断条件在字节码间跳来跳去。第7章介绍逻辑运算和关系运算,通过特定的优化跟上一章的控制结构结合起来。
第8章介绍函数,函数的基本概念和实现是比较简单的,但可变参数和多返回值要求对栈做精细管理。第9章介绍的闭包是Lua语言中的一个强大特性,这其中的关键是Upvalue及其逃逸。
每一章都从Lua功能特性出发,先讨论如何设计,再介绍具体实现。不仅要讲清楚“怎么做”,更重要的是讲清楚“为什么这么做”,尽量避免写成代码阅读笔记。不过为了实现完整的Lua特性,肯定会有部分文章很无聊,尤其是前面几章。读者可以先浏览相对有趣的字符串类型的定义和Upvalue的逃逸这两节,以判断本系列文章是否符合口味。
每一章都有完整的可运行代码,并且每一章的代码都是基于前一章的最终代码,保证整个项目是连续的。每个小节对应的代码改动都集中在两三个commit中,可以通过git来查看变更历史。开头的章节在介绍完设计原理后,基本还会逐行解释代码;到后面只会解释关键部分的代码;最后两章基本上就不讲代码了。
目前这些章节只是完成了Lua解释器最核心的部分,而距离一个完整的解释器还差很多。未完待续一节罗列了部分未完成的功能列表。
文章中并不会介绍Lua和Rust的基本语法,我们预期读者对这两门语言都有基本的了解。其中对Lua语言自然是越熟悉越好。对Rust语言则无高要求,只要读过《Rust程序设计语言》,了解基本语法即可,毕竟这个项目的初衷就是为了学习Rust。另外,一提到实现一门语言的解释器就会让人想起艰深的编译原理,但实际上由于Lua语言很简单,并且有Lua官方实现的解释器代码作为参考,这个项目不需要太多理论知识,主要是工程实践为主。
由于我在编译原理、Lua语言、Rust语言等各方面技术能力所限,项目和文章中必定会有很多错误;另外我的语言表达能力很差,文章中也会有很多词不达意或语句不通之处,都欢迎读者来项目的github主页提issue反馈。
hello, world!
按照介绍编程语言的惯例,我们从hello, world!
开始。不过,我们不是编写程序直接输出这句话,而是要实现一个Lua解释器来解释执行下面的Lua代码:
print "hello, world!"
这段代码虽然简单,但我们的极简版本的解释器仍然会包含一个通用解释器的完整流程,包括词法分析、语法分析、生成字节码和虚拟机执行等步骤。后续只要在这个流程的基础上增加功能,就可以逐渐实现一个完整的Lua解释器。
不过,这段Lua代码也没看上去那么简单,它包含了全局变量(print),字符串常量("hello, world!"),标准库(print)和函数调用等诸多概念。这些概念又依赖Lua的值、栈等内部概念。能够解释执行这段代码,就可以对解释器的工作原理有个很直观的理解。
为了完成这个解释器,本章首先介绍下必要的编译原理知识,这应该是整个系列文章里仅有的理论部分,也可能是错误最多的一节。然后介绍字节码和值这两个核心概念。再然后逐步实现词法分析、语法分析和虚拟机。最终完成一个(仅)能执行上述Lua代码的解释器。
编译原理
编译原理是一门很精深也很成熟的学科,这里没必要也没能力做完整或准确的介绍,只是按照后续实现的流程做些简单的概念介绍。
编译型和解释型
无论什么编程语言,源代码在交给计算机执行之前,必然需要一个翻译的过程,以把源代码翻译成计算机可执行的语言。按照这个翻译的时机,编程语言大致可以分为2种:
- 编译型,即编译器先将源代码编译成计算机语言,并生成可执行文件。后续由计算机直接执行此文件。比如在Linux下,用编译器gcc把C语言源码编译为可执行文件。
- 解释型,则需要一个解释器,实时地加载并解析源程序,然后将解析的结果对应到预先编译的功能并执行。这个解释器一般是由上面的编译型语言实现。
+-------+ 编译 +----------+ +---------+ 解析并执行 +----------+
| 源代码 | -----> | 可执行文件 | | 源代码 | ----------> | Lua解释器 |
| bar.c | | bar.exe | | bar.lua | | lua.exe |
+-------+ +----------+ +---------+ +----------+
^ ^
|执行机器指令 |执行机器指令
| |
+-------------+ +-------------+
| 计算机 | | 计算机 |
+-------------+ +-------------+
编译型 解释型
上图大致展示了两种类型的翻译和执行过程。Lua属于解释型语言,我们的目标也是要实现Lua解释器,所以下面只介绍这个类型。在此之前先明确几个名词的含义:
- 编译(compile),这个名词的含义有点乱。广义上可以指任何将程序从一种计算机程序语言转换到另一种语言计算机语言的过程,比如“编译原理”这个词中的编译,再比如把Lua的源码转换为字节码的过程也可以认为是编译。狭义上特指上述的第一种类型,跟“解释型”相对。再狭义些,特指上述编译型过程的某个阶段,跟预处理、链接等过程并列。本文后续尽量避免使用这个名词。
- 解释(interpret),特指上述的第二种编译类型,跟“编译型”相对。
- 解析,是个笼统的概念,而非编译原理的专有名词。可指任何形式的转换,比如理解源码的语义,再比如把字符串解析为数字等。
- 翻译,对应编译的最广义的概念。
- 分析,这个词本身是个笼统的概念,但“词法分析”和“语法分析”是编译原理中的专有名词。
解析和执行
一般编译原理教程上介绍的编译过程如下:
词法分析 语法分析 语义分析
字符流 --------> Token流 --------> 语法树 --------> 中间代码 ...
- 其中字符流对应源代码,即把源代码作为字符流来处理。
- 词法分析,把字符流拆为语言支持的Token。比如上述Lua代码就拆为“标识
print
”和“字符串"hello, world!"
”两个Token。Lua是忽略空白字符的。 - 语法分析,把Token流按照语法规则,解析为语法树。比如刚才的2个Token被识别为一条函数调用语句,其中“标识
print
”是函数名,“字符串"hello, world!"
”是参数。 - 语义分析,把这条函数调用的语句生成对应的中间代码,这些代码指示从哪里查找函数体,把参数加载到什么位置等具体功能。
生成中间代码之后,编译型和解释型语言就分道扬镳。编译型继续前进,最终生成可以直接执行的机器码,并包装为可执行文件。而对于解释型语言到此就告一段落,生成的中间代码(一般称为字节码)就是编译的结果;而字节码的执行就是虚拟机的任务了。
虚拟机把字节码转换为对应的一系列预先编译好的功能,然后执行这些功能。比如执行上述生成的字节码,虚拟机会首先找到对应的函数,即print
,是Lua标准库里的函数;然后加载参数,即"hello, world";最后调用print
函数。这个函数也是预先编译好的,其功能是打印参数。这就最终完成了输出"hello, world!"的功能。
上述只是一般流程。具体到每个语言或者每个解释器流程可能有所不同。比如有的解释器可能不生成字节码,而是让虚拟机直接执行语法树。而Lua的官方实现则是省略了语法树,由语法分析直接生成字节码。这些选择各有优劣,但已超出我们的主题范围,这里不做讨论。我们的解释器在主流程上是完全参考的Lua官方实现,所以最终的流程如下:
词法分析 语法分析
字符流 --------> Token流 --------> 字节码
^
|
虚拟机
由此可以明确我们的解释器的主要功能组件:词法分析、语法分析和虚拟机。可以把词法分析和语法分析合并称为“解析”过程,而虚拟机是“执行”的过程,那么字节码就是连接这两个过程的纽带。解析和执行两个过程相对独立。接下来我们就以字节码作为突破口,开始实现我们的解释器。
字节码
作为一个小白,要实现一个解释器,开始自然是一头雾水,无从下手。
好在上一节最后介绍了字节码,把整个解释器流程分为解析和执行两个阶段。那么我们就可以从字节码入手:
- 先确定字节码,
- 然后让解析过程(词法分析和语法分析)努力生成这套字节码,
- 再让执行过程(虚拟机)努力执行这套字节码。
生成 执行
解析 -------> 字节码 <------- 虚拟机
但字节码长什么样?如何定义?有什么类型?可以先参考Lua的官方实现。
luac的输出
为方便叙述,这里再次列出目标代码:
print "hello, world!"
Lua官方实现自带一个非常好用的工具,luac
,即Lua Compiler,把源代码翻译为字节码并输出。是我们这个项目的最得力助手。看下其对"hello, world!"程序的输出:
$ luac -l hello_world.lua
main <hello_world.lua:0,0> (5 instructions at 0x600000d78080)
0+ params, 2 slots, 1 upvalue, 0 locals, 2 constants, 0 functions
1 [1] VARARGPREP 0
2 [1] GETTABUP 0 0 0 ; _ENV "print"
3 [1] LOADK 1 1 ; "hello, world!"
4 [1] CALL 0 2 1 ; 1 in 0 out
5 [1] RETURN 0 1 1 ; 0 out
输出的前面2行看不懂,先忽略。后面应该就是字节码了,还有注释,太棒了。不过还是看不懂。查看Lua的官方手册,但是发现找不到任何关于字节码的说明。原来Lua的语言标准只是定义了语言的特性,而字节码属于“具体实现”的部分,就像解释器代码里的变量命名一样,并不属于Lua标准的定义范围。事实上完全兼容Lua 5.1的Luajit项目就用了一套完全不一样的字节码。我们甚至可以不用字节码来实现解释器,呃,扯远了。既然手册没有说明,那就只能查看Lua官方实现的代码注释。这里只介绍上面出现的5个字节码:
- VARARGPREP,暂时用不到,忽略。
- GETTABUP,这个有些复杂,可以暂时理解为:加载全局变量到栈上。3个参数分别是作为目标地址的栈索引(0)、忽略、全局变量名在常量表里的索引(0)。后面注释里列出了全局变量名是"print"。
- LOADK,加载常量到栈上。2个参数分别是作为目的地址的栈索引(1),和作为加载源的常量索引(1)。后面注释里列出了常量的值是"hello, world!"。
- CALL,函数调用。3个参数分别是函数的栈索引(0)、参数个数、返回值个数。后面注释说明是1个参数,0个返回值。
- RETURN,暂时用不到,忽略。
连起来再看一下,就是
- 首先把名为
print
的全局变量加载到栈(0)位置; - 然后把字符串常量
"hello, world!"
加载到栈(1)位置; - 然后执行栈(0)位置的函数,并把栈(1)位置作为参数。
执行时的栈示意图如下:
+-----------------+
0 | print | <- 函数
+-----------------+
1 | "hello, world!" |
+-----------------+
| |
我们目前只要实现上述的2、3、4这三个字节码即可。
字节码定义
现在定义字节码格式。
首先参考Lua官方实现的格式定义。源码里有对字节码格式的注释:
We assume that instructions are unsigned 32-bit integers.
All instructions have an opcode in the first 7 bits.
Instructions can have the following formats:
3 3 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0
1 0 9 8 7 6 5 4 3 2 1 0 9 8 7 6 5 4 3 2 1 0 9 8 7 6 5 4 3 2 1 0
iABC C(8) | B(8) |k| A(8) | Op(7) |
iABx Bx(17) | A(8) | Op(7) |
iAsBx sBx (signed)(17) | A(8) | Op(7) |
iAx Ax(25) | Op(7) |
isJ sJ(25) | Op(7) |
A signed argument is represented in excess K: the represented value is
the written unsigned value minus K, where K is half the maximum for the
corresponding unsigned argument.
字节码用32bit的无符号整数表示。其中7bit是命令,其余25bit是参数。字节码一共5种格式,每种格式的参数不同。如果你喜欢这种精确到bit的控制感,也许会立即想到各种位操作,可能已经开始兴奋了。不过先不着急,先来看下Luajit的字节码格式:
A single bytecode instruction is 32 bit wide and has an 8 bit opcode field and
several operand fields of 8 or 16 bit. Instructions come in one of two formats:
+---+---+---+---+
| B | C | A | OP|
| D | A | OP|
+---+---+---+---+
也是32bit无符号整数,但字段的划分只精确到字节,而且只有2种格式,比Lua官方实现简单很多。在C语言里,通过定义匹配的struct和union,就可以较方便地构造和解析字节码,从而避免位操作。
既然Lua语言没有规定字节码的格式,那我们也可以设计自己的字节码格式。像这种不同类型命令,每个命令有独特关联参数的场景,最适合使用Rust的enum,用tag做命令,用关联的值做参数:
#[derive(Debug)]
pub enum ByteCode {
GetGlobal(u8, u8),
LoadConst(u8, u8),
Call(u8, u8),
}
Luajit的字节码定义可以避免位操作,而使用Rust的enum可以更进一步,甚至都不用关心每个字节码的内存布局。可以用enum的创建语法来构造字节码,比如ByteCode::GetGlobal(1,2)
;用模式匹配match
来解析字节码。在后面1.4节里的parse和vm模块分别构造和解析字节码。
不过也要注意保证这个enum不超过32bit,所以还是要了解一下enum的布局。Rust中enum的tag的大小是以字节为单位,并且是按需分配的。所以只要字节码种类少于2^8=256个,那么tag就只需要1个字节。Lua官方的字节码里只有7bit用来表示命令类型,所以256是足够的。然后就还有3个字节的空间可以存储参数。Luajit的两种字节码类型里,参数也都只占了3个字节,那就也是足够的。这个文章介绍了静态检查的方法,不过由于需要第三方库或宏,我们这里暂时不用。
Rust的enum真的很好用!
两个表
从上面的分析里可以看到,除了字节码,我们还需要两个表。
一个是常量表,在解析过程中存储所有遇到的常量,生成的字节码通过索引参数来引用对应的常量;在执行过程中虚拟机通过字节码里的参数来读取表中的常量。在这个例子里,遇到两个常量,一个是全局变量print
的名字,另外一个是字符串常量"hello, world!"。这也就是上述luac的输出第2行里2 constants
的意思了。
另一个是全局变量表,根据变量名称保存全局变量。虚拟机执行时,先通过字节码中参数查询常量表里的全局变量名,然后再根据名字查询全局变量表。全局变量表只在执行过程中使用(添加,读取,修改),而跟解析过程无关。
这两个表的具体定义,需要依赖Lua的“值”这个概念,下一节介绍。
值和类型
上一节定义了字节码,并且在最后提到我们还需要两个表,常量表和全局变量表,分别维护常量和变量跟“值”之间的关系,所以其定义就依赖Lua值的定义。本节就介绍并定义Lua的值。
为方便叙述,本节中后续所有“变量”一词包括变量和常量。
Lua是动态类型语言,“类型”是跟值绑定,而不是跟变量绑定。比如下面代码第一行,等号前面变量n包含的信息是:“名字是n”;等号后面包含的信息是:“类型是整数”和“值是10”。所以在第2行还是可以把n赋值为字符串的值。
local n = 10
n = "hello" -- OK
作为对比,下面是静态类型语言Rust。第一行,等号前面包含的信息是:“名字是n” 和 “类型是i32”;等号后面的信息是:“值是10”。可以看到“类型”信息从变量的属性变成了值的属性。所以后续就不能把n赋值为字符串的值。
let mut n: i32 = 10;
n = "hello"; // !!! Wrong
下面两个图分别表示动态类型和静态类型语言中,变量、值和类型之间的关系:
变量 值 变量 值
+--------+ +----------+ +----------+ +----------+
| 名称:n |--\------>| 类型:整数 | | 名称:n |-------->| 值: 10 |
+--------+ | | 值: 10 | | 类型:整数 | | +---------+
| +----------+ +----------+ X
| |
| +------------+ | +------------+
\------>| 类型:字符串 | \--->| 值:"hello" |
| 值:"hello" | +------------+
+------------+
动态类型 静态类型
类型跟值绑定 类型跟变量绑定
值Value
综上,Lua的值是包含了类型信息的。这也非常适合用enum来定义:
use std::fmt;
use crate::vm::ExeState;
#[derive(Clone)]
pub enum Value {
Nil,
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::String(s) => write!(f, "{s}"),
Value::Function(_) => write!(f, "function"),
}
}
}
当前定义了3种类型:
Nil
,Lua的空值。String
,用于hello, world!
字符串。关联值类型暂时使用最简单的String,后续会做优化。Function
,用于print
。关联的函数类型定义是参考Lua中的C API函数定义typedef int (*lua_CFunction) (lua_State *L);
,后续会做改进。其中ExeState
对应lua_State
,在下一节介绍。
可以预见后续还会增加整数、浮点数、表等类型。
在Value定义的上面,通过#[derive(Clone)]
实现了Clone
trait。这是因为Value肯定会涉及到赋值操作,而我们现在定义的String类型包含了Rust的字符串String
,后者是不支持直接拷贝的,即没有实现Copy
trait,或者说其拥有堆heap上的数据。所以只能把整个Value也声明为Clone
的。后续所有涉及Value的赋值,都需要通过 clone()
来实现。看上去比直接赋值的性能要差一些。我们后续在定义了更多类型后,还会讨论这个问题。
我们还手动实现了Debug
trait,定义打印格式,毕竟当前目标代码的功能就是打印"hello, world!"。由于其中的Function
关联的函数指针参数不支持Debug
trait,所以不能用#[derive(Debug)]
的方式来自动实现。
两个表
定义好值Value后,就可以定义上一节最后提到的两个表了。
常量表,用来存储所有需要的常量。字节码直接用索引来引用常量,所以常量表可以用Rust的可变长数组Vec<Value>
表示。
全局变量表,根据变量名称保存全局变量,可以暂时用Rust的HashMap<String, Value>
表示。
相对于古老的C语言,Rust标准库里
Vec
和HashMap
这些组件带来了很大的方便。不用自己造轮子,并提供一致的体验。
动手实现
之前章节介绍了编译原理基础知识,并定义了两个最重要的概念字节码ByteCode和值Value。接下来就可以动手编码实现我们的解释器了!
这系列文章对应的代码全部使用Rust自带的Cargo来管理。目前采用二进制类型的项目,后续会改成库类型。
目前要实现的极简解释器是非常简单的,代码很少,我当初也是把代码都写在一个文件里。不过可以预见的是,这个项目的代码量会随着功能的增加而增加。所以为了避免后续再拆文件的改动,我们直接创建多个文件:
- 程序入口:
main.rs
; - 三个组件:词法分析
lex.rs
、语法分析parse.rs
、和虚拟机vm.rs
; - 两个概念:字节码
byte_code.rs
、值value.rs
。
后面的两个概念和其代码之前已经介绍了。下面介绍其他4个文件。先从程序入口开始。
程序入口
简单起见,我们的解释器只有一种工作方式,即接受一个参数作为Lua源码文件,然后解析并执行。代码如下:
use std::env;
use std::fs::File;
mod value;
mod bytecode;
mod lex;
mod parse;
mod vm;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() != 2 {
println!("Usage: {} script", args[0]);
return;
}
let file = File::open(&args[1]).unwrap();
let proto = parse::load(file);
vm::ExeState::new().execute(&proto);
}
开头2行引用了两个标准库。env
用于获取命令行参数,可参考这里。fs::File
用来打开Lua源文件。
中间几行通过use
引用其他文件的模块。
然后看main()
函数。前面几行是读取参数并打开源文件。在打开源文件时使用了unwrap()
,如果打开失败则终止程序。简单起见,接下来几章对所有错误的处理方式都是直接终止程序,之后再统一引入规范的错误处理。
最后2行是核心功能:
- 首先语法分析模块
parse
(内部调用词法分析lex
)解析文件,并返回解析结果proto
; - 然后创建一个虚拟机,并执行
proto
。
这个流程跟Lua官方实现的API调用方式不一样。Lua官方实现的主要流程如下(完整示例):
lua_State *L = lua_open(); // 创建lua_State
luaL_loadfile(L, filename); // 解析,并把解析结果放在栈顶
lua_pcall(L, 0, 0, 0); // 执行栈顶
这是因为Lua官方实现是“库”,API对外只暴露lua_State
这一个数据结构,负责解析和执行两部分的功能,所以要先创建lua_State
,再以其为基础去调用解析和执行,解析结果也是通过Lua_state
的栈来传递。而我们目前没有类似的统一的状态数据结构,所以只能先分别调用解析和执行两部分的功能。
下面分别看解析和执行过程。
词法分析
虽然上面的main()
函数里是直接调用的语法分析parse
模块,但语法分析内部是调用了词法分析lex
模块。先看词法分析。
词法分析的输出是Token流。对于"hello, world!"程序,只需用到“标识print
”和“字符串"hello, world!"
”这两个Token,简单起见我们也暂时只支持这两个。另外我们还定义一个Eos
用于表示文件结束:
#[derive(Debug)]
pub enum Token {
Name(String),
String(String),
Eos,
}
我们并没有一次性把输入文件解析完毕,返回一个Token数组,而是提供一个类似迭代器的功能,以便让语法分析模块按需调用。为此先定义一个词法分析器:
#[derive(Debug)]
pub struct Lex {
input: File,
}
现在暂时只包含一个成员,即输入文件。
对外提供2个API:new()
基于输入文件创建语法分析器;next()
返回下一个Token。
impl Lex {
pub fn new(input: File) -> Self ;
pub fn next(&mut self) -> Token ;
}
具体的解析过程就是单纯的字符串处理了,代码略过。
按照Rust的惯例,这里的next()
函数的返回值应该是Option<Token>
类型,Some<Token>
表示读到新Token,None
表示文件结束。但是既然Token
本身就是一个enum
了,直接在里面加入一个Eos
似乎更方便些。而且如果改成Option<Token>
类型,那么在下一次语法分析调用的地方,也会需要多一层判断,如下代码。所以还是选择了新增Eos
类型。
loop {
if let Some(token) = lex.next() { // extra check
match token {
... // parse
}
} else {
break
}
}
语法分析
上面main()
函数中的解析结果proto
是解析和执行两个过程的中间纽带。但是鉴于Rust强大的类型机制,在上述代码中proto
并没有展示出具体的类型。现在来看其类型定义。在字节码一节中已经介绍过,解析结果需要包含2部分:字节码序列和常量表。于是可以定义解析结果的格式如下:
#[derive(Debug)]
pub struct ParseProto {
pub constants: Vec::<Value>,
pub byte_codes: Vec::<ByteCode>,
}
常量表constants
是包含Value
类型的Vec
,字节码序列byte_codes
是包含ByteCode
类型的Vec
。他们都是Vec
结构,具有相同的功能,但包含类型不一样。在古老的C语言里要包含Value
和ByteCode
这两种类型,要么针对每种类型都编写一套代码,要么就要用到宏或函数指针等复杂特性。Rust语言中的泛型就可以针对不同类型抽象出同一套逻辑。后续代码中会用到泛型的更多特性。
在定义好ParseProto
后,接下来看语法分析流程。我们目前只支持print "hello, world!"
这一个类型的语句,也就是Name String
的格式。首先从词法分析中读取Name,然后再读取字符串常量。如果不是这个格式则报错。具体代码如下:
pub fn load(input: File) -> ParseProto {
let mut constants = Vec::new();
let mut byte_codes = Vec::new();
let mut lex = Lex::new(input);
loop {
match lex.next() {
Token::Name(name) => { // `Name LiteralString` as function call
constants.push(Value::String(name));
byte_codes.push(ByteCode::GetGlobal(0, (constants.len()-1) as u8));
if let Token::String(s) = lex.next() {
constants.push(Value::String(s));
byte_codes.push(ByteCode::LoadConst(1, (constants.len()-1) as u8));
byte_codes.push(ByteCode::Call(0, 1));
} else {
panic!("expected string");
}
}
Token::Eos => break,
t => panic!("unexpected token: {t:?}"),
}
}
dbg!(&constants);
dbg!(&byte_codes);
ParseProto { constants, byte_codes }
}
输入是源文件File
,输出是刚才定义的ParseProto
。
函数主体是个循环,通过在函数开头创建的词法分析器lex
提供的next()
函数,循环读取Token。我们目前只支持一种类型的语句,即Name LiteralString
,并且语义是函数调用。所以分析逻辑也很简单:
- 遇到
Name
,认为是语句开始:- 把
Name
作为全局变量,存入常量表中; - 生成
GetGlobal
字节码,把根据名字把全局变量加载到栈上。第1个参数是目标栈索引,由于我们目前只支持函数调用语言,栈只用来函数调用,所以函数一定是在0的位置;第2个参数是全局变量名在全局变量中的索引; - 读取下一个Token,并预期是字符串常量,否则panic;
- 把字符串常量加入到常量表中;
- 生成
LoadConst
字节码,把常量加载到栈上。第1个参数是目标栈索引,排在函数的后面,为1;第2个参数是常量在常量表中的索引; - 准备好了函数和参数,就可以生成
Call
字节码,调用函数。目前2个参数,分别是函数位置和参数个数,分别固定为0和1。
- 把
- 遇到
Eos
,退出循环。 - 遇到其他Token(目前只能是
Token::String
类型),则panic。
函数后面通过dbg!
输出常量表和字节码序列,用于调试。可以跟luac
的输出做对比。
最后返回ParseProto
。
虚拟机执行
解析生成ParseProto
后,就轮到虚拟机执行了。按照之前分析,虚拟机目前需求2个组件:栈和全局变量表。所以定义虚拟机状态如下:
pub struct ExeState {
globals: HashMap<String, Value>,
stack: Vec::<Value>,
}
创建虚拟机时,需要提前在全局变量表里添加print
函数:
impl ExeState {
pub fn new() -> Self {
let mut globals = HashMap::new();
globals.insert(String::from("print"), Value::Function(lib_print));
ExeState {
globals,
stack: Vec::new(),
}
}
其中print
函数的定义如下:
// "print" function in Lua's std-lib.
// It supports only 1 argument and assumes the argument is at index:1 on stack.
fn lib_print(state: &mut ExeState) -> i32 {
println!("{:?}", state.stack[1]);
0
}
目前print
函数只支持一个参数,并假设这个参数在栈的1位置。函数功能就是打印这个参数。因为这个函数不需要向调用者返回数据,所以返回0。
完成初始化后,下面就是最核心的虚拟机执行功能,也就是字节码分发的大循环:依次读取字节码序列并执行对应的预定义功能。具体代码如下:
pub fn execute(&mut self, proto: &ParseProto) {
for code in proto.byte_codes.iter() {
match *code {
ByteCode::GetGlobal(dst, name) => {
let name = &proto.constants[name as usize];
if let Value::String(key) = name {
let v = self.globals.get(key).unwrap_or(&Value::Nil).clone();
self.set_stack(dst, v);
} else {
panic!("invalid global key: {name:?}");
}
}
ByteCode::LoadConst(dst, c) => {
let v = proto.constants[c as usize].clone();
self.set_stack(dst, v);
}
ByteCode::Call(func, _) => {
let func = &self.stack[func as usize];
if let Value::Function(f) = func {
f(self);
} else {
panic!("invalid function: {func:?}");
}
}
}
}
}
现在只支持3个字节码。每个功能都很明确,无须多言。
测试
至此,我们实现了一个完整流程的Lua解释器!看下运行效果:
$ cargo r -q -- test_lua/hello.lua
[src/parse.rs:39] &constants = [
print,
hello, world!,
]
[src/parse.rs:40] &byte_codes = [
GetGlobal(
0,
0,
),
LoadConst(
1,
1,
),
Call(
0,
),
]
hello, world!
输出分3部分。第1部分是常量表,包含2个字符串常量。第2部分是字节码,可以跟字节码一节里的luac
的输出对比下。最后1行就是我们期待的结果:hello, world!
还有个额外的功能。语法分析部分并非只支持一条语句,而是一个循环。所以我们可以支持多条print
语句,比如:
print "hello, world!"
print "hello, again..."
执行会发现有个小问题,就是在常量表里print
出现了两次。这里可以优化为,每次向常量表里添加值时可以先判断是否已经存在。在下一章处理。
总结
本章的目的是实现一个完整流程的Lua解释器,以熟悉解释器结构。为此,我们首先介绍了编译原理基础知识,然后介绍了Lua的字节码和值这两个核心概念,最后编码实现!
我们一直在强调“完整流程”,是因为后续只需要基于这个框架上,加上亿点点细节,就能完成一个“完整功能”的Lua解释器。继续前行。
变量和赋值
上一章我们完成了一个功能简单但具有完整流程的Lua解释器。后续就要在这个解释器的基础上,持续添加新特性。
这一章首先增加一些简单的类型,包括布尔、整数和浮点数。然后引入局部变量。
更多类型
这一节增加简单的类型,包括布尔、整数和浮点数。其他类型比如表和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。
局部变量
本节介绍局部变量的定义和访问(下一节介绍赋值)。
简单起见,我们暂时只支持定义局部变量语句的简化格式:local name = expression
,也就是说不支持多变量或者无初始化。目标代码如下:
local a = "hello, local!" -- define new local var 'a'
print(a) -- use 'a'
局部变量如何管理,如何存储,如何访问?先参考下luac的结果:
main <local.lua:0,0> (6 instructions at 0x6000006e8080)
0+ params, 3 slots, 1 upvalue, 1 local, 2 constants, 0 functions
1 [1] VARARGPREP 0
2 [1] LOADK 0 0 ; "hello, world!"
3 [2] GETTABUP 1 0 1 ; _ENV "print"
4 [2] MOVE 2 0
5 [2] CALL 1 2 1 ; 1 in 0 out
6 [2] RETURN 1 1 1 ; 0 out
跟上一章的直接打印"hello, world!"的程序对比,有几个区别:
- 输出的第2行里的
1 local
,说明有1个局部变量。不过这只是一个说明,跟下列字节码没关系。 - LOADK,加载常量到栈的索引0处。对应源码第[1]行,即定义局部变量。由此可见变量是存储在栈上,并在执行过程中赋值。
- GETTABUP的目标地址是1(上一章里是0),也就是把
print
加载到位置1,因为位置0用来保存局部变量。 - MOVE,新字节码,用于栈内值的复制。2个参数分别是目的索引和源索引。这里就是把索引0的值复制到索引2处。就是把局部变量a作为print的参数。
前4个字节码执行完毕后,栈上布局如下:
+-----------------+ MOVE
0 | local a |----\
+-----------------+ |
1 | print | |
+-----------------+ |
2 | "hello, world!" |<---/
+-----------------+
| |
由此可知,执行过程中局部变量存储在栈上。在上一章里,栈只是用于函数调用,现在又多了存储局部变量的功能。相对而言局部变量是更持久的,只有在当前block结束后才失效。而函数调用是在函数返回后就失效。
定义局部变量
增加局部变量的处理。首先定义局部变量表locals
。在值和类型一节里说明,Lua的变量只包含变量名信息,而没有类型信息,所以这个表里只保存变量名即可,定义为Vec<String>
。另外,此表只在语法分析时使用,而在虚拟机执行时不需要,所以不用添加到ParseProto
中。
目前已经支持2种语句(函数调用的2种格式):
Name String
Name ( exp )
其中exp
是表达式,目前支持多种常量,比如字符串、数字等。
现在要新增的定义局部变量语句的简化格式如下:
local Name = exp
这里面也包括exp
。所以把这部分提取为一个函数load_exp()
。那么定义局部变量对应的语法分析代码如下:
Token::Local => { // local name = exp
let var = if let Token::Name(var) = lex.next() {
var // can not add to locals now
} else {
panic!("expected variable");
};
if lex.next() != Token::Assign {
panic!("expected `=`");
}
load_exp(&mut byte_codes, &mut constants, lex.next(), locals.len());
// add to locals after load_exp()
locals.push(var);
}
代码比较简单,无需全部介绍。load_exp()
函数参考下面小节。
需要特别注意的是,最开始解析到变量名var
时,并不能直接加入到局部变量表locals
中,而是要在解析完表达式后才能加入。可以认为解析到var
时,还没有完整局部变量的定义;需要等到整个语句结束后才算完成定义,才能加入到局部变量表中。下面小节说明具体原因。
访问局部变量
现在访问局部变量,即print(a)
这句代码。也就是在exp
中增加对局部变量的处理。
其实,在上一节的函数调用语句的
Name ( exp )
格式里,就可以在exp
里增加全局变量。这样就可以支持print(print)
这样的Lua代码了。只不过当时只顾得增加其他类型常量,就忘记支持全局变量了。这也反应了现在的状态,即加功能特性全凭感觉,完全不能保证完整性甚至正确性。我们会在后续章节里解决这个问题。
于是修改load_exp()
的代码(这里省略原来各种常量类型的处理部分):
fn load_exp(byte_codes: &mut Vec<ByteCode>, constants: &mut Vec<Value>,
locals: &Vec<String>, token: Token, dst: usize) {
let code = match token {
... // other type consts, such as Token::Float()...
Token::Name(var) => load_var(constants, locals, dst, var),
_ => panic!("invalid argument"),
};
byte_codes.push(code);
}
fn load_var(constants: &mut Vec<Value>, locals: &Vec<String>, dst: usize, name: String) -> ByteCode {
if let Some(i) = locals.iter().rposition(|v| v == &name) {
// local variable
ByteCode::Move(dst as u8, i as u8)
} else {
// global variable
let ic = add_const(constants, Value::String(name));
ByteCode::GetGlobal(dst as u8, ic as u8)
}
}
load_exp()
函数中对变量的处理也放到单独的load_var()
函数中,这是因为之前的函数调用语句的“函数”部分也可以调用这个函数,这样就也可以支持局部变量的函数了。
对变量的处理逻辑是:先在局部变量表locals
里查找:
- 如果存在,就是局部变量,生成
Move
字节码。这是一个新字节码。 - 否则,就是全局变量,处理过程之前章节介绍过,这里略过。
可以预见,后续在支持upvalue后,也是在这个函数中判断。
load_var()
函数在变量表中查找变量时,是从后往前查找,即使用.rposition()
函数。这是因为我们在注册局部变量时,并没有检查重名。如果有重名,也会照旧注册,即排到局部变量表的最后。这种情况下,反向查找,就会找到后注册的变量,而先注册的变量就永远定位不到了。相当于后注册的变量覆盖了前面的变量。比如下列代码是合法的,并且输出456
:
local a = 123
local a = 456
print(a) -- 456
我感觉这种做法很巧妙。如果每次添加局部变量时都先判断是否存在的话,必定会消耗性能。而这种重复定义局部变量的情况并不多见(也可能是我孤陋寡闻),为了这小概率情况而去判断重复(无论是报错还是重复利用)都不太值得。而现在的做法(反向查找)即保证了性能,又可以正确支持这种重复定义的情况。
Rust中也有类似的shadow变量。不过我猜Rust应该不能这么简单的忽略处理,因为Rust中一个变量不可见时(比如被shadow了)是要drop的,所以还是要特意判断这种shadow情况并特别处理。
另外一个问题是,在上一段定义局部变量的最后提到,解析到变量名var
时,并不能直接加入到局部变量表locals
中,而是要在解析完表达式后才能加入。当时因为还没有“访问”局部变量,所以没有说明具体原因。现在可以说明了。比如对下列代码:
local print = print
这种语句在Lua代码中比较常见,即把一个常用的“全局变量”赋值给一个同名的“局部变量”,这样后续在引用此名字时就是访问的局部变量。局部变量比全局变量快很多(局部变量通过栈索引访问,而全局变量要实时查找全局变量表,也就是Move
和GetGlobal
这两个字节码的区别),这么做会提升性能。
回到刚才的问题,如果在刚解析到变量名print
时就加入到局部变量表中,那在解析=
后面的表达式print
时,查询局部变量表就会找到刚刚加入的print
,那么就相当于是把局部变量print
赋值给局部变量print
,就循环了,没意义了(真这么做的话,print
会被赋值为nil)。
综上,必须在解析完=
后面表达式后,才能把变量加入到局部变量表中。
函数调用的位置
之前我们的解释器只支持函数调用的语句,所以栈只是函数调用的场所,执行函数调用时,函数和参数分别固定在0和1的位置。现在支持了局部变量,栈就不只是函数调用的场所了,函数和参数的位置也就不固定了,而需要变成栈上的第一个空闲位置,即局部变量的下一个位置。为此:
-
在语法分析时,可以通过
locals.len()
获取局部变量的个数,也就是栈上的第一个空闲位置。 -
在虚拟机执行时,需要在
ExeState
中增加一个字段func_index
,在函数调用前设置此字段来表示这个位置,并在函数中使用。对应的代码分别如下:
ByteCode::Call(func, _) => {
self.func_index = func as usize; // set func_index
let func = &self.stack[self.func_index];
if let Value::Function(f) = func {
f(self);
} else {
panic!("invalid function: {func:?}");
}
}
fn lib_print(state: &mut ExeState) -> i32 {
println!("{:?}", state.stack[state.func_index + 1]); // use func_index
0
}
测试
至此,我们实现了局部变量的定义和访问,并且在这个过程中还整理了代码,使得之前的函数调用语句也变强大了,函数和参数都支持了全局变量和局部全局。所以本文开头的那个只有2行的目标代码太简单了。可以试试下面的代码:
local a = "hello, local!" -- define a local by string
local b = a -- define a local by another local
print(b) -- print local variable
print(print) -- print global variable
local print = print -- define a local by global variable with same name
print "I'm local-print!" -- call local function
执行结果:
[src/parse.rs:71] &constants = [
hello, local!,
print,
I'm local-print!,
]
byte_codes:
LoadConst(0, 0)
Move(1, 0)
GetGlobal(2, 1)
Move(3, 1)
Call(2, 1)
GetGlobal(2, 1)
GetGlobal(3, 1)
Call(2, 1)
GetGlobal(2, 1)
Move(3, 2)
LoadConst(4, 2)
Call(3, 1)
hello, local!
function
I'm local-print!
符合预期!这个字节码有点多了,可以跟luac的输出对比一下。我们之前是只能分析和模仿luac编译的字节码序列,现在可以自主编译并输出字节码了。很大的进步!
语法分析代码的OO改造
功能已经完成。但是随着功能的增加,语法分析部分的代码变的比较乱,比如上述load_exp()
函数的定义,就有一堆的参数。为了整理代码,把语法分析也改造成面向对象模式的,围绕ParseProto
来定义方法,这些方法通过self
就能获取全部信息,就不用很多参数传来传去了。具体改动参见提交f89d2fd。
把几个独立的成员集合在一起,也带来一个小问题,一个Rust语言特有的问题。比如原来读取字符串常量的代码如下,先调用load_const()
生成并返回字节码,然后调用byte_codes.push()
保存字节码。这两个函数调用是可以写在一起的:
byte_codes.push(load_const(&mut constants, iarg, Value::String(s)));
改成面向对象方式后,代码如下:
self.byte_codes.push(self.load_const(iarg, Value::String(s)));
而这是不能编译通过的,报错如下:
error[E0499]: cannot borrow `*self` as mutable more than once at a time
--> src/parse.rs:70:38
|
70 | self.byte_codes.push(self.load_const(iarg, Value::String(s)));
| ---------------------^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^-
| | | |
| | | second mutable borrow occurs here
| | first borrow later used by call
| first mutable borrow occurs here
|
help: try adding a local storing this argument...
--> src/parse.rs:70:38
|
70 | self.byte_codes.push(self.load_const(iarg, Value::String(s)));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: ...and then using that local as the argument to this call
--> src/parse.rs:70:17
|
70 | self.byte_codes.push(self.load_const(iarg, Value::String(s)));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
For more information about this error, try `rustc --explain E0499`.
Rust编译器虽然很严格,但报错信息还是很清晰的,甚至给出了正确的修改方法。
self
被mut引用了2次。虽然self.load_const()
中并没有用到self.byte_codes
,实际中并不会出现冲突,但编译器并不知道这些细节,编译器只知道self
被引用了两次。这就是把多个成员集合在一起的后果。解决方法是,按照Rust给出的建议,引入一个局部变量,然后把这行代码拆成两行:
let code = self.load_const(iarg, Value::String(s));
self.byte_codes.push(code);
这里的情况还属于简单的,因为返回的字节码code
和self.constants
没有关联,也就跟self
没了关联,所以下面才能正常使用self.byte_codes
。假如一个方法返回的内容还跟这个数据结构有关联,那解决方法就没这么简单了。后续在虚拟机执行时会遇到这种情况。
变量赋值
我们在第一章最开始的打印"hello, world!"的程序中,就支持了全局变量,即print
函数。但是只支持访问,而不支持赋值或创建,现在唯一的全局变量print
还是在创建虚拟机的时候,手动加到全局变量表里的。我们上一节里又实现了定义和访问局部变量,但是也不支持赋值。本节就来实现全局变量和局部变量的赋值。
单纯变量的赋值比较简单,但Lua中完整的赋值语句就很复杂,比如t[f()] = 123
。我们这里先实现变量赋值,然后简单介绍下完整的赋值语句的区别。
赋值的组合
本节要支持的变量赋值语句表示如下:
Name = exp
等号=
左边(左值)目前就是2类,局部变量和全局变量;右边就是前面章节的表达式exp
,大致分为3类:常量、局部变量、和全局变量。所以这就是一个2*3的组合:
-
local = const
,把常量加载到栈上指定位置,对应字节码LoadNil
、LoadBool
、LoadInt
和LoadConst
等。 -
local = local
,复制栈上值,对应字节码Move
。 -
local = global
,把栈上值赋值给全局变量,对应字节码GetGlobal
。 -
global = const
,把常量赋值给全局变量,需要首先把常量加到常量表中,然后通过字节码SetGlobalConst
完成赋值。 -
global = local
,把局部变量赋值给全局变量,对应字节码SetGlobal
。 -
global = global
,把全局变量赋值给全局变量,对应字节码SetGlobalGlobal
。
这6种情况中,前3种是赋值给局部变量,在上一节的load_exp()
函数已经实现,这里不再介绍。后面3种是赋值给全局变量,相应新增了3个字节码。这3个字节码的参数格式类似,都是2个参数,分别是:
- 目标全局变量的名字在常量表中的索引,类似之前的
GetGlobal
字节码的第2个参数。所以这3种情况下,都需要先把全局变量的名字加入到常量表中。 - 源索引,3个字节码分别是:在常量表中的索引、在栈上地址、全局变量的名字在常量表中的索引。
上述的第4种情况,即global = const
,只用一个字节码就处理了全部常量类型,而没有像之前局部变量那样针对部分类型设置不同的字节码(比如LoadNil
、LoadBool
等)。这是因为局部变量是直接通过栈上的索引来定位的,虚拟机执行其赋值是很快的,如果把源数据能内联进字节码,减少一次常量表的访问,可以明显比例的提升性能。但是访问全局变量是需要查表的,虚拟机执行较慢,此时内联源数据带来的性能提升相对而言非常小,就没必要了。毕竟多几个字节码,在解析和执行阶段都会带来复杂度。
词法分析
原来支持函数调用和定义局部变量语句,现在要新增变量赋值语句。如下:
Name String
Name ( exp )
local Name = exp
Name = exp # 新增
这里有个问题,新增的变量赋值语句也是以Name
开头,跟函数调用一样。所以根据开头第一个Token无法区分,就需要再向前“看”一个Token:如果是等号=
就是变量赋值语句,否则就是函数调用语句。这里的“看”打了引号,是为了强调是真的看一下,而不能“取”出来,因为后续的语句分析还需要用到这个Token。为此,词法分析还要新增一个peek()
方法:
pub fn next(&mut self) -> Token {
if self.ahead == Token::Eos {
self.do_next()
} else {
mem::replace(&mut self.ahead, Token::Eos)
}
}
pub fn peek(&mut self) -> &Token {
if self.ahead == Token::Eos {
self.ahead = self.do_next();
}
&self.ahead
}
其中的ahead
是在Lex
结构体中新增的字段,用以保存从字符流中解析出来但又不能返回出去的Token。按照Rust语言的惯例,这个ahead
应该是Option<Token>
类型,Some(Token)
代表有提前读取的Token,None
代表没有。但鉴于跟next()
返回值类型同样的原因,这里直接使用Token
类型,而用Token::Eos
来代表没有提前读取到Token。
原来的对外next()
函数改成do_next()
内部函数,被新增的peek()
和新的next()
函数调用。
新增的peek()
函数返回的是&Token
而非Token
,是因为这个Token的所有者还是Lex,而并没有交给调用者。只是“借给”调用者“看”一下。如果调用者不仅要“看”,还要“改”,那就需要&mut Token
了,但我们这里只需要看,并不需要改。既然出现了&
借用,那就涉及到生命周期lifetime。由于这个函数只有一个输入生命周期参数,即&mut self
,按照省略规则,它被赋予所有输出生命周期参数,这种情况下就可以省略生命周期的标注。这种默认生命周期向编译器传递的意思是:返回的引用&Token
的合法周期,小于等于输入参数即&mut self
,也就是Lex
本身。
我个人认为变量的所有者、借用(引用)、可变借用,是Rust语言最核心的概念。概念本身是很简单的,但是需要跟编译器深入斗争一段时间,才能深刻理解。而生命周期这个概念是基于上述几个核心概念的,也稍微复杂些,更需要在实践中理解。
新的next()
是对原来的do_next()
函数的简单包装,处理了可能存储在ahead
中的、之前peek的Token:如果存在,则直接返回这个Token,而无需调用do_next()
。但在Rust中这个“直接返回”并不能很直接。由于Token
类型不是Copy
的(因为其String(String)
类型不是Copy
的),所以不能直接返回。简单的解决方法是使用Clone
,但Clone的意思就是告诉我们:这是需要付出代价的,比如对于字符串,就需要复制一份;而我们并不需要2份字符串,因为把Token返回后,我们就不需要这个Token了。所以我们现在需要的结果是:返回ahead
中的Token,并同时清理ahead
(这里自然是设置为代表“没有”的Token::Eos
)。这个场景非常像网上流传甚广的那个《夺宝奇兵》的gif(直接搜索“夺宝奇兵gif”即可),把手中的沙袋“替换”机关上的宝物。这里的“替换”就是个关键词,这个需求可以用标准库中的std::mem::replace()
函数完成。这个需求感觉是很常见的(至少在C语言的项目里很常见),所以需要用这么一个函数来完成,感觉有些小题大做。不过也正是因为这些限制,才保证了Rust承诺的安全性。不过如果ahead
是Option<Token>
类型,那么就可以用Option
的take()
方法了,看上去简单一些,功能完全一样。
语法分析
随着功能的增加,语法分析的这个大循环内部代码会越来越多,所以我们先把每种语句都放到独立的函数中,即function_call()
和local()
,然后再增加变量赋值语句assignment()
。这里用到了刚才词法分析中增加的peek()
函数:
fn chunk(&mut self) {
loop {
match self.lex.next() {
Token::Name(name) => {
if self.lex.peek() == &Token::Assign {
self.assignment(name);
} else {
self.function_call(name);
}
}
Token::Local => self.local(),
Token::Eos => break,
t => panic!("unexpected token: {t:?}"),
}
}
}
然后看assignment()
函数:
fn assignment(&mut self, var: String) {
self.lex.next(); // `=`
if let Some(i) = self.get_local(&var) {
// local variable
self.load_exp(i);
} else {
// global variable
let dst = self.add_const(var) as u8;
let code = match self.lex.next() {
// from const values
Token::Nil => ByteCode::SetGlobalConst(dst, self.add_const(Value::Nil) as u8),
Token::True => ByteCode::SetGlobalConst(dst, self.add_const(Value::Boolean(true)) as u8),
Token::False => ByteCode::SetGlobalConst(dst, self.add_const(Value::Boolean(false)) as u8),
Token::Integer(i) => ByteCode::SetGlobalConst(dst, self.add_const(Value::Integer(i)) as u8),
Token::Float(f) => ByteCode::SetGlobalConst(dst, self.add_const(Value::Float(f)) as u8),
Token::String(s) => ByteCode::SetGlobalConst(dst, self.add_const(Value::String(s)) as u8),
// from variable
Token::Name(var) =>
if let Some(i) = self.get_local(&var) {
// local variable
ByteCode::SetGlobal(dst, i as u8)
} else {
// global variable
ByteCode::SetGlobalGlobal(dst, self.add_const(Value::String(var)) as u8)
}
_ => panic!("invalid argument"),
};
self.byte_codes.push(code);
}
}
对于左值是局部变量的情况,调用load_exp()
处理。对于全局变量的情况,按照右边表达式的类型,分别生成SetGlobalConst
、SetGlobal
和SetGlobalGlobal
字节码。
测试
使用下列代码测试上述6种变量赋值的情况:
local a = 456
a = 123
print(a)
a = a
print(a)
a = g
print(a)
g = 123
print(g)
g = a
print(g)
g = g2
print(g)
执行符合预期。不再贴出具体执行结果。
完整的赋值语句
上述变量赋值的功能很简单,但Lua完整的赋值语句很复杂。主要表现在下面两个地方:
首先,等号=
左边现在只支持局部变量和全局变量,但完整的赋值语句中还要支持表字段的赋值,比如t.k = 123
,或者更复杂的t[f()+g()] = 123
。而上述的assignment()
函数是很难增加表的支持的。为此,是要增加一个中间表达层的,即后续TODO引入的ExpDesc
结构。
其次,等号=
后面的表达式现在分为3类,对于3个字节码。后续如果要引入其他类型的表达式,比如upvalue、表索引(比如t.k
)、或者运算结果(比如a+b
),那是要给每个类型都增加一个字节码吗?答案是,不能。但这会涉及到一些现在还没遇到的问题,所以也不好解释。如果不能的话,那需要怎么办?这也涉及到上面提到的ExpDesc
。
我们在后续会实现Lua完整的赋值语句,届时现在的赋值代码就会被完全舍弃。
字符串
在继续完善我们的解释器之前,本章先停下来仔细讨论一下Lua中的字符串类型。在Lua这样的高级语言中,字符串是使用起来很简单的类型;但是对于Rust这种低级语言,字符串就没那么简单了。下面引用《Rust程序设计语言》中的一段话:
字符串是新晋 Rustacean 们通常会被困住的领域,这是由于三方面理由的结合:Rust 倾向于确保暴露出可能的错误,字符串是比很多程序员所想象的要更为复杂的数据结构,以及 UTF-8。所有这些要素结合起来对于来自其他语言背景的程序员就可能显得很困难了。
在Lua解释器中实现并优化字符串,就是一个探索Rust字符串的大好机会。
基于字符串的定义,本章还会做出一个重要的决定:使用Rc
实现垃圾回收。
字符串定义
这节暂时不添加新功能,而是停下来讨论和优化字符串类型。
《Rust程序设计语言》书中所有权一节以字符串为例介绍了堆和栈的概念,以及其和所有权的关系;在字符串String一节提到了Rust的字符串是复杂的。我们现在就通过字符串来探索Rust对堆和栈的分配,并初步体验字符串的复杂。
堆和栈
《Rust程序设计语言》中以字符串为例介绍了堆和栈的概念,以及堆栈与所有权之间的关系。这里简单复述一遍。Rust的String包括两部分:
- 元数据,一般位于栈上,包括3个字段:指向内存块的指针、字符串长度、内存块的容量。下面分别用
buffer
、len
和cap
表示。 - 用来存储字符串内容的私有内存块,在堆上申请。由字符串拥有,所以在字符串结束时被释放。正因为拥有堆上的这块内存,所以String就不是
Copy
的,进而导致Value
不是Copy
的。为了复制Value
就只能将其定义为Clone
的。
比如内容为"hello, world!"的String,内存布局如下。左边是栈上的元数据,其中buffer
指向堆上的内存块,len
为字符串的长度,是13,cap
为内存块的容量,很有可能对齐为16。右边是位于堆上的存储字符串内容的内存块。
栈 堆
+--------+
| buffer +------->+----------------+
|--------| |hello, world! |
| len=13 | +----------------+
|--------|
| cap=16 |
+--------+
这里需要说明的是,上面说元数据“一般”位于栈上,是对于简单类型而言。但对于复杂点的类型,比如Vec<String>
,那String元数据部分作为数组的内容就也存在堆上了(类似String的内存块部分)。下面就是一个有2个字符串成员的数组Vec。数组本身的元数据在栈上,但字符串的元数据就在堆上了。
栈 堆
+--------+
| buffer +------->+-------------+-------------+----
|--------| | buf|len|cap | buf|len|cap | ...
| len=2 | +--+----------+--+----------+----
|--------| | V
| cap=4 | V +----------------+
+--------+ +--------+ |hello, world! |
|print | +----------------+
+--------+
这种情况下,元数据数组部分虽然是在堆上,但是仍然有栈的特点,包括后进先出,通过索引快速访问,固定已知大小,无需管理(申请和释放)。事实上,我们Lua解释器的虚拟机的栈是就是类似的Vec<Value>
类型。同样的,其数据虽然是在堆上,但有栈的特点。“栈”这个名词在这里有2个意思:Rust层面的栈,和Lua虚拟机的栈。后者是位于Rust层面的堆上。本文下面所讲到的“栈”,都是后一种意思,即Lua虚拟机的栈。不过当成Rust的栈去理解,也不影响。
使用String
目前Value的字符串类型是直接使用的Rust标准库中的字符串String:
#[derive(Clone)]
struct Value {
String(String),
这样定义的最大问题是,如果要复制一个字符串的Value,就要深度复制字符串,即Clone。下图表示了复制一个字符串的内存布局:
栈 堆
| |
+--------+
|t| |
|-+------|
| buffer +------->+----------------+
|--------| |hello, world! |
| len=13 | +----------------+
|--------|
| cap=16 |
+--------+
: :
: :
+--------+
|t| |
|-+------|
| buffer +------->+----------------+
|--------| |hello, world! |
| len=13 | +----------------+
|--------|
| cap=16 |
+--------+
| |
图中左边是Lua虚拟机的栈,每行代表一个字。由于我们基于64位系统开发,所以一个字是8字节。
第1行的t
代表enum Value
的tag。由于我们的Value类型小于256种,1个字节就可以表示,所以t占用1个字节。紧接着的3行buffer
、len
和cap
构成一个Rust标准库的String。每个字段都占用一个字。buffer
是8字节对齐,所以跟t
之间就空了7个字节,这部分是空洞,不可用。这4行(图中四个+
包围起来的矩形)总共构成一个字符串类型的Value。
Rust中并没有规定enum的默认布局(虽然可以指定)。我们这里只是列出一种布局的可能性。这并不影响本节的讨论。
深度复制这个字符串Value,就需要复制栈上的元数据和堆上的内存块,对性能和内存都是很大的浪费。Rust中解决这个问题最直接的方法就是使用Rc
。
使用Rc<String>
为了快速复制字符串String,就需要允许字符串同时存在多个所有者。Rust的Rc提供了这个特性。在String外面封装Rc
,在复制时只需要更新Rc计数即可。定义如下:
#[derive(Clone)]
struct Value {
String(Rc<String>),
内存布局如下:
栈 堆
| |
+--------+
|t| |
|-+------|
| Rc +----+-->+--------+--------+--------+--------+--------+
+--------+ | |count=2 | weak=0 | buffer | len=13 | cap=16 |
: : | +--------+--------+-+------+--------+--------+
: : | |
+--------+ | V
|t| | | +----------------+
|-+------| | |hello, world! |
| Rc +----/ +----------------+
+--------+
| |
图中右边的count
和weak
就是Rc
的封装。由于当前有2个Value指向这个字符串,所以count
为2。
使用Rc
,直接导致了这个解释器要使用引用计数法来实现垃圾回收。在下面小节中会专门讨论这个影响重大的决定。
这个方案虽然解决了复制的问题,但也带来了一个新问题,就是访问字符串的内容需要2次指针跳转。这会浪费内存并影响执行性能。下面介绍一些优化方案。
使用Rc<str>
Lua中的字符串有个特点,是只读的!如果要对字符串做处理,比如截断、连接、替换等,都会生成新的字符串。而Rust的String是为可变字符串设计的,所以用来表示只读字符串有点浪费,比如可以省掉元数据里的cap
字段,也不用为了可能的修改而预留内存。比如上述例子里,"hello, world!"长度只有13,但申请了16的内存块。Rust中更适合表示只读字符串的是&str
,即String
的slice。但&str
是个引用,并没有对字符串的所有权,而需要依附在某个字符串上。不过它有不是字符串String的引用(字符串的引用是&String
),直观看上去,应该是str
的引用。那str
是个什么?好像从来没有单独出现过。
举例说明。对于如下代码:
let s = String::from("hello, world!"); // String
let r = s[7..12]; // &str
其中r
是&str
类型,内存布局如下:
栈 堆
s: +--------+
| buffer +------->+----------------+
|--------| |hello, world! |
| len=13 | +-------^--------+
|--------| |
| cap=16 | |
+--------+ |
|
r: +--------+ |
| buffer +----------------/
|--------|
| len=5 |
+--------+
那对&str
解引用,得到的就是"world"这段内存。不过一般的引用就是个地址,但这里还附加了长度信息,说明str
除了内存,还包括了长度信息。只不过这个长度信息并不像String那样在原始数据上,而是跟随引用在一起。事实上,str
确实不能独立存在,必须跟随引用(比如&str
)或者指针(比如Box(str)
)。这种属于动态大小类型。
而Rc
也是一种指针,所以就可以定义Rc<str>
。定义如下:
#[derive(Clone)]
struct Value {
String(Rc<str>),
内存布局如下:
栈 堆
| |
+--------+
|t| |
|-+------|
| Rc +----+-->+--------+--------+-------------+
|--------| | |count=2 | weak=0 |hello, world!|
| len=13 | | +--------+--------+-------------+
+--------+ |
: : |
: : |
+--------+ |
|t| | |
|-+------| |
| Rc +----/
+--------+
| len=13 |
+--------+
| |
其中"hello, world!"是原始数据,被Rc封装。而长度信息len=13
跟随Rc
一起存储在栈上。
这个方案看上去非常好!相对于上面的Rc<String>
方案,这个方案去掉了没用的cap
字段,无需预留内存,而且还省去了一层指针跳转。但这个方案也有2个问题:
首先,创建字符串时需要复制内容。之前的方案只需要复制字符串的元数据部分即可,只有3个字的长度。而这个方案要把字符串内容复制到新创建的Rc包内。想象要创建一个1M长的字符串,这个复制就很影响性能了。
其次,就是在栈上占用2个字的空间。虽然在最早的直接使用String的方案里占用3个字的空间,问题更严重,但是可以理解为我们现在的标准提高了。目前,Value里的其他类型都最多只占用1个字(加上tag就一共是2个字),可以剧透的是后续要增加的表、UserData等类型也都只占用1个字,所以如果单独因为字符串类型而让Value的大小从2变成3,那就是浪费了。不仅占用更多内存,而且还对CPU缓存不友好。
这个问题的关键就在于len
跟随Rc
一起,而不是跟随数据一起。如果能把len
放到堆上,比如在图中weak
和"hello, world!"之间,那就完美了。对于C语言这是很简单的,但Rust并不支持。原因在于str
是动态大小类型。那如果选一个固定大小类型的,是不是就可以实现?比如数组。
使用Rc<(u8, [u8; 47])>
Rust中的数组是有内在的大小信息的,比如[u8; 10]
和[u8; 20]
的大小就分别是10和20,这个长度是编译时期就知道的,无需跟随指针存储。两个长度不同的数组就是不同的类型,比如[u8; 10]
和[u8; 20]
就是不同的类型。所以数组是固定大小类型,可以解决上一小节的问题,也就是栈上只需要1个word即可。
既然是固定长度,那就只能存储小于这个长度的字符串,所以这个方案不完整,只能是是一个性能优化的补充方案。不过Lua中遇到的字符串大部分都很短,至少我的经验如此,所以这个优化还是很有意义的。为此我们需要定义2种字符串类型,一个是固定长度数组,用于优化短字符串,另一个是之前的Rc<String>
方案,用于存储长字符串。固定长度数组的第一个字节用来表示字符串的实际长度,所以数组可以拆成2部分。我们先假设使用总长度48的数组(1个字节表示长度,47个字节存储字符串内容),则定义如下:
struct Value {
FixStr(Rc<(u8, [u8; 47])>), // len<=47
String(Rc<String>), // len>47
短字符串的内存布局如下:
栈 堆
| |
+--------+
|t| |
|-+------|
| Rc +----+-->+--------+--------+----------------------------+
+--------+ | |count=2 | weak=0 |len|hello, world! |
: : | +--------+--------+----------------------------+
: : |
+--------+ |
|t| | |
|-+------| |
| Rc +----/
+--------+
| |
图中右边的数组部分开头第一个字节len
表示后面字符串的实际长度。后面的47个字节可以用于存储字符串内容。
这个方案跟上述的Rc<str>
一样,都需要复制字符串内容,所以不适合长字符串。这个问题不大,本来这个方案就是为了优化短字符串的。然后即便是短字符串,数组长度的选取也很关键。如果很长,则对短字符串而言空间浪费严重;如果很短,则覆盖比例不高。不过在这个方案上还可以继续优化,采用多级长度的数组,比如16、32、48、64等。不过这也会造成一些复杂性。
另外,数组长度的选取还依赖Rust使用的内存管理库。比如我们选择长度为48,加上Rc封装的2个计数字段16字节,那么上图中右边堆上的内存块长度为64字节,是个很“规整”的长度。比如内存管理库jemalloc对小内存块的管理就是分为16、32、48、64、128等长度,那么上述总长度64的内存申请就没有浪费。假如我们选择数组长度为40,内存块总长度就是56,仍然会匹配到64的分类中,就会浪费64-56=8字节。当然,依赖其他库的具体实现来做决定,这是很不好的行为,不过好在这个影响并不大。
我们这里选择数组长度为48,也就是只能表示长度从0到47的字符串。
然后跟Rc<String>
方案对比下,看看优化效果如何。首先,这个方案最大的优点是只需一次内存分配,在执行时也就只需一次指针跳转。
其次,对比下分配的内存大小。在Rc<String>
方案中需要申请2块内存:一是Rc计数和字符串元数据,固定2+3=5个字,40字节,按照jemalloc的内存策略,会占用48字节内存;二是字符串内容部分,所占内存大小跟字符串长度相关,也取决于Rust String的内存管理策略和底层库的实现,比如对于长度为1的字符串,可能占用16字节内存;对于长度为47的字符串,可能占用48字节,也可能占用64字节内存。两块内存加起来要占用64到112字节,大于或等于这个固定长度数组的方案。
我们沿着“优化短字符串”的思路,看下一个方案。
使用内联数组
上一个方案相对于Rc<String>
而言减少了一层指针跳转。下面这个方案更进一步,直接去掉堆上存储,而把字符串完全存储在栈上。
我们希望Value
类型的大小是2个字,即16个字节。其中1个用于tag,1个用于字符串长度,那么就还有14个字节的剩余,这部分空间可以用来存储长度小于14的字符串。这个方案跟上一个一样,也是补充方案,也要跟一个长字符串定义配合使用。具体如下:
// sizeof(Value) - 1(tag) - 1(len)
const INLSTR_MAX: usize = 14;
struct Value {
InlineStr(u8, [u8; INLSTR_MAX]), // len<=14
String(Rc<String>), // len>14
其中短字符串InlineStr
关联两个参数:u8
类型的字符串长度,和长度为14的u8
数组,这也充分利用了之前一直被浪费的t
后面7个字节的空洞。而长字符串String
仍然使用Rc<String>
方案。
短字符串的内存布局如下:
栈
| |
+vv------+
|tlhello,|
|--------|
| world! |
+--------+
: :
: :
+vv------+
|tlhello,|
|--------|
| world! |
+--------+
| |
其中格子上的箭头v
指向的t
和l
分别代表1个字节的tag和长度。实际的字符串内容跨了2个字。如果横着画栈,看上去更清晰些:
栈:
--+-+-+--------------+......+-+-+--------------+--
|t|l|hello, world! | |t|l|hello, world! |
--+------------------+......+------------------+--
这个方案性能最优,但能力最差,只能处理长度不大于14的字符串。Lua中字符串类型的使用场景有3个:全局变量名、表索引、和字符串值。前两者绝大部分的都不大于14字节,所以应该可以覆盖大部分情况。
还可以再进一步优化,再增加一种类型,专门存储长度为15的字符串,因为长度已知,所以原来存储长度的一个字节也可以用来存储字符串内容。但这个方案带来的优化感觉不明显,小于带来的复杂度,所以不采用。定义如下。
struct Value {
InlineStr(u8, [u8; INLSTR_MAX]), // len<=14
Len15Str([u8; 15]), // len=15
String(Rc<String>), // len>15
总结和选择
这一节里,我们依次使用并分析了String
、Rc<String>
、Rc<str>
、Rc<(u8, [u8; 47])>
和内联(u8, [u8; 14])
等几种方案。各有优缺点。合理的做法是区分对待长短字符串,用短字符串优化,用长字符串兜底。可选的3个方案:
- 为了保证
Value
类型的长度,长字符串只能使用Rc<String>
。 - 对于短字符串,最后的内联方案完全不用堆上内存,优化效果最好。
- 倒数第2个固定长度数组方案,属于上述两个方案的折中,略显鸡肋。不过缺点也只有一个,就是引入更大的复杂性,字符串需要处理3种类型。下一节通过泛型来屏蔽这3种类型,就解决了这个缺点。
最终方案如下:
const SHORT_STR_MAX: usize = 14; // sizeof(Value) - 1(tag) - 1(len)
const MID_STR_MAX: usize = 48 - 1;
struct Value {
ShortStr(u8, [u8; SHORT_STR_MAX]),
MidStr(Rc<(u8, [u8; MID_STR_MAX])>),
LongStr(Rc<Vec<u8>>),
原来的InlineStr
和FixStr
都是代表具体实现方案,而对外表现的特征就是长和短,所以改名为ShortStr
、MidStr
和LongStr
,更直观。
这样,对于大部分情况(短字符串)可以快速处理,而对于小部分情况(长字符串)虽然慢但也可以正确处理,并且不影响全局(比如Rc<str>
就占用了2个字,直接使得Value
也变大,就算是影响了全局),最终提升整体的处理效率,这是很常见并且很有效的优化思路。我们这个方案通过区分2套定义实现优化,是个典型的例子。如果能不区分定义,而只用一套定义、一套算法就能达到这个目的,就更优美了。后面在赋值语句的语法分析时,会遇到这样的例子。
区分长短字符串后,也带来两个新问题:
-
生成字符串类型
Value
时,要根据字符串长度来选择ShortStr
、MidStr
还是LongStr
。这个选择应该是自动实现的,而不应该由调用者实现,否则一是麻烦二是可能出错。比如现在语法分析的代码中出现多次的self.add_const(Value::String(var))
语句,就需要改进。 -
字符串,顾名思义是“字符”组成,但
ShortStr
和MidStr
都是由u8
组成,区别在哪里?u8
如何正确表达Unicode?如何处理非法字符?
接下来的几节讨论这两个问题。
类型转换
上一节在Value
类型中引入了3个字符串类型,在创建字符串类型时需要根据长度来生成不同类型。这个判断不应该交给调用者,而应该自动完成。比如现有的语句:
self.add_const(Value::String(var));
就应该改成:
self.add_const(str_to_value(var));
其中str_to_value()
函数就把字符串var
转换成Value
对应的字符串类型。
From trait
这种从一种类型转换(或者称为生成)另外一种类型的功能非常常见,所以Rust标准库中为此定义了From
和Into
trait。这两个互为相反操作,一般只需要实现From
即可。下面就实现了字符串String
类型到Value
类型的转换:
impl From<String> for Value {
fn from(s: String) -> Self {
let len = s.len();
if len <= SHORT_STR_MAX {
// 长度在[0-14]的字符串
let mut buf = [0; SHORT_STR_MAX];
buf[..len].copy_from_slice(s.as_bytes());
Value::ShortStr(len as u8, buf)
} else if len <= MID_STR_MAX {
// 长度在[15-47]的字符串
let mut buf = [0; MID_STR_MAX];
buf[..len].copy_from_slice(s.as_bytes());
Value::MidStr(Rc::new((len as u8, buf)))
} else {
// 长度大于47的字符串
Value::LongStr(Rc::new(s))
}
}
}
然后,本节开头的语句就可以改用into()
函数:
self.add_const(var.into());
泛型
至此,本节开头的需求已经完成。不过既然字符串可以这么做,那其他类型也可以。而且其他类型的转换更直观。下面仅列出两个数字类型到Value
类型的转换:
impl From<f64> for Value {
fn from(n: f64) -> Self {
Value::Float(n)
}
}
impl From<i64> for Value {
fn from(n: i64) -> Self {
Value::Integer(n)
}
}
然后,向常量表里添加数字类型的Value
也可以通过into()
函数:
let n = 1234_i64;
self.add_const(Value::Integer(n)); // 旧方式
self.add_const(n.into()); // 新方式
这么看上去似乎有点小题大做。但如果把所有可能转换为Value
的类型都实现From
,那么就可以把.into()
放到add_const()
内部了:
fn add_const(&mut self, c: impl Into<Value>) -> usize {
let c = c.into();
这里只列出了这个函数的前2行代码。下面就是添加常量的原有逻辑了,这里省略。
先看第2行代码,把.into()
放到add_const()
函数内部,那么外部在调用的时候就不用.into()
了。比如前面添加字符串和整数的语句可以简写成:
self.add_const(var);
self.add_const(n);
现有代码中很多地方都可以这么修改,就会变得清晰很多,那对这些类型实现From
trait就很值得了。
然而问题来了:上述的2行代码里,两次add_const()
函数调用接受的参数的类型不一致!那函数定义中,这个参数类型怎么写?答案就在上面add_const()
函数的定义中:c: impl Into<Value>
。其完整写法如下:
fn add_const<T: Into<Value>>(&mut self, c: T) -> usize {
这个定义的意思是:参数类型为T
,其约束为Into<Value>
,即这个T
需要能够转换为Value
,而不能把随便一个什么类型或数据结构加到常量表里。
这就是Rust语言中的泛型!我们并不完整地介绍泛型,很多书籍和文章里已经介绍的很清楚了。这里只是提供了一个泛型的应用场景,来具体体验泛型。其实我们很早就使用了泛型,比如全局变量表的定义:HashMap<String, Value>
。大部分情况下,是由一些库来定义带泛型的类型和函数,而我们只是使用。而这里的add_const()
是定义了一个带泛型的函数。下一节也会再介绍一个泛型的使用实例。
反向转换
上面是把基础类型转换为Value
类型。但在某些情况下需要反向的转换,即把Value
类型转换为对应的基础类型。比如虚拟机的全局变量表是以字符串类型为索引的,而全局变量的名字是存储在Value
类型的常量表中的,所以就需要把Value
类型转换为字符串类型才能作为索引使用。其中对全局变量表的读操作和写操作,又有不同,其对应的HashMap的API分别如下:
pub fn get<Q: ?Sized>(&self, k: &Q) -> Option<&V> // 省略了K,Q的约束
pub fn insert(&mut self, k: K, v: V) -> Option<V>
读写的区别是,读get()
函数的参数k
是引用,而写insert()
函数的参数k
是索引本身。原因也简单,读时只是用一下索引,而写时是要把索引添加到字典里的,是要消费掉k
的。所以我们要实现Value
类型对字符串类型本身和其引用的转换,即String
和&String
。但对于后者,我们用更通用的&str
来代替。
impl<'a> From<&'a Value> for &'a str {
fn from(v: &'a Value) -> Self {
match v {
Value::ShortStr(len, buf) => std::str::from_utf8(&buf[..*len as usize]).unwrap(),
Value::MidStr(s) => std::str::from_utf8(&s.1[..s.0 as usize]).unwrap(),
Value::LongStr(s) => s,
_ => panic!("invalid string Value"),
}
}
}
impl From<&Value> for String {
fn from(v: &Value) -> Self {
match v {
Value::ShortStr(len, buf) => String::from_utf8_lossy(&buf[..*len as usize]).to_string(),
Value::MidStr(s) => String::from_utf8_lossy(&s.1[..s.0 as usize]).to_string(),
Value::LongStr(s) => s.as_ref().clone(),
_ => panic!("invalid string Value"),
}
}
}
这里的两个转换调用的函数名不一样,std::str::from_utf8()
和String::from_utf8_lossy()
。前者不带_lossy
而后者带。其中原因在于UTF-8等,后续在介绍UTF8时详细介绍。
另外,这个反向转换是可能失败的,比如把一个字符串的Value
类型转换为一个整数类型。但这涉及到错误处理,我们在后续统一梳理错误处理后再做修改。这里仍然使用panic!()
来处理可能的失败。
后续在支持了环境后,会用Lua的表类型和Upvalue来重新实现全局变量表,届时索引就直接是
Value
类型了,这里的转换也就没必要了。
在虚拟机执行的代码中,读写全局变量表时,分别通过两次into()
就完成Value
类型到字符串的转换:
ByteCode::GetGlobal(dst, name) => {
let name: &str = (&proto.constants[name as usize]).into();
let v = self.globals.get(name).unwrap_or(&Value::Nil).clone();
self.set_stack(dst.into(), v);
}
ByteCode::SetGlobal(name, src) => {
let name = &proto.constants[name as usize];
let value = self.stack[src as usize].clone();
self.globals.insert(name.into(), value);
}
输入类型
上一节中我们定义了一个带泛型的函数。实际中我们对泛型“使用”的多,“定义”的少。本章再讨论一个“使用”的示例,就是整个解释器的输入类型,即词法分析模块读取源代码。
目前只支持从文件中读取源代码,并且Rust的文件类型std::fs::File
还不包括标准输入。词法分析数据结构Lex的定义如下:
pub struct Lex {
input: File,
// 省略其他成员
读字符的方法read_char()
定义如下:
impl Lex {
fn read_char(&mut self) -> char {
let mut buf: [u8; 1] = [0];
self.input.read(&mut buf).unwrap();
buf[0] as char
}
这里只关注其中的self.input.read()
调用即可。
使用Read
而Lua官方实现是支持文件(包括标准输入)和字符串这两种类型作为源代码输入的。按照Rust泛型的思路,我们要支持的输入可以不限于某些具体的类型,而是某类支持某些特性(即trait)的类型。也就是说,只要是字符流,可以逐个读取字符就行。这个特性很常见,所以Rust标准库中提供了std::io::Read
trait。所以修改Lex的定义如下:
pub struct Lex<R> {
input: R,
这里有两个改动:
- 把原来的
Lex
改成了Lex<R>
,说明Lex是基于泛型R
, - 把原来的字段input的类型
File
改成了R
。
相应的,实现部分也要改:
impl<R: Read> Lex<R> {
加入了<R: Read>
,表示<R>
的约束是Read
,即类型R必须支持Read
trait。这是因为read_char()
的方法中,用到了input.read()
函数。
而read_char()
方法本身不用修改,其中的input.read()
函数仍然可以正常使用,只不过其含义发生了细微变化:
- 之前input使用
File
类型时,调用的read()
函数,是File
类型实现了Read
trait的方法; - 现在调用的
read()
函数,是所有实现了Read
trait的类型要求的方法。
这里说法比较绕,不理解的话可以忽略。
另外,其他使用到了Lex的地方都要添加泛型的定义,比如ParseProto定义修改如下:
pub struct ParseProto<R> {
其load()
方法的参数也从File
修改为R
:
pub fn load(input: R) -> Self {
支持了Read
后,就可以使用文件以外的类型了。接下来看看使用标准输入类似和字符串类型。
使用标准输入类型
标准输入std::io::Stdin
类型是实现了Read
trait,所以可以直接使用。修改main()
函数,使用标准输入:
fn main() {
let input = std::io::stdin(); // 标准输入
let proto = parse::ParseProto::load(input);
vm::ExeState::new().execute(&proto);
}
测试来自标准输入的源代码:
echo 'print "i am from stdin!"' | cargo r
使用字符串类型
字符串类型并没有直接支持Read
trait,这是因为字符串类型本身没有记录读位置的功能。可以通过封装std::io::Cursor
类型来实现Read
,这个类型功能就是对所有AsRef<[u8]>
的类型封装一个位置记录功能。其定义很明确:
pub struct Cursor<T> {
inner: T,
pos: u64,
}
这个类型自然是实现了Read
trait的。修改main()
函数使用字符串作为源代码输入:
fn main() {
let input = std::io::Cursor::new("print \"i am from string!\""); // 字符串+Cursor
let proto = parse::ParseProto::load(input);
vm::ExeState::new().execute(&proto);
}
使用BufReader
直接读写文件是很消耗性能的操作。上述实现中每次只读一个字节,这对于文件类型是非常低效的。这种频繁且少量读取文件的操作,外面需要一层缓存。Rust标准库中的std::io::BufReader
类型提供这个功能。这个类型自然也实现了Read
trait,并且还利用缓存另外实现了BufRead
trait,提供了更多的方法。
我最开始是把Lex的input字段定义为BufReader<R>
类型,代替上面的R
类型。但后来发现不妥,因为BufReader
在读取数据时,是先从源读到内部缓存,然后再返回。虽然对于文件类型很实用,但对于字符串类型,这个内部缓存就没必要了,多了一次无谓的内存复制。并且还发现标准输入std::io::Stdin
也是自带缓存的,也无需再加一层。所以在Lex内部还是不使用BufReader
,而是让调用者根据需要(比如针对File
类型)自行添加。
下面修改main()
函数,在原有的File
类型外面封装BufReader
:
fn main() {
// 省略参数处理
let file = File::open(&args[1]).unwrap();
let input = BufReader::new(file); // 封装BufReader
let proto = parse::ParseProto::load(input);
vm::ExeState::new().execute(&proto);
}
放弃Seek
本节开头说,我们只要求输入类型支持逐个字符读取即可。事实上并不正确,我们还要求可以修改读位置,即Seek
trait。这是原来的putback_char()
方法要求的,使用了input.seek()
方法:
fn putback_char(&mut self) {
self.input.seek(SeekFrom::Current(-1)).unwrap();
}
这个函数的应用场景是,在词法分析中,有时候需要根据下一个字符来判断当前字符的类型,比如在读到字符-
后,如果下一个字符还是-
,那就是注释;否则就是减法,此时下一个字符就要放回到输入源中,作为下个Token。之前介绍过,在语法分析中读取Token也是这样,要根据下一个Token来判断当前语句类型。当时是在Lex中增加了peek()
函数,可以“看”一眼下个Token而不消费。这里的peek()
和上面的putback_char()
是处理这种情况的2种方式,伪代码分别如下:
// 方式一:peek()
if input.peek() == xxx then
input.next() // 消费掉刚peek的
handle(xxx)
end
// 方式二:put_back()
if input.next() == xxx then
handle(xxx)
else
input.put_back() // 塞回去,下次读取
end
之前使用File
类型时,因为支持seek()
函数,很容易支持后面的put_back
函数,所以就采用了第二种方式。但现在input改为了Read
类型,如果还要使用input.seek()
,那就要求input也有std::io::Seek
trait约束了。上面我们已经测试的3种类型中,带缓存的文件BufReader<File>
和字符串Cursor<String>
都支持Seek
,但标准输入std::io::Stdin
是不支持的,而且可能还有其他支持Read
而不支持Seek
的输入类型(比如std::net::TcpStream
),如果我们这里增加Seek
约束,就把路走窄了。
既然不能用Seek
,那就不用必须使用第二种方式了。也可以考虑第一种方式,这样至少跟Token的peek()
函数方式保持了一致。
比较直白的做法是,在Lex中增加一个ahead_char: char
字段,保存peek到的字符,类似peek()
函数和对应的ahead: Token
字段。这么做比较简单,但是Rust标准库中有更通用的做法,使用Peekable。在介绍Peekable之前,先看下其依赖的Bytes类型。
使用Bytes
本节开头列出的read_char()
函数的实现,相对于其目的(读一个字符)而言,有点复杂了。我后来发现了个更抽象的方法,Read
triat的bytes()
方法,返回一个迭代器Bytes
,每次调用next()
返回一个字节。修改Lex定义如下:
pub struct Lex<R> {
input: Bytes::<R>,
相应的修改构造函数和read_char()
函数。
impl<R: Read> Lex<R> {
pub fn new(input: R) -> Self {
Lex {
input: input.bytes(), // 生成迭代器Bytes
ahead: Token::Eos,
}
}
fn read_char(&mut self) -> char {
match self.input.next() { // 只调用next(),更简单
Some(Ok(ch)) => ch as char,
Some(_) => panic!("lex read error"),
None => '\0',
}
}
这里read_char()
的代码似乎并没有变少。但是其主体只是input.next()
调用,剩下的都是返回值的处理,后续增加错误处理后,这些判断处理就会更有用。
使用Peekable
然后在Bytes
的文档中发现了peekable()
方法,返回Peekable
类型,刚好就是我们的需求,即在迭代器的基础上,可以向前“看”一个数据。其定义很明确:
pub struct Peekable<I: Iterator> {
iter: I,
/// Remember a peeked value, even if it was None.
peeked: Option<Option<I::Item>>,
}
为此,再修改Lex的定义如下:
pub struct Lex<R> {
input: Peekable::<Bytes::<R>>,
相应的修改构造函数,并新增peek_char()
函数:
impl<R: Read> Lex<R> {
pub fn new(input: R) -> Self {
Lex {
input: input.bytes().peekable(), // 生成迭代器Bytes
ahead: Token::Eos,
}
}
fn peek_char(&mut self) -> char {
match self.input.peek() {
Some(Ok(ch)) => *ch as char,
Some(_) => panic!("lex peek error"),
None => '\0',
}
}
这里input.peek()
跟上面的input.next()
基本一样,区别是返回类型是引用。这跟Lex::peek()
函数返回&Token
的原因一样,因为返回的值的所有者还是input,并没有move出来,而只是“看”一下。不过我们这里是char
类型,是Copy的,所以直接解引用*ch
,最终返回char类型。
小结
至此,我们完成了输入类型的优化,从最开始只支持File
类型,到最后支持Read
trait。整理下来内容并不多,但在开始的实现和探索过程中,东撞西撞,费了不少劲。这个过程中也彻底搞清楚了标准库中的一些基本类型,比如Read
、BufRead
、BufReader
,也发现并学习了Cursor
和Peekable
类型,另外也更加了解了官网文档的组织方式。通过实践来学习Rust语言,正是这个项目的最终目的。
Unicode和UTF-8
本章的前面三节优化了字符串相关内容,理清了一些问题,但也引入了一些混乱。比如Value
中的3个字符串类型的定义,有的是[u8]
类型,有的是String
类型:
pub enum Value {
ShortStr(u8, [u8; SHORT_STR_MAX]), // [u8]类型
MidStr(Rc<(u8, [u8; MID_STR_MAX])>), // [u8]类型
LongStr(Rc<String>), // String类型
再比如上一节中“字节”和“字符”混用。词法分析的代码也是这样,从输入字符流中读取字节u8
类型,但通过as
转换为字符char
类型。
fn read_char(&mut self) -> char {
match self.input.next() {
Some(Ok(ch)) => ch as char, // u8 -> char
目前这些混乱之所以还没有造成问题,是因为我们的测试程序只涉及了ASCII字符。如果涉及其他字符,就会出问题。比如对于如下Lua代码:
print "你好"
执行结果就是错误的:
$ cargo r -q -- test_lua/nihao.lua
constants: [print, ä½ å¥½]
byte_codes:
GetGlobal(0, 0)
LoadConst(1, 1)
Call(0, 1)
ä½ å¥½
输出的结果并不是预期中的你好
,而是ä½ å¥½
。有没有想起“手持两把锟斤拷,口中疾呼烫烫烫”?下面就来解释这个“乱码”出现的原因,并修复这个问题。
Unicode和UTF-8概念
这两个都是非常通用的概念,这里只做最基本的介绍。
Unicode对世界上大部分文字进行了统一的编码。其中为了跟ASCII码兼容,对ASCII字符集的编码保持一致。比如英文字母p
的ASCII和Unicode编码都是0x70,按照Unicode的方式写作U+0070
。中文你
的Unicode编码是U+4F60
。
Unicode只是对文字编了号,至于计算机怎么存储就是另外一回事。最简单的方式就是按照Unicode编码直接存储。由于Unicode目前已经支持14万多个文字(仍然在持续增加),那至少需要3个字节来表示,所以英文字母p
就是00 00 70
,中文你
就是00 4F 60
。这种方式的问题是,对于ASCII部分也需要3个字节表示,(对于英文而言)造成浪费。所以就有其他的编码方式,UTF-8就是其中一种。UTF-8是一种变长编码,比如每个ASCII字符只占用1个字节,比如英文字母p
编码仍然是0x70,按照UTF-8的方式写作\x70
;而每个中文占3个字节,比如中文你
的UTF-8编码是\xE4\xBD\xA0
。UTF-8更详细的编码规则这里省略。下面是几个例子:
字符 | Unicode编号 | UTF-8编码
----+------------+---------------
p | U+0070 | \x70
r | U+0072 | \x72
你 | U+4F60 | \xE4\xBD\xA0
好 | U+597D | \xE5\xA5\xBD
乱码分析
介绍完编码概念,再来分析本节开头的Lua测试代码出现乱码的原因。用hexdump查看源码文件:
$ hexdump -C test_lua/nihao.lua
00000000 70 72 69 6e 74 20 22 e4 bd a0 e5 a5 bd 22 0a |print "......".|
# p r i n t " |--你---| |--好---| "
其中最后一行是我添加的注释,表示出每个Unicode文字。可以看到p
和你
的编码,跟上面介绍的UTF-8编码一致。说明这个文件是UTF-8编码的。文件的编码方式取决于使用的文字编辑器和操作系统。
我们目前的词法分析是逐个“字节”读取的,所以对于中文你
,就被词法分析认为是3个独立的字节,分别是e4
、bd
和a0
。然后再用as
转换为char
。Rust的char
是Unicode编码的,所以就得到了3个Unicode文字,通过查询Unicode可以得到这3个文字分别是ä
、½
和
(最后一个是个空白字符),这就是我们开头遇到的“乱码”的前半部分。后面的好
对应乱码的后半部分。这6个字节代表的6个文字,被依次push到Token::String
(Rust的String
类型)中,最终被println!
打印出来。Rust的String
类型是UTF-8编码的,不过这个倒是不影响输出结果。
概括下乱码出现的过程:
- 源文件是UTF-8编码;
- 逐个字节读取,此时UTF-8编码已被支离;
- 每个字节被解释为Unicode,导致乱码;
- 存储和打印。
还可以通过Rust编码再次验证下:
fn main() { let s = String::from("print 你好"); // Rust的String是UTF-8编码,所以可以模拟Lua源文件 println!("string: {}", &s); // 正常输出 println!("bytes in UTF-8: {:x?}", s.as_bytes()); // 查看UTF-8编码 print!("Unicode: "); for ch in s.chars() { // 逐个“字符”读取,查看Unicode编码 print!("{:x} ", ch as u32); } println!(""); let mut x = String::new(); for b in s.as_bytes().iter() { // 逐个“字节”读取 x.push(*b as char); // as char,字节被解释为Unicode,导致乱码 } println!("wrong: {}", x); }
点击右上角可以运行看结果。
乱码问题的核心在于“字节”到“字符char”的转换。所以有2种解决方法:
-
读取源代码时,修改为逐个“字符char”读取。这个方案问题比较大:
- 上一节中我们介绍的Lex的输入类型是
Read
trait,只支持按照“字节”读取。如果要按照“字符char”读取,那就需要首先转换为String
类型,就需要BufRead
trait了,对输入的要求更严格了,比如字符串外封装的Cursor<T>
就不支持。 - 假如源代码输入是UTF-8编码,最后Rust的存储也是UTF-8编码,如果按照Unicode编码的“字符char”读取,那就需要UTF-8到Unicode再到UTF-8的两次无谓的转换。
- 还有一个最重要的原因,接下来马上就会讨论的,Lua的字符串是可以包含任意数据,而不一定是合法的UTF-8内容,也就不一定能正确转换为“字符char”。
- 上一节中我们介绍的Lex的输入类型是
-
读取源代码时,仍然逐个字节读取;在保存时,不再转换为“字符char”,而是直接按照“字节”保存。这就不能继续使用Rust的
String
类型来保存了,具体方案见下。
显而易见(只是现在看来显而易见,当初也是一头雾水,尝试了很久)应该选择第2个方案。
字符串定义
现在看下Lua和Rust语言中字符串内容的区别。
Lua中关于字符串的介绍:We can specify any byte in a short literal string。也就是说Lua的字符串可以表示任意数据。与其叫字符串,不如说就是一串连续的数据,而并不关心数据的内容。
而Rust字符串String
类型的介绍:A UTF-8–encoded, growable string。简单明了。两个特点:UTF-8编码,可增长。Lua的字符串是不可变的,Rust的可增长,但这个区别不是现在要讨论的。现在关注的是前一个特点,即UTF-8编码,也就是说Rust字符串不能存储任意数据。通过Rust的字符串的定义,可以更好的观察到这点:
pub struct String {
vec: Vec<u8>,
}
可以看到String
就是对Vec<u8>
类型的封装。正是通过这个封装,保证了vec
中的数据是合法的UTF-8编码,而不会混进任意数据。如果允许任意数据,那直接定义别名type String = Vec<u8>;
就行了。
综上,Rust的字符串String
只是Lua字符串的子集;跟Lua字符串类型相对应的Rust类型不是String
,而是可以存储任意数据的Vec<u8>
。
修改代码
现在弄清了乱码的原因,也分析了Rust和Lua字符串的区别,就可以着手修改解释器代码了。需要修改的地方如下:
-
词法分析中
Token::String
关联的类型由String
改为Vec<u8>
,以支持任意数据,而不限于合法的UTF-8编码数据。 -
对应的,
Value::LongStr
关联的类型也由String
改为Vec<u8>
。这也就跟另外两个字符串类型ShortStr和MidStr保持了一致。 -
词法分析中,原来的读取函数
peek_char()
和read_char()
分别改成peek_byte()
和next_byte()
,返回类型由“字符char”改成“字节”。原来虽然名字里是char
,但实际上是逐个“字节”读取,所以这次不用修改函数内容。 -
代码中原来匹配的字符常量如
'a'
,要改成字节常量如b'a'
。 -
原来的
read_char()
如果读取到结束,则返回\0
,因为当时认为\0
是特殊字符。现在Lua的字符串可以包含任意值,包括\0
,所以\0
就不能用来表示读到结束。此时就需要Rust的Option
了,返回值类型定义为Option<u8>
。但这就导致调用这个函数的地方不太方便,每次都需要模式匹配(
if let Some(b) =
)才能取出字节。好在这个函数调用的地方不多。但是另外一个函数peek_byte()
调用的地方就很多了。照理说这个函数的返回值也应该改成Option<u8>
,但实际上这个函数返回的字节都是用来“看一看”,只要跟几个可能路径都不匹配,就可以认为没有产生效果。所以这个函数读到结束时,仍然可以返回\0
,因为\0
不会匹配任何可能路径。如果真的读到结尾,那么就留给下一次的next_byte()
去处理就行。正是
Option
带来的这个不方便(必须通过匹配才能取出值),才提现了其价值。我在C语言编程经历中,对于这种函数返回特殊情况的处理,一般都用一个特殊值来表示,比如指针类型就用NULL
,int类型就用0
或-1
。这带来2个问题:一是调用者可能没有处理这种特殊值,会直接导致bug;二是这些特殊值后续可能就变成普通值了(比如我们这次的\0
就是个典型例子),那所有调用这个函数的地方都要修改。而Rust的Option
就完美解决了这两个问题。 -
词法分析中,字符串支持escape。这部分都是无趣的字符处理,这里省略介绍。
-
增加
impl From<Vec<u8>> for Value
,用以将Token::String(Vec<u8>)
中的字符串常量转换为Value
类型。这个又涉及很多Vec和字符串的细节,非常繁琐,且跟主线关系不大,下面再开两个小节专门介绍。
&str, String, &[u8], Vec到Value的转换
之前已经实现了String
和&str
到Value
的转换。现在要增加Vec<u8>
和&[u8]
到Value
的转换。这4个类型间的关系如下:
slice
&[u8] <---------> Vec<u8>
^
|封装
slice |
&str <---------> String
String
是对Vec<u8>
的一层封装。可以通过into_bytes()
返回封装的Vec<u8>
。&str
是String
的slice(可以认为是引用?)。&[u8]
是Vec<u8>
的slice。
所以String
和&str
可以分别依赖Vec<u8>
和&[u8]
。而且看上去Vec<u8>
和&[u8]
之间也可以相互依赖,即只直接实现其中之一到Value
的转换即可。不过这样会损失性能。分析如下:
- 源类型:
Vec<u8>
是拥有所有权的,而&[u8]
没有。 - 目的类型:
Value::ShortStr/MidStr
只需要复制字符串内容(分别到Value和Rc内部),无需获取源数据的所有权。而Value::LongStr
需要获取Vec
的所有权。
2个源类型,2个目的类型,可得4种转换组合:
| Value::ShortStr/MidStr | Value::LongStr
---------+------------------------+-----------------
&[u8] | 1.复制字符串内容 | 2.创建Vec,申请内存
Vec<u8> | 3.复制字符串内容 | 4.转移所有权
如果我们直接实现Vec<u8>
,而对于&[8]
就先通过.to_vec()
创建Vec<u8>
再间接转换为Value
。那么对于上述第1种情况,本来只需要复制字符串内容即可,而通过.to_vec()
创建的Vec就浪费了。
如果我们直接实现&[8]
,而对于Vec<u8>
就先通过引用来转换为&[u8]
再间接转换为Value
。那么对于上述的第4种情况,就要先取引用转换为&u[8]
,然后再通过.to_vec()
创建Vec来获得所有权。多了一次无谓的创建。
所以为了效率,还是直接实现Vec<u8>
和&[u8]
到Value
的转换。不过,也许编译器会优化这些的,上述考虑都是瞎操心。但是,这可以帮助我们更深刻理解Vec<u8>
和&[u8]
这两个类型,和Rust所有权的概念。最终转换代码如下:
// convert &[u8], Vec<u8>, &str and String into Value
impl From<&[u8]> for Value {
fn from(v: &[u8]) -> Self {
vec_to_short_mid_str(v).unwrap_or(Value::LongStr(Rc::new(v.to_vec())))
}
}
impl From<&str> for Value {
fn from(s: &str) -> Self {
s.as_bytes().into() // &[u8]
}
}
impl From<Vec<u8>> for Value {
fn from(v: Vec<u8>) -> Self {
vec_to_short_mid_str(&v).unwrap_or(Value::LongStr(Rc::new(v)))
}
}
impl From<String> for Value {
fn from(s: String) -> Self {
s.into_bytes().into() // Vec<u8>
}
}
fn vec_to_short_mid_str(v: &[u8]) -> Option<Value> {
let len = v.len();
if len <= SHORT_STR_MAX {
let mut buf = [0; SHORT_STR_MAX];
buf[..len].copy_from_slice(&v);
Some(Value::ShortStr(len as u8, buf))
} else if len <= MID_STR_MAX {
let mut buf = [0; MID_STR_MAX];
buf[..len].copy_from_slice(&v);
Some(Value::MidStr(Rc::new((len as u8, buf))))
} else {
None
}
}
反向转换
之前已经实现了Value
到String
和&str
的转换。现在要增加到Vec<u8>
的转换。先列出代码:
impl<'a> From<&'a Value> for &'a [u8] {
fn from(v: &'a Value) -> Self {
match v {
Value::ShortStr(len, buf) => &buf[..*len as usize],
Value::MidStr(s) => &s.1[..s.0 as usize],
Value::LongStr(s) => s,
_ => panic!("invalid string Value"),
}
}
}
impl<'a> From<&'a Value> for &'a str {
fn from(v: &'a Value) -> Self {
std::str::from_utf8(v.into()).unwrap()
}
}
impl From<&Value> for String {
fn from(v: &Value) -> Self {
String::from_utf8_lossy(v.into()).to_string()
}
}
-
由于现在
Value
的3种字符串都是连续u8
序列了,所以转换为&[u8]
很简单。 -
到
&str
的转换,需要通过std::str::from_utf8()
处理刚才得到的&[u8]
类型。这个函数不涉及新的内存分配,只是验证下UTF-8编码的合法性。如果非法则失败,我们这里直接通过unwrap()
来panic。 -
到
String
的转换,通过String::from_utf8_lossy()
处理刚才得到的&[u8]
类型。这个函数也是验证UTF-8编码的合法性,但如果验证失败则会用一个特殊字符u+FFFD
来替换非法数据。但又不能直接修改原有数据,所以就会创建一个新的字符串。如果验证成功,则无需新创建数据,只返回原有数据的索引即可。这个函数的返回类型Cow
也是值得学习。
上述两个函数的不同处理方式,是由于&str
没有所有权,所以就不能创建新数据,而只能报错。可见所有权在Rust语言中非常关键。
Value
到String
的转换,目前的需求只是需要设置全局变量表时使用。可以看到这个转换总是会调用.to_string()
来创建一个新字符串。这个使得我们这一章对字符串的优化(主要是第1节)都失去了意义。后续在介绍到Lua的表结构后,会把全局变量表的索引类型从String
改为Value
,届时操作全局变量表就无需这个转换了。不过在其他地方还是会用到这个转换。
测试
至此,Lua字符串的功能更加完整了。本节开头的测试代码也可以正常输出了。通过escape还可以处理更多的方式,用如下测试代码验证:
print "tab:\thi" -- tab
print "\xE4\xBD\xA0\xE5\xA5\xBD" -- 你好
print "\xE4\xBD" -- invalid UTF-8
print "\72\101\108\108\111" -- Hello
print "null: \0." -- '\0'
总结
本章学习了Rust字符串类型,涉及到所有权、内存分配、Unicode和UTF-8编码等,深刻体会到了《Rust程序设计语言》中说的:Rust的字符串是复杂的,因为字符串本身是复杂的。通过这些学习,优化了Lua的字符串类型,还涉及到泛型和From
trait。虽然没有给我们的Lua解释器增加新特性,但也收获满满。
垃圾回收和Rc
在上面字符串定义小节中,我们使用了Rc
来定义Lua中的字符串类型,这就涉及到了一个重要话题:垃圾回收(Garbage Collection,即GC)。垃圾回收是一个非常通用且深入的话题,这里只介绍跟我们解释器实现相关的部分。
GC vs RC
Lua语言是一门自动管理内存的语言,通过垃圾回收来自动释放不在使用的内存。垃圾回收主要有两种实现途径:标记-清除(mark-and-sweep)和引用计数(reference counting,即RC)。有时候RC并不被认为是GC,所以狭义的GC特指前者,即标记-清除的方案。本节下面提到GC都是其狭义的含义。
相比而言,RC有两个缺点:
-
无法判断循环引用,进而导致内存泄漏。这点是很致命的。其实Rust中的
Rc
也有这个问题。Rust对此的策略是:由程序员来避免循环引用。 -
性能相比GC较差。这点倒不是绝对的,但貌似是主流观点。主要原因在于每次clone或drop操作,都需要更新引用计数器,进而影响CPU缓存。
基于以上原因,主流语言都不会采用RC方案,而是采用GC方案,包括Lua的官方实现版本。但是我们在本章字符串的定义中仍然选择了用Rc
,也就是采用RC方案,这是因为GC的两个缺点:
-
实现复杂。虽然实现一个简单的GC方案可能比较简单,但是如果要追求性能就非常难。很多语言(比如Python、Go、Lua)的GC也都是在多个版本持续改进。很难一步到位。
-
用Rust实现更复杂。本来Rust语言的最大特色就是自动内存管理。而GC方案这种手动内存管理的功能就跟Rust的这一特性相违背,会使得更加复杂。网络上有很多关于用Rust实现GC的讨论和项目(比如1、2、3、4等),明显已经超出Rust初学者的能力范围。
相比而言,如果采用RC方案,只要使用Rust中的Rc
即可,并不需要额外的内存管理。也就是说,完全可以避免垃圾回收的部分。
对于上述的RC方案的两个缺点的对策:一是循环引用,也就只能交由Lua程序员来避免循环引用了,不过我们在常见的情况中,比如一个表设置自身为元表,可以特殊处理以避免内存泄漏。二是性能,只能放弃这方面的追求。
采用RC方案来实现垃圾回收,是一个艰难的决定。因为这个项目一开始的目标就是完全遵守Lua手册,完全兼容官方实现版本。而采用RC方案后,就不能处理循环引用的场景,也就破坏了这个目标。怎奈能力有限,暂时只能如此,偷懒采用了RC方案。不过以后也可能会尝试GC方案。替换GC方案对我们这个解释器的其他部分影响很小。感兴趣的读者可以先自行尝试。
Rust中的Rc
好了,现在让我们离开垃圾回收这个话题,单纯讨论一下Rust中的Rc
。
在很多介绍Rust的文章中都提到要尽量避免使用Rc
,因为Rust语言特有的所有权机制不仅提供了编译期的自动内存管理,而且还会优化程序设计。其他支持指针的语言(比如C、C++)可以用指针随便指向,每个Object都可能被很多其他Object指向,整个程序就很容易形成混乱的Object Sea。而Rust的所有权机制强制要求Rust程序员在设计程序时,每个Object只能有唯一的所有者,整个程序形成一个清晰的Object Tree。大部分场景下,后者(Object Tree)显然是更优良的设计。然而Rc
打破了这个规范,整个程序又变成了混乱的Object Sea,所以要尽量避免使用Rc
。
我非常认可这个观点。我在实现这个Lua解释器项目的过程中,为了遵循Rust的所有权机制,必须要调整之前C语言的设计思路,而调整后的结果往往确实更加清晰。
从某个角度而言,Lua解释器这个项目可以分为两部分:
- 解释器本身,主要是词法分析和语法分析部分;
- 要解释执行的Lua代码,包括值、栈、字节码对应的预置执行流程等,也就是虚拟机部分。
对于前者,我们完全遵循Object Tree的设计要求,力求程序架构清晰。而对于后者,由于我们并不能限制Lua程序员编写的Lua代码(比如Lua代码可以很方便的实现图的数据结构,而这是明显不符合Object Tree的),所以对于这部分我们就不去追求Object Tree。哪怕采用GC来实现垃圾回收,必然会涉及大量unsafe代码,那相比Rc
更加违背Rust的设计意图了。
表
本章实现Lua中唯一的数据结构:表。依次完成表的定义、构造、和读写。
为了实现写操作,也就是对表成员的赋值,在语法分析阶段引入ExpDesc
这个关键的数据结构。
表的定义
Lua的表,对外表现为统一的散列表,其索引可以是数字、字符串、或者除了Nil和Nan以外的其他所有Value类型。但为了性能考虑,对于数字类型又有特殊的处理,即使用数组来存储连续数字索引的项。所以在实现里,表其实是由两部分组成:数组和散列表。为此我们定义表:
pub struct Table {
pub array: Vec<Value>,
pub map: HashMap<Value, Value>,
}
后续为了支持元表的特性,还会增加其他字段,这里暂且忽略。
Lua语言中的表(以及以后介绍的线程、UserData等)类型并不代表对象数据本身,而只是对象数据的引用,所有对表类型的操作都是操作的引用。比如表的赋值,只是拷贝了表的引用,而非“深拷贝”整个表的数据。所以在Value
中定义的表类型就不能是Table
,而必须是引用或者指针。在上一章定义字符串类型时,引入了Rc
并讨论了引用和指针。基于同样的原因,这次也采用指针Rc
对Table
进行封装。除此之外,这里还需要引入RefCell
以提供内部可变性。综上,表类型定义如下:
pub enum Value {
Table(Rc<RefCell<Table>>),
Table
中散列表部分的定义是HashMap<Value, Value>
,即索引和值的类型都是Value
。而HashMap
的索引类型是要求实现Eq
和Hash
这两个trait的。这也好理解,散列表的工作原理就是在插入和查找时,通过计算索引的哈希值(Hash
)来快速定位,并通过比较索引(Eq
)来处理哈希冲突。接下来就实现这两个trait。
Eq
trait
我们之前已经为Value
实现了PartialEq
trait,即比较两个Value是否相等,或者说可以对Value类型使用==
操作符。而Eq
的要求更高,是在PartialEq
的基础上再要求自反性,即要求对于此类型的任意值x
,都满足x==x
。大部分情况下都是满足自反性的,但也有反例,比如浮点数中,Nan != Nan
,所以浮点数类型虽然实现了PartialEq
但并没有实现Eq
。我们的Value
类型中虽然包括了浮点数类型,但由于Lua语言禁止使用Nan作为索引(具体说来,我们会在虚拟机执行表插入操作时,判断索引是否为Nan),所以可以认为Value
类型满足自反性。对于满足自反性的类型,只要告诉Rust满足即可,而不需要特别的实现:
impl Eq for Value {}
Hash
trait
Rust中的大部分基础类型都已经实现了Hash
trait,我们这里只需要针对每种类型按照语义调用.hash()
即可。
实现Hash
trait的代码如下:
impl Hash for Value {
fn hash<H: Hasher>(&self, state: &mut H) {
match self {
Value::Nil => (),
Value::Boolean(b) => b.hash(state),
Value::Integer(i) => i.hash(state),
Value::Float(f) => // TODO try to convert to integer
unsafe {
mem::transmute::<f64, i64>(*f).hash(state)
}
Value::ShortStr(len, buf) => buf[..*len as usize].hash(state),
Value::MidStr(s) => s.1[..s.0 as usize].hash(state),
Value::LongStr(s) => s.hash(state),
Value::Table(t) => Rc::as_ptr(t).hash(state),
Value::Function(f) => (*f as *const usize).hash(state),
}
}
}
很多类型,如bool
、Rc
的指针等,都已经实现了哈希方法,但浮点类型f64
并没有,原因也是因为Nan
,这里有详细的讨论。在Eq
trait一节已经说明,Lua禁止使用Nan作为索引,我们就可以忽略Nan而默认浮点类型可以做哈希。一个方法是把浮点数看做是一块内存,来做哈希。我们这里选择了转换为更简单的整型i64
来做哈希。
这个转换用到标准库的mem::transmute()
函数,而这个函数是unsafe
的。我们这里可以明确知道这个转换是安全的(真的吗?),所以可以放心使用这个unsafe
。
刚学Rust语言时,看到一些库的描述中明确说明“不含unsafe代码”,就感觉这是一个很自豪的特征。于是在开始这个项目时,我也希望不用任何unsafe代码。不过现在看来unsafe并不是洪水猛兽,也许类似C语言里的
goto
,只要使用合理,就可以带来很大便利。
对于字符串类型,需要对字符串内容计算hash。对于表类型,只需要对指针计算hash,而忽略表的内容。这是因为字符串的比较是内容的比较,而表的比较就是对表引用的比较。
Debug
和Display
trait
因为Rust的match是穷尽的,所以编译器会提醒我们在Debug
trait里也增加表Table类型:
Value::Table(t) => {
let t = t.borrow();
write!(f, "table:{}:{}", t.array.len(), t.map.len())
}
代码块中一共2行。第1行用到borrow()
,就是对RefCell
类型的动态引用,确保没有其他可变引用。相对于Rust语言中大部分的编译期间的检查,这种动态引用会带来额外的运行时开销。
Lua的官方实现里,表类型的输出格式是表的地址,可以用来做简单的调试。我们这里增加了表中数组和散列表部分的长度,更加方便调试。另外,我们为Value实现Display
trait,用于print
的正式的输出:
impl fmt::Display for Value {
fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
match self {
Value::Table(t) => write!(f, "table: {:?}", Rc::as_ptr(t)),
表的构造
本节介绍表的构造。表的构造支持3种类型:列表式、记录式、和通用式。分别见如下示例代码:
local key = "kkk"
print { 100, 200, 300; -- list style
x="hello", y="world"; -- record style
[key]="vvv"; -- general style
}
先来看下Lua官方实现中是如何处理表的构造的。luac的输出如下:
$ luac -l test_lua/table.lua
main <test_lua/table.lua:0,0> (14 instructions at 0x600001820080)
0+ params, 6 slots, 1 upvalue, 1 local, 7 constants, 0 functions
1 [1] VARARGPREP 0
2 [1] LOADK 0 0 ; "kkk"
3 [2] GETTABUP 1 0 1 ; _ENV "print"
4 [2] NEWTABLE 2 3 3 ; 3
5 [2] EXTRAARG 0
6 [2] LOADI 3 100
7 [2] LOADI 4 200
8 [2] LOADI 5 300
9 [3] SETFIELD 2 2 3k ; "x" "hello"
10 [3] SETFIELD 2 4 5k ; "y" "world"
11 [4] SETTABLE 2 0 6k ; "vvv"
12 [5] SETLIST 2 3 0
13 [2] CALL 1 2 1 ; 1 in 0 out
14 [5] RETURN 1 1 1 ; 0 out
跟表的构造相关的字节码是第4到第12行:
- 第4行,NEWTABLE,用以创建一个表。一共3个参数,分别是新表在栈上位置,数组部分长度,和散列表部分长度。
- 第5行,看不懂,暂时忽略。
- 第6,7,8行,三个LOADI,分别加载数组部分的值100,200,300到栈上,供后面使用。
- 第9,10行,字节码SETFIELD,分别向散列表部分插入x和y。
- 第11行,字节码SETTABLE,向散列表部分插入key。
- 第12行,SETLIST,把上述第6-8行加载到栈上的数据,一次性插入到数组中。
每个字节码的执行对应的栈情况如下:
| | /<--- 9.SETFILED
+-------+ |<---10.SETFILED
4.NEWTABLE | { } |<----+--+<---11.SETTABLE
+-------+ |
6.LOADI | 100 |---->|
+-------+ |12.SETLIST
7.LOADI | 200 |---->|
+-------+ |
8.LOADI | 300 |---->/
+-------+
| |
首先可以看到,表的构造是在虚拟机执行过程中,通过插入逐个成员,实时构造出来的。这一点有点出乎我的意料(虽然之前并没有想过应该是什么过程)。我以前写过类似如下的代码:
local function day_of_week(day)
local days = {
"Sunday"=0, "Monday"=1, "Tuesday"=2,
"Wednesday"=3, "Thursday"=4, "Friday"=5,
"Saturday"=6,
}
return days[day]
end
代码中把days
放在函数内部是很自然的,因为这个变量只在这个函数内部使用。但是根据上面表的构造的实现,每次调用这个函数都会实时构建这个表,也就是把这7个日期插入到表里,这个代价就有点大了(需要8次字符串hash和1次字符串比较,至少需要9条字节码,还有创建表带来的不止一次的内存分配)。感觉上甚至不如逐个星期名比较来的快(平均需要4次字符串比较,每次比较2条字节码一共8条)。更好的方法是把days
这个变量放到函数外面(就是以后介绍的UpValue),每次进入函数就不需要构造表,但这样就把一个函数内部变量放到外面,不是好的编程习惯。另外一种做法(Lua的官方实现并不支持)就是对于这种全部由常量组成的表,在解析阶段就构建好,后续只要引用即可,但这么做会带来一些复杂性,后续看有没有精力完成。
回到表的构造,对于数组部分和散列表部分的处理方式是不同的:
- 数组部分,是先把值依次加载到栈上,最后一次性插入到数组中;
- 散列表部分,是每次直接插入到散列表中。
一个是批量的一个是逐次的。采用不同方式的原因猜测如下:
-
数组部分如果也逐一插入,那么插入某些类型的表达式就需要2条字节码。比如对于全局变量,就需要先用
GetGlobal
字节码加载到栈上,然后再用一个类似AppendTable
的字节码插入到数组中,那么插入N个值最多就需要2N条字节码。如果批量插入,N个值就只需要N+1条字节码。所以批量插入更适合数组部分。 -
而对于散列表部分,每条数据有key和value两个值,如果也采用批量的方式,把两个值都加载到栈上就需要2条字节码。而如果是逐个插入,很多情况下只需要1条字节码即可。比如上述示例代码中的后面3项都只分别对应1条字节码。这么一来,批量的方式反而需要更多字节码了,所以逐个插入更适合散列表部分。
这一节按照Lua官方实现方法,对应增加下面等4个字节码:
pub enum ByteCode {
NewTable(u8, u8, u8),
SetTable(u8, u8, u8), // key在栈上
SetField(u8, u8, u8), // key是字符串常量
SetList(u8, u8),
不过中间的两个字节码并不支持值是常量的情况,只支持栈上索引。我们在后面小节会加入对常量的优化。
语法分析
在介绍完表构造的原理后,现在来看具体实现。先看语法分析部分。代码很长,但都只是依照上面的介绍,逻辑很简单。把代码贴在这里仅作参考,没兴趣的读者可以跳过这里。
fn table_constructor(&mut self, dst: usize) {
let table = dst as u8;
let inew = self.byte_codes.len();
self.byte_codes.push(ByteCode::NewTable(table, 0, 0)); // 新建表
let mut narray = 0;
let mut nmap = 0;
let mut sp = dst + 1;
loop {
match self.lex.peek() {
Token::CurlyR => { // `}`
self.lex.next();
break;
}
Token::SqurL => { // `[` exp `]` `=` exp,通用式
nmap += 1;
self.lex.next();
self.load_exp(sp); // key
self.lex.expect(Token::SqurR); // `]`
self.lex.expect(Token::Assign); // `=`
self.load_exp(sp + 1); // value
self.byte_codes.push(ByteCode::SetTable(table, sp as u8, sp as u8 + 1));
},
Token::Name(_) => { // Name `=` exp | Name
nmap += 1;
let key = if let Token::Name(key) = self.lex.next() {
self.add_const(key)
};
if self.lex.peek() == &Token::Assign { // Name `=` exp,记录式
self.lex.next();
self.load_exp(sp); // value
self.byte_codes.push(ByteCode::SetField(table, key as u8, sp as u8));
} else {
narray += 1;
self.load_exp_with_ahead(sp, Token::Name(key)); // exp,列表式
sp += 1;
if sp - (dst + 1) > 50 { // too many, reset it
self.byte_codes.push(ByteCode::SetList(table, (sp - (dst + 1)) as u8));
sp = dst + 1;
}
}
},
_ => { // exp,列表式
narray += 1;
self.load_exp(sp);
sp += 1;
if sp - (dst + 1) > 50 { // too many, reset it
self.byte_codes.push(ByteCode::SetList(table, (sp - (dst + 1)) as u8));
sp = dst + 1;
}
},
}
match self.lex.next() {
Token::SemiColon | Token::Comma => (),
Token::CurlyR => break,
t => panic!("invalid table {t:?}"),
}
}
if sp > dst + 1 {
self.byte_codes.push(ByteCode::SetList(table, (sp - (dst + 1)) as u8));
}
// reset narray and nmap
self.byte_codes[inew] = ByteCode::NewTable(table, narray, nmap);
}
函数开头生成NewTable
字节码,但由于目前还不知道数组和散列表的成员数量,所以后面两个参数暂时填0。并记下这个字节码的位置,在函数最后修改参数。
中间循环就是遍历表的所有成员。一共3种语法类型:
-
通用式,
[ exp ] = exp
,key和value都是表达式,通过load_exp()
函数分别加载到栈的sp和sp+1的位置,然后生成SetTable
字节码; -
记录式,
Name = exp
,key是Name即字符串常量,加入到常量表中,value是表达式,最后生成SetField
字节码。这里有个地方跟Rust的所有权机制相关,就是通过match self.lex.peek()
的模式分支Token::Name(key)
匹配拿到的key
是不能直接通过add_const(*key)
添加到常量表中的。这是因为peek()
返回的不是Token
本身,而是Token
的引用,这个引用是self.lex.peek()
返回的,所以关联的self.lex
和self
也都处于被引用的状态;而调用self.add_const()
也是对self
的mut引用,就违反了引用规则。正确的做法是放弃peek()
的返回值,而是调用self.lex.next()
返回Token并重新匹配。这时Rust的检查显得过于严格,因为self.lex.peek()
返回的Token引用并不会影响self.add_const()
。应该是Rust没有能力确定这两者间没有影响。 -
列表式,
exp
,加载到栈的sp
位置,并更新sp
,以待最后的SetList
执行插入。但不能无限向栈上加载数据,因为这会导致栈一直重分配内存,所以如果当前栈上数据超过50,就生成一次SetList
字节码,清理栈。
这里需要说明的一点是,在解析到Name
的时候,既可能是记录式也可能是列表式,需要再peek下一个Token才能区分两者:如果下一个Token是=
则是记录式,否则是列表式。这里的问题是,Name
已经是peek的了,而词法分析由于使用了Peekable
所以只支持peek一个Token,于是就只能修改表达式解析的函数load_exp()
,支持一个提前读取的Token,为此新增load_exp_with_ahead()
函数。整个Lua语法中,只有这一个地方需要向前看两个Token。
这种需要向前看两个Token才能确定表达式的行为,不知道是不是叫LL(2)?
虚拟机执行
下面是新增的4个字节码的虚拟机执行代码,同样很简单,可以跳过:
ByteCode::NewTable(dst, narray, nmap) => {
let table = Table::new(narray as usize, nmap as usize);
self.set_stack(dst, Value::Table(Rc::new(RefCell::new(table))));
}
ByteCode::SetTable(table, key, value) => {
let key = self.stack[key as usize].clone();
let value = self.stack[value as usize].clone();
if let Value::Table(table) = &self.stack[table as usize] {
table.borrow_mut().map.insert(key, value);
} else {
panic!("not table");
}
}
ByteCode::SetField(table, key, value) => {
let key = proto.constants[key as usize].clone();
let value = self.stack[value as usize].clone();
if let Value::Table(table) = &self.stack[table as usize] {
table.borrow_mut().map.insert(key, value);
} else {
panic!("not table");
}
}
ByteCode::SetList(table, n) => {
let ivalue = table as usize + 1;
if let Value::Table(table) = self.stack[table as usize].clone() {
let values = self.stack.drain(ivalue .. ivalue + n as usize);
table.borrow_mut().array.extend(values);
} else {
panic!("not table");
}
}
第一个字节码NewTable
很简单,不做介绍。后面两个字节码SetTable
和SetField
类似,都需要通过borrow_mut()
来获取表的mut引用。最后的字节码SetList
再次遇到Rust的所有权问题,需要对栈上的表显式调用clone()
函数,创建一个独立的表的指针。如果不调用clone()
的话,那么第一行if let
语句匹配得到的table
变量是对栈上成员的引用,也就是对栈的引用,并且这个引用还需要持续到第三行,所以不能提前释放;第二行调用stack.drain()
是需要获取栈的可变引用,就跟前面第一行table
变量获取的引用出现冲突了。所以需要clone()
出一个独立的表的指针,这样第一行匹配的table
变量就只是对表的引用,而脱离了对栈的引用,从而避免了冲突。
这里强制的clone()
增加了性能消耗,但也避免了潜在bug。比如这个table所在的栈位置,是可能被后续的stack.drain()
包括的,从而地址失效,那么后续第三行向table中插入数据的操作就会异常。当然,在SetList
这个场景下,语法分析会保证stack.drain()
清理的栈位置不包括table,但Rust编译器并不知道,而且也不能保证以后不会包括。所以这里的clone()
彻底杜绝了这个隐患,是值得的。
至此,我们完成了表的构造,后面几节介绍表的读写。
ExpDesc概念
在介绍表的读写之前,本节先引入ExpDesc
这个语法分析的核心数据结构。
表构造的问题
上一节实现的表的构造存在一个性能问题。比如,对于通用式[ exp ] = exp
,会依次把key和value对应的表达式通过load_exp()
函数加载到栈顶,作为临时变量;然后再生成SetTable
字节码,包含栈顶的两个临时变量的索引。代码如下:
Token::SqurL => { // `[` exp `]` `=` exp
nmap += 1;
self.lex.next();
self.load_exp(sp); // 加载key到栈顶
self.lex.expect(Token::SqurR); // `]`
self.lex.expect(Token::Assign); // `=`
self.load_exp(sp + 1); // 加载value到栈顶
self.byte_codes.push(ByteCode::SetTable(table, sp as u8, sp as u8 + 1));
},
这就造成了浪费,因为某些表达式类型,比如局部变量、临时变量和常量,都可以直接引用,而无需加载到栈上。比如下面的Lua代码:
local k = 'kkk'
local v = 'vvv'
local t = { [k] = v }
按照现在的实现方式,运行时的栈和字节码序列如下:
+---------+
0 | "kkk" |---\[1] Move 3 0
+---------+ |
1 | "vvv" |---|-\[2] Move 4 1
+---------+ | |
2 | { } |<--|-|---------\
+---------+ | | |
3 | "kkk" |<--/ | --\ |
+---------+ | >--/[3] SetTable 2 3 4
4 | "vvv" |<----/ --/
+---------+
| |
而实际上这两个临时变量都不需要,而是只需要1条字节码即可:SetTable 2 0 1
,其中的3个参数分别是表、key和value在栈上的索引。这也是Lua官方实现的方式,即尽量直接引用索引,而避免无谓的临时变量。运行时的栈和字节码序列如下:
+---------+
0 | "kkk" |---\
+---------+ >--\[1] SetTable 2 0 1
1 | "vvv" |---/ |
+---------+ |
2 | { } |<------/
+---------+
| |
这两种方式(是否引入临时变量)对应虚拟机的两种类型,基于栈的和基于寄存器的。
基于栈和基于寄存器
先罗列一下上面例子中,现在实现方式的字节码:
Move 3 0 # 把k加载到3位置。此时3是栈顶
Move 4 1 # 把v加载到4位置。此时4是栈顶
SetTable 2 3 4
在现在的实现方式中,我们可以确定key和value是要加载到栈顶,所以两条Move
字节码中的第一个参数(即目标地址)是可以省略的;另外我们也可以确定在设置表时,key和value一定在栈顶,所以SetTable
字节码的后面两个参数也是可以省略的。所以字节码序列可以简化如下:
Push 0 # 把k加载到栈顶
Push 1 # 把v加载到栈顶
SetTable 2 # 把栈顶的两个值作为key和value,设置给2位置的表
这种通过栈顶来操作参数的方式,称为基于栈的虚拟机。很多脚本语言如Java、Python等的虚拟机都是基于栈的。而在字节码中直接索引参数的方式(比如SetTable 2 0 1
),称为基于寄存器的虚拟机。这里的“寄存器”并不是计算机CPU中的寄存器,而是一个虚拟的概念,比如在我们的Lua解释器中,就是用栈和常量表来实现的寄存器。Lua是第一个(官方的虚拟机)基于寄存器的主流语言。
上面是通过表的写语句作为例子。下面再介绍一个更容易理解的例子,即加法语句(虽然我们现在还没有实现加法,但确实更容易理解)。对于如下Lua代码:
local r
local a = 1
local b = 2
r = a + b
基于栈的虚拟机生成的字节码可能如下:
Push 1 # 把a加载到栈顶
Push 2 # 把b加载到栈顶
Add # 把栈顶的2个数弹出并相加,并把结果压到栈顶
Pop 0 # 把栈顶的结果弹出,并赋值给r
基于寄存器的虚拟机生成的字节码可能如下:
Add 0 1 2
可以直观地看到,基于寄存器虚拟机的字节码序列,条数少,但每条字节码更长。一般认为基于寄存器的字节码性能略优,但实现较复杂。这两种类型更详细的说明和比较已经超出本文讨论范围,并且我也没能力介绍。我之所以在这个项目里选择了基于寄存器,仅仅是因为Lua官方实现就是这么做的。我甚至是项目写了一部分后,才知道有这两种方式。接下来只要继续按照基于寄存器的方式即可,而不去纠结基于栈的方式。
需要说明的一点是,基于寄存器的方式,也只是尽量避免使用栈顶的临时变量。在必要的时候也是需要的。如何选择寄存器还是临时变量,这一点在后面会详细介绍。
中介ExpDesc
既然我们要按照基于寄存器方式,但为什么上一节表的构造里,要把key和value都加载到栈顶,使用基于栈的方式呢?是因为我们目前还无法实现基于寄存器的方式。现在load_exp()
函数在遇到Token后,是直接生成字节码加载到栈的指定位置的。代码如下:
fn load_exp(&mut self, dst: usize) {
let code = match self.lex.next() {
Token::Nil => ByteCode::LoadNil(dst as u8),
Token::True => ByteCode::LoadBool(dst as u8, true),
Token::False => ByteCode::LoadBool(dst as u8, false),
Token::Float(f) => self.load_const(dst, f),
Token::String(s) => self.load_const(dst, s),
// 省略其他Token
};
self.byte_codes.push(code);
}
所以在解析上面的表的通用式写语句时,遇到key和value的表达式时,就立即加载到栈顶了,就成为了基于栈的方式。
而如果要实现基于寄存器方式,生成SetTable 2 0 1
这样的字节码,那在遇到key或value的表达式时,就不能立即生成字节码,而是需要暂时保存起来,等待时机成熟时,再按情况处理。还是用本节开头的Lua代码为例:
local k = 'kkk'
local v = 'vvv'
local t = { [k] = v }
第3行的表构造语句,解析过程如下:
[
,确定为通用式;k
,作为Name,先确定是局部变量,索引是0,然后作为key保存起来;]
和=
,预期中;v
,作为Name,也确定是局部变量,索引是1,然后作为value保存起来;- 此时,一条初始化语句完成,之前保存的key和value的索引分别是0和1,于是就可以生成字节码
SetTable 2 0 1
。
这里的关键是“把key/value保存起来”。我们现在就要增加这种暂存的机制。一个解决方法是直接保存读到的Token。比如这个例子中,key和value分别保存为Token::Name("k")
和Token::Name("v")
。但这样做有几个问题:
- 一个小问题是Name可能是局部变量或者全局变量,我们后续会看到对这两种变量的处理方式是不一样的,而用
Token::Name
无法区分这两个类型。 - 稍大的问题是,有的表达式是更复杂的,不止包含一个Token,比如
t.k
、a+1
、foo()
等,这些不能用一个Token来代表。这一章支持表,就要支持t.k
甚至t.k.x.y
这种表达式语句。 - 更大的问题是,表的读语句
t.k
至少还可以用基于栈的方式来实现,但表的写语句就不行了。比如t.k = 1
,就是赋值语句的左边部分,在解析时,必须要先保存起来,然后再解析右值表达式,最后再执行赋值。要支持表的写语句,就必须先增加这种暂存的机制。这也是在接下来支持表的读写功能之前,必须要先插入这一节的原因。
所以,我们需要一种新类型来保存中间结果。为此我们引入ExpDesc
(名字来自Lua官方实现代码):
#[derive(Debug, PartialEq)]
enum ExpDesc {
Nil,
Boolean(bool),
Integer(i64),
Float(f64),
String(Vec<u8>),
Local(usize), // on stack, including local and temprary variables
Global(usize), // global variable
}
现在看上去其类型,就是表达式目前支持的类型,只是把Token::Name
拆成了Local
和Global
,为此引入这个类型有点小题大做。但在下一节支持表的读写时,以及后续的运算表达式、条件跳转等语句时,ExpDesc就会大显身手!
原来的解析过程是从Token直接生成字节码:
Token::Integer -> ByteCode::LoadInt
Token::String -> ByteCode::LoadConst
Token::Name -> ByteCode::Move | ByteCode::GetGlobal
...
现在中间增加了ExpDesc这层,解析过程就变成:
Token::Integer -> ExpDesc::Integer -> ByteCode::LoadInt
Token::String -> ExpDesc::String -> ByteCode::LoadConst
Token::Name -> ExpDesc::Local -> ByteCode::Move
Token::Name -> ExpDesc::Global -> ByteCode::GetGlobal
...
语义分析和ExpDesc
ExpDesc是非常重要的,这里换个角度再介绍一次。
第1.1节基础的编译原理中介绍了通用的编译流程:
词法分析 语法分析 语义分析
字符流 --------> Token流 --------> 语法树 --------> 中间代码 ...
我们仍然用上面的加法代码来举例:
local r
local a = 1
local b = 2
r = a + b
按照上述通用编译流程,对于最后一行的加法语句,语法分析会得到语法树:
|
V
+
/ \
a b
然后在语义分析时,先看到+
,得知这是一条加法的语句,于是可以很直接地生成字节码:Add ? 1 2
。其中?
是加法的目标地址,由赋值语句处理,这里忽略;1
和2
分别是两个加数的栈索引。
但我们目前的做法,也是Lua官方实现的做法,是省略了“语义分析”这一步,从语法分析直接生成中间代码,边分析边生成代码。那么在语法分析时,就不能像上述语义分析那样有全局的视角。比如对于加法语句a+b
,在读到a
时,还不知道这是一条加法语句,只能先存起来。读到+
时才确定是加法语句,然后再读第二个加数,然后生成字节码。我们为此引入了ExpDesc
这个中间层。所以ExpDesc就相当于是通用流程中的“语法树”的作用。只不过语法树是全局的,而ExpDesc是局部的,而且是最小粒度的局部。
词法分析 语法分析
字符流 --------> Token流 ----(ExpDesc)---> 中间代码 ...
可以直观地看到,Lua的这种方式省去了语义分析步骤,速度应该略快,但由于没有全局视角,所以实现相对复杂。这两种方式更详细的说明和对比已经超出了本文的讨论范围。我们选择按照Lua官方实现的方式,选择语法分析直接生成字节码的方式。
小结
本节引入了ExpDesc的概念,并介绍了其作用和角色。下一节,基于ExpDesc对现有代码做改造。
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
。我在第一遍看这本书时,被里面无数的新概念搞得晕头转向,所以完全没有印象看过这句话,即便看到了也理解不了记不住。我这个项目写了一多半时,把这本书又完整地看了一遍,这次对里面的大部分概念理解都很顺畅了,对于函数指针这种微言大义的介绍也能注意到了。而且刚好可以用到,多么美好的发现!
表的读写和BNF
在前面两节引入ExpDesc并对现有语法分析进行改造之后,本节实现表的读写。
Lua中表的索引支持两种方式,分别举例如下:t["k"]
和t.k
,其中后者是前者的特殊形式。表的读写操作都需要用到表的索引。需要在ExpDesc中增加表索引的类型。
表的读写操作本身并不复杂,但会使得其他语句突然变得复杂起来:
-
表的读操作可能有连续多级,比如
t.x.y
,那么在解析表达式时无法立即判断结束,而需要peek下一个Token来判断。 -
表的写操作,即赋值语句。现在的赋值语句只支持“变量”的赋值,即左值只支持一个Token::Name。如要增加表索引的支持,对左值的处理要重新实现。只解析一个Token不行,而是要解析完一个左值才行。那怎么才算一个完整的左值?比如并不是所有的表达式都可以作为左值,比如函数调用或者表构造都不行。
-
之前区分赋值语句和函数调用语句,是根据第2个Token,如果是等号
=
就是赋值语句。现在要支持表的写操作,比如t.k = 123
,那么第2个Token是点.
,而不是等号=
,但仍然是赋值语句。之前的判断方法就失效了。那有什么新的办法来区分赋值语句和函数调用语句呢?
第一个读操作问题好解决。后面两个写操作相关的问题就很难了,我们现在无法准确地回答,只能猜测答案。这就引申出一个更大的问题,就是之前的语法分析都是靠猜的!比如局部变量的定义语句的格式等,都是根据使用Lua语言的经验猜测的,而不能保证其是否准确、完整。但之前都比较简单,可以猜个大概。另外为了不打断整个项目的节奏,也就没有深究这个问题。现在要引入表的读写,语句变得复杂了,靠猜不能继续混下去了,有必要引入形式化的语法描述了。
BNF
Lua手册的最后一章名字就叫:Lua的完整语法,内容主要是一套BNF描述。我们并不需要知道BNF这个术语的含义,只需要知道这是一种形式化的语法描述方式,在这里就可以完整且准确地描述Lua语法。BNF本身的语法规则也很简单,大部分一目了然,这里只列两个:
{A}
代表0个或多个A[A]
代表可选的1个A
Lua的代码段称为chunk
,所以以chunk
的定义为入口,列出几条描述:
chunk ::= block
block ::= {stat} [retstat]
stat ::= ‘;’ |
varlist ‘=’ explist |
functioncall |
label |
break |
goto Name |
do block end |
while exp do block end |
repeat block until exp |
if exp then block {elseif exp then block} [else block] end |
for Name ‘=’ exp ‘,’ exp [‘,’ exp] do block end |
for namelist in explist do block end |
function funcname funcbody |
local function Name funcbody |
local attnamelist [‘=’ explist]
由这些规则可以得到:一个chunk
包含一个block
。一个block
包含0个或多个stat
和一个可选的retstat
。一个stat
有很多种类型的语句。这其中我们已经实现了functioncall
和local
两个语句,后续把剩下的类型逐个实现就完成了Lua的全部语法(虽然离完整的Lua语言还差很远)。
我不太理解这里
chunk
和block
的区别是什么?为什么要单独列两个?
就是说,我们后续就按照这套规范来实现解释器,再也不用靠猜的了!挑几条跟我们之前的对比下,比如局部变量定义语句,就可以发现应该要支持多变量,多初始化表达式,甚至没有初始化表达式。这就看出来我们之前的语句解析是非常不完善的。这节后面会根据BNF来完善我们已经支持的语句。当下先从中找出跟表索引相关的规则:
var ::= Name | prefixexp ‘[’ exp ‘]’ | prefixexp ‘.’ Name
exp ::= nil | false | true | Numeral | LiteralString | ‘...’ | functiondef |
prefixexp | tableconstructor | exp binop exp | unop exp
prefixexp ::= var | functioncall | ‘(’ exp ‘)’
functioncall ::= prefixexp args | prefixexp ‘:’ Name args
一眼看上去有些复杂。以var
为例分析下。这里var
推导出3种情况,第一个Name
是简单变量,后面两个就是表索引,分别是通用方式和字符串索引语法糖。涉及到了prefixexp
和exp
。其中exp
跟我们目前实现的exp()
函数很类似了,只是我们还缺少了一些情况,也是需要后续补上的。另外Name
是直接在exp()
函数里的,现在要挪到var
里。
消除左递归
这里有个大问题,上述3条规则是有递归引用的。比如:
var
引用了prefixexp
而后者又引用了var
;exp
引用了prefixexp
而后者又引用了exp
。
但这两个例子又有本质区别。
对于第一个例子,带入var后展开下,就是
prefixexp ::= Name | prefixexp ‘[’ exp ‘]’ | prefixexp ‘.’ Name | prefixexp args | prefixexp ‘:’ Name args | ‘(’ exp ‘)’
问题在于推导规则的第2和第3项也是prefixexp
开头的。那在语法分析时,比如读到一个Name,即可以匹配第1项,也可以匹配第2和第3项,这就无法判断应该选择哪条规则了。这就很头疼,我当时花了两天时间在这个问题上,想了各种土办法也没有解决。后来上网搜索发现了“消除左递归”这个概念,才隐约回忆起来这是编译原理课程上的必考题目。而且消除是有标准方法的:对于包含左递归的规则,都可以表达为如下形式:
A := Aα | β
然后就可以改写为如下形式:
A := βA’
A’ := αA’ | ε
其中ε
是未匹配。这样就消除了左递归。拿上面的prefixexp
举例,先套用上述标准形式,可得:
α = ‘[’ exp ‘]’ | ‘.’ Name | args | ‘:’ Name args
β = Name | ‘(’ exp ‘)’
然后再带入上述改写公式,可得:
prefixexp := ( Name | ‘(’ exp ‘)’ ) A’
A’ := ( ‘[’ exp ‘]’ | ‘.’ Name | args | ‘:’ Name args ) A’ | ε
这样我们就得到了没有左递归的规则。
而本小节最开始的第二个例子,关于exp
的,虽然也有递归引用,但不是“左”递归,所以没有这个问题。
表的读操作和prefixexp
使用BNF规则的好处是,不需要想着Lua的语法,只需要照着规则实现即可。
得到上述BNF规则后,就可以完成prefixexp的解析:
fn prefixexp(&mut self, ahead: Token) -> ExpDesc {
let sp0 = self.sp;
// beta
let mut desc = match ahead {
Token::Name(name) => self.simple_name(name),
Token::ParL => { // `(` exp `)`
let desc = self.exp();
self.lex.expect(Token::ParR);
desc
}
t => panic!("invalid prefixexp {t:?}"),
};
// A' = alpha A'
loop {
match self.lex.peek() {
Token::SqurL => { // `[` exp `]`
self.lex.next();
let itable = self.discharge_if_need(sp0, desc);
desc = match self.exp() {
ExpDesc::String(s) => ExpDesc::IndexField(itable, self.add_const(s)),
ExpDesc::Integer(i) if u8::try_from(i).is_ok() => ExpDesc::IndexInt(itable, u8::try_from(i).unwrap()),
key => ExpDesc::Index(itable, self.discharge_top(key)),
};
self.lex.expect(Token::SqurR);
}
Token::Dot => { // .Name
self.lex.next();
let name = self.read_name();
let itable = self.discharge_if_need(sp0, desc);
desc = ExpDesc::IndexField(itable, self.add_const(name));
}
Token::Colon => todo!("args"), // :Name args
Token::ParL | Token::CurlyL | Token::String(_) => { // args
self.discharge(sp0, desc);
desc = self.args();
}
_ => { // Epsilon
return desc;
}
}
}
}
代码第一段对应上述的β
,即Name | ‘(’ exp ‘)’
。
第二段的循环对应的是上述的A’ := αA’ | ε
,如果匹配的是α
部分即‘[’ exp ‘]’ | ‘.’ Name | args | ‘:’ Name args
,那么解析完后循环继续;如果没有匹配,则对应ε
,就退出循环。这里这个循环支持了很多连续的操作,比如t.f()
,就是一个表索引接一个函数调用。或者更多的连续操作如t.t.t.k
和f()()()
。如果按照之前章节的土方法,想到一个功能就做一个功能,要支持这种连续操作就很难了,很难实现也很难想到。但按照BNF来,就可以正确且完整地实现。
对应表的构造时的3类字节码,即key为栈上变量、字符串常量和小整型,这里也新增3个ExpDesc的类型,分别为Index
、IndexField
和IndexInt
。在discharge时,新增3个对应的字节码,GetTable
、GetField
和GetInt
。这样自然而然就解决了本节开头的第一个问题,也就是实现了表的读操作,而且是正确且完整地实现!
依照BNF规则来编码的另一个特点是,就只能看懂每个匹配分支内部的处理逻辑了,而看不清楚每个分支间的整体关系了。这就像解物理应用题,首先分析物理原理,列出方程,其中的每一项都有对应的物理含义;但是之后在求解方程时,具体求解步骤就已经完全脱离了物理对应关系,就是一个数学工具。
上面列出了prefixexp()
函数,另外exp()
函数的实现也类似,这里省略。
表的写操作和赋值语句
在按照BNF实现了prefixexp和exp后,就可以解决本节开头的关于表的写操作的问题。按照BNF重新实现赋值语句,即可解决问题。这次要实现的是“完整的赋值语句”,终于不用再强调是“变量赋值语句”了。
赋值语句虽然看上去跟局部变量定义语句很像,但实际完全不一样,要复杂的多。BNF中赋值语句定义如下:
varlist ‘=’ explist
varlist ::= var {‘,’ var}
var ::= Name | prefixexp ‘[’ exp ‘]’ | prefixexp ‘.’ Name
赋值符=
左边是var
列表。var
展开为3个种类。第一个Name
是变量,目前支持局部变量和全局变量,后续引入闭包后还会支持upvalue。后面两个都是表索引。由此可以看到赋值只支持这几种,而其他类型比如函数调用就不支持赋值。再看=
右边,是表达式列表,直接使用已经完成的exp()
函数解析。
看完赋值语句的BNF语法规则,还有3个语义规则。
首先,=
左右两边的变量数跟表达式数不相等时的做法:
- 如果两者相等,则逐一赋值;
- 如果变量数小于表达式数,则变量列表跟对应的表达式列表逐一赋值,而额外多出的表达式忽略;
- 如果变量数大于表达式数,则表达式列表跟对应的变量列表逐一赋值,而额外多出的变量被赋值为
nil
。
其次,如果=
右边最后一个表达式有多个值(比如函数调用和可变参数),则会尽量展开。不过我们现在还不支持这两个类型,所以暂时忽略这种情况。
最后,先对=
右边的所有表达式求值,然后再赋值。而不是边求值边赋值。比如下面Lua语句,应该先对右边两个表达式b
和a
求值得到2
和1
,然后再分别赋值给左边的a
和b
。于是完成了两个变量的交换。但是如果边求值边赋值,就是先对右边的b
求值,得到2
,赋值给a
。然后再对右边的a
求值,得到刚被赋值的2
,再赋值给b
。最终结果就是两个变量都会为2
。
local a, b = 1, 2
a, b = b, a -- swap 2 variables !!!
下面的图描述了错误的执行过程:
+-------+
/--(1)--| a |<------\
| +-------+ |
\------>| b |--(2)--/
+-------+
| |
既然要先全部求值,那求得的值就要先存到一个地方,自然就是栈顶,作为临时变量。下面的图描述了正确的执行过程:
+-------+
/---(1)--| a |<-------\
| +-------+ |
| /-(2)-| b |<----\ |
| | +-------+ | |
\------->| tmp1 |-(3)-/ |
| +-------+ |
\---->| tmp2 |--(4)---/
+-------+
| |
图中,(1)和(2)是对表达式求值,放到栈顶临时位置;(3)和(4)是赋值,把栈顶临时位置的值赋给变量。
这种做法的功能是正确的,但性能比较差。因为每个赋值都需要2次操作,先求值再赋值,也就需要2条字节码。但是大部分情况下本来只需要1次操作即可,比如把一个局部变量赋值给另外一个局部变量,本来只需要一条Move
字节码即可。尤其是程序中最常见的赋值语句是单个变量的赋值,单个变量就无所谓顺序了,也就不用先求值到临时变量了。所以上述这种先求值到栈顶然后再赋值的做法,就是为了少数情况的正确性,而牺牲了多数情况的性能。这种情况在编程中是比较常见的。通用的解决办法是,对多数情况增加一个quick path,比如我们现在的情况可以用如下逻辑:
if 单个变量 then
var = exp // 直接赋值,quick path
else // 多个变量
tmp_vars = exp_list // 先全部求值到临时变量
var_list = tmp_vars // 再统一赋值
不过,对于这个具体问题,有更优雅的解决办法。这里的关键是,在多重赋值的情况里,最后一个变量的赋值是不依赖其他变量的赋值的,就可以直接赋值而无需先求值到临时变量的。所以新的方案是:对最后这个变量做特殊处理(直接赋值),其他变量仍然先求值再赋值。这样对于单个变量的赋值语句(单个变量自然就是最后一个变量)这种情况,就退变为直接赋值。这样即保证了多个变量的正确性,也保证了大多数情况(单个变量)的性能。漂亮!
下图描述了这种方案:对于前面的变量a
先求值到栈顶临时变量,对于最后一个变量b
直接赋值,然后依次把栈顶临时变量赋值给对应变量。
+-------+
/---(1)--| a |<------\
| +-------+ |
| | b |--(2)--/
| +-------+ <-------\
\------->| tmp1 |--(3)----/
+-------+
| |
既然我们是最先执行的最后一个表达式的赋值,那么将错就错,前面的表达式也按照倒序来赋值。这样全部表达式就都是按照倒序赋值了。
至此,介绍完了赋值语句的语法和语义规则。接下来就是重写assignment()
函数。函数主体逻辑如下:
- 调用
prefixexp()
读取左值列表,保存为ExpDesc; - 调用
exp()
读取右值表达式列表,最后一个表达式保留ExpDesc,剩余表达式均discharge到栈顶; - 对齐左值和右值的数量;
- 赋值,先把最后一个表达式赋值给最后一个左值,然后把栈顶的临时变量依次赋值给对应的左值。
具体代码这里省略。下面只详细介绍第4步,赋值。
执行赋值
赋值语句由左值和右值组成:
-
每个左值由
prefixexp()
函数读取,返回ExpDesc类型。但由BNF可知赋值语句只支持变量和表索引,其中变量包括局部变量和全局变量,分别对应Local
和Global
这2个ExpDesc类型,表索引又有Index
、IndexField
和IndexInt
这3个ExpDesc类型,加起来一共5种类型。 -
每个右值由
exp()
函数读取,也返回ExpDesc类型,并且支持任意的ExpDesc类型。
综上,左边5种类型,右边N种类型(N是ExpDesc的全部类型数量),一共就有5N个组合。有点多,需要梳理下。
首先,对于左值是局部变量的情况,赋值就相当于是把表达式discharge到局部变量的栈位置。调用discharge()
函数即可。这个函数已经处理了ExpDesc的全部N种类型。
剩下的4种左值类型就有些复杂了,不过这4种情况是类似的,下面先用全局变量Global
类型为例介绍。
在之前的赋值小节中介绍了赋值的几种组合。对于左值是全局变量的情况,右值支持了3种表达式类型:常量、局部变量、和全局变量,当时为了简单起见,分别直接生成SetGlobalConst
、SetGlobal
、SetGlobalGlobal
这3个字节码。现在可以预见后面会有更多类型的表达式,比如本节增加的表的读取(如t.k
),还有后续要增加的如UpValue、运算(如a+b
)等。如果每新增一种类型就新加一个字节码,会变得很复杂。
而且表索引和运算这种表达式需要2个参数来表示,而这系列全局变量的赋值字节码,塞不进2个参数来表示赋值的源表达式(一个字节码最多支持3个u8类型的参数,这系列字节码需要1个参数来表示目的地址,看上去是可以用2个参数来表示源表达式的。但通过luac的输出可以看到Lua官方的全局变量赋值字节码SETTABUP
是有3个参数,除了表示源和目的地址的2个参数外,还有1个额外参数。虽然现在还不清楚多出的那个参数的作用,但先假设后续我们也会用上那个参数,于是我们这系列字节码留给源表达式的也就只有1个参数位置了)。那么对这种复杂的表达式该如何处理?答案是先把这些复杂表达式求值到栈顶,作为临时变量,就是Local
类型了,然后再使用SetGlobal
来完成赋值。
这就出现了两个极端:
- 之前的做法是对每种源表达式类型都定义一个字节码;
- 刚才讨论的方案是把所有类型都先discharge到栈上,然后只用一个
SetGlobal
字节码。
在这两个极端中间,我们参考Lua官方实现的选择,即为常量类型(ExpDesc的String
、Float
等类型)定义一个字节码,而其他类型都先discharge到栈上,转换为Local
类型。虽然常量类型实际上不是一种具体的类型(包括了String
、Float
等多个类型),但处理方式是一样的,通过add_const()
函数加到常量表中,并用常量表索引来表示,所以在处理赋值语句时,可以看着是一个类型。于是,我们的右值表达式就简化为了两种类型:常量和栈上变量Local
!Lua的官方实现中,全局变量赋值的SETTABUP
字节码是通过1个bit来表示源表达式是常量还是栈上变量。我们的字节码的生成不方便精确操作bit,所以新增一个字节码SetGlobalConst
来表示常量。
Lua官方实现为什么对常量特殊对待,而对其他类型(如全局变量、UpValue、表索引等)却没有优化?个人猜测有两个原因:
-
如果一个全局变量或UpValue或表索引被频繁访问以至于有优化的必要,那么可以简单的创建一个局部变量来优化,比如
local print = print
。而对常量来说,很多时候赋值给局部变量是不合适的,比如把一条赋值语句g = 100
修改为local h = 100; g = a
就显得很别扭,多此一举。 -
访问全局变量是根据变量名查表的,是相对比较耗时的操作,相比而言增加一条字节码的代价就不明显。访问其他类型也类似。而访问常量是通过索引直接引用的,增加一条字节码的代价相对就很大了。
至此,介绍完了全局变量的赋值,而表索引的赋值(即表的写操作)是类似的,对于3种类型Index
、IndexField
和IndexInt
,分别定义SetTable
、SetField
、SetInt
和SetTableConst
、SetFieldConst
、SetIntConst
共6个字节码。
最终,赋值的代码如下:
// process assignment: var = value
fn assign_var(&mut self, var: ExpDesc, value: ExpDesc) {
if let ExpDesc::Local(i) = var {
// self.sp will be set to i+1 in self.discharge(), which is
// NOT expected, but it's ok because self.sp will not be used
// before next statement.
self.discharge(i, value);
} else {
match self.discharge_const(value) {
ConstStack::Const(i) => self.assign_from_const(var, i),
ConstStack::Stack(i) => self.assign_from_stack(var, i),
}
}
}
fn assign_from_stack(&mut self, var: ExpDesc, value: usize) {
let code = match var {
ExpDesc::Local(i) => ByteCode::Move(i as u8, value as u8),
ExpDesc::Global(name) => ByteCode::SetGlobal(name as u8, value as u8),
ExpDesc::Index(t, key) => ByteCode::SetTable(t as u8, key as u8, value as u8),
ExpDesc::IndexField(t, key) => ByteCode::SetField(t as u8, key as u8, value as u8),
ExpDesc::IndexInt(t, key) => ByteCode::SetInt(t as u8, key, value as u8),
_ => panic!("assign from stack"),
};
self.byte_codes.push(code);
}
fn assign_from_const(&mut self, var: ExpDesc, value: usize) {
let code = match var {
ExpDesc::Global(name) => ByteCode::SetGlobalConst(name as u8, value as u8),
ExpDesc::Index(t, key) => ByteCode::SetTableConst(t as u8, key as u8, value as u8),
ExpDesc::IndexField(t, key) => ByteCode::SetFieldConst(t as u8, key as u8, value as u8),
ExpDesc::IndexInt(t, key) => ByteCode::SetIntConst(t as u8, key, value as u8),
_ => panic!("assign from const"),
};
self.byte_codes.push(code);
}
至此,按照BNF重新了赋值语句,自然也就支持了表的读写操作。
赋值语句和函数调用语句
现在回过头来看本节最开始提出的3个问题的最后一个问题,即语法分析时如何区分赋值语句和函数调用语句。
先来开赋值语句的BNF表示:
varlist ‘=’ explist
varlist ::= var {‘,’ var}
var ::= Name | prefixexp ‘[’ exp ‘]’ | prefixexp ‘.’ Name
语句开头是varlist
,展开后是变量var
,再展开是Name
和prefixexp
。Name
对应Token::Name
,但prefixexp
还需要继续展开。下面是其定义:
prefixexp ::= var | functioncall | ‘(’ exp ‘)’
functioncall ::= prefixexp args | prefixexp ‘:’ Name args
其中第一个var
又回到刚才赋值语句的开头,循环引用了,忽略。最后一个是(
开头,也很简单。中间的functioncall
展开后也是prefixexp
开头,也是循环引用,但这次不能忽略,因为functioncall
本身也是一条完整的语句,就是说如果一个语句以prefixexp
开头,那既可能是赋值语句,也可能是函数调用语句。这两个语句如何区分?在上一节里说明了,赋值语句的左值只能是变量和表索引这两种类型,而函数调用不能作为左值。这就是区分的关键!
综上,最终的解析逻辑是:如果是Name
或(
开头,则按照prefixexp
解析,并判断解析结果:
- 如果是函数调用,则认为是一个完整的
functioncall
语句; - 否则,就认为是赋值语句,而本次解析结果只是赋值语句的第一个
var
。
为此,在ExpDesc中新增函数调用类型Call
并让函数调用语句args()
返回。在load()
函数里,这部分的代码如下:
match self.lex.next() {
Token::SemiColon => (),
t@Token::Name(_) | t@Token::ParL => {
// functioncall and var-assignment both begin with
// `prefixexp` which begins with `Name` or `(`.
let desc = self.prefixexp(t);
if desc == ExpDesc::Call {
// prefixexp() matches the whole functioncall
// statement, so nothing more to do
} else {
// prefixexp() matches only the first variable, so we
// continue the statement
self.assignment(desc);
}
}
总结
本节通过BNF重新了赋值语句的解析,最终实现了表的读写操作。另外还有局部变量定义的语句,也需要按照BNF重新,相对很简单,这里省略介绍。
至此,本章完成了表的定义、构造和读写这些基本操作。并为此引入了非常重要的ExpDesc概念和BNF规则。
数值运算
Lua手册中介绍了支持的运算。本章主要讨论并实现数值运算和位运算,顺便也实现字符串连接运算和取长度运算。这些运算的处理方式是一样的。至于关系运算和逻辑运算,需要等到介绍完条件语句后,再做特殊处理。
首先介绍简单的一元运算,然后是二元运算,最后介绍浮点数转换。
一元运算
Lua中一元运算的语法参见:
exp ::= nil | false | true | Numeral | LiteralString | ‘...’ | functiondef |
prefixexp | tableconstructor | exp binop exp | unop exp
一元运算在最后一项:exp ::= unop exp
。即在表达式exp中,可以前置一元运算符。
Lua支持4个一元运算符:
-
,取负。这个Token也是二元运算符:减法。not
,逻辑取反。~
,按位取反。这个Token也是二元运算符:按位亦或。#
,取长度,用于字符串和表等。
语法分析代码中,增加这4个一元运算符即可:
fn exp(&mut self) -> ExpDesc {
match self.lex.next() {
Token::Sub => self.unop_neg(),
Token::Not => self.unop_not(),
Token::BitNot => self.unop_bitnot(),
Token::Len => self.unop_len(),
// 省略其他exp分支
下面以取负-
来举例,其他几个类似。
取负
由上面BNF可见,取负运算的操作数也是表达式exp,而表达式由ExpDesc来表示,所以考虑ExpDesc的几种类型:
-
整数和浮点数,则直接取负,比如对于
ExpDesc::Integer(10)
直接转换为ExpDesc::Integer(-10)
。也就是说,对于源码中的-10
,在词法分析阶段会生成Sub
和Integer(10)
这两个Token,然后由语法分析转换为-10
。没有必要在词法分析中直接支持负数,因为还可以有如下情况- -10
,即连续多个取负操作,对于这种情况,语法分析就比词法分析更适合了。 -
其他常量类型,比如字符串等,都不支持取负,所以报错panic。
-
其他类型,则在虚拟机运行时求值。生成新增的字节码
Neg(u8, u8)
,两个参数分别是栈上的目的和源操作数地址。这里只新增了1个字节码。相比之下,前面章节介绍的读取全局变量和读表操作为了优化而都设置3个字节码,分别处理参数的3种类型:栈上变量、常量、小整数。但是对于这里的取负操作,上面的两种情况已经处理了后两种类型(常量和小整数),所以只需要新增Neg(u8, u8)
这一个字节码来处理第一种类型(栈上变量)即可。而下一节的二元运算就不能完全处理常量类型,也就需要像读表操作一样对每种运算符都新增3个字节码了。
根据上一章对ExpDesc的介绍,对于最后一种情况,生成字节码,需要两步:首先exp()
函数返回ExpDesc类型,然后discharge()
函数根据ExpDesc生成字节码。目前ExpDesc现有类型无法表达一元运算语句,需要新增一个类型UnaryOp。这个新类型如何定义呢?
从执行角度考虑,一元运算操作和局部变量间的赋值操作非常类似。后者是把栈上一个值复制到另外一个位置;前者也是,只是在复制过程中增加了一个运算的转换。所以对于一元运算语句返回的ExpDesc类型就可以参考局部变量。对于局部变量,表达式exp()
函数返回ExpDesc::Local(usize)
类型,关联的usize类型参数为局部变量在栈上的位置。那对于一元运算操作,新增ExpDesc::UnaryOp(fn(u8,u8)->ByteCode, usize)
类型,相对于ExpDesc::Local
类型增加了一个关联参数,即复制过程中做的运算。这个运算的参数类型为fn(u8,u8)->ByteCode
,这种通过函数类型来传递enum的tag的方法,在用ExpDesc重新表构造中介绍过,这里不再重复。还以取负操作为例,生成ExpDesc::UnaryOp(ByteCode::Neg, i)
,其中i
为操作数的栈地址。
具体解析代码如下:
fn unop_neg(&mut self) -> ExpDesc {
match self.exp_unop() {
ExpDesc::Integer(i) => ExpDesc::Integer(-i),
ExpDesc::Float(f) => ExpDesc::Float(-f),
ExpDesc::Nil | ExpDesc::Boolean(_) | ExpDesc::String(_) => panic!("invalid - operator"),
desc => ExpDesc::UnaryOp(ByteCode::Neg, self.discharge_top(desc))
}
}
在生成ExpDesc::UnaryOp
类型后,按照此类型生成字节码就很简单了:
fn discharge(&mut self, dst: usize, desc: ExpDesc) {
let code = match desc {
ExpDesc::UnaryOp(op, i) => op(dst as u8, i as u8),
至此,我们完成了取负这个一元运算,其他3个一元运算大同小异,这里省略。
另外,由于一元运算语句的定义为:exp ::= unop exp
,操作数也是表达式语句,这里是递归引用,所以就自然支持了连续多个一元运算,比如not - ~123
语句。
上述是语法分析部分;而虚拟机执行部分需要添加这4个新增字节码的处理。也很简单,这里省略。
下一节介绍二元运算,会复杂很多。
二元运算
二元运算相对于上一节的一元运算,虽然只是多了一个操作数,但引入了很多问题,主要包括BNF左递归,优先级,操作数类型、和求值顺序等。
BNF左递归
Lua中二元运算语句的完整语法如下:
exp ::= nil | false | true | Numeral | LiteralString | ‘...’ | functiondef |
prefixexp | tableconstructor | exp binop exp | unop exp
简单起见,其他部分简化为OTHERS
,得到:
exp ::= exp binop exp | OTHERS
是左递归规则,需要按照之前介绍的方法来消除左递归,得到:
exp ::= OTHERS A'
A' := binop exp A' | Epsilon
之前的exp()
函数只是实现了上面第一行的OTHERS
部分,现在要加上第二行的A'
部分,也是递归引用,使用循环来实现。修改exp()
函数结构如下:
fn exp(&mut self) -> ExpDesc {
// OTHERS
let mut desc = match self.lex.next() {
// 这里省略原有的各种OTHERS类型处理
};
// A' := binop exp A' | Epsilon
while is_binop(self.lex.peek()) {
let binop = self.lex.next(); // 运算符
let right_desc = self.exp(); // 第二个操作数
desc = self.process_binop(binop, desc, right_desc);
}
desc
}
其中对第二个操作数right_desc也是递归调用exp()
函数来读取,这就导致一个问题:优先级。
优先级
上一节的一元运算语句中,也是递归调用exp()
函数来读取操作数,但因为只有一个操作数,所以并不需要优先级,或者说所有一元运算符的优先级都相等。并且一元运算符都是右结合的。比如下面两个连续一元运算的例子,都是按照从右向左的顺序执行,而跟具体运算符无关:
~ -10
,先取负,再按位取反,- ~10
,先按位取反,再取负。
但对于二元运算语句,就要考虑优先级了。比如下面两个语句:
a + b - c
,先执行前面的加法,再执行后面的减法,a + b * c
,先执行后面的乘法,再执行前面的加法。
对应到上面的exp()
函数代码中,开头的OTHERS
部分读取到第一个操作数a
;然后while
循环内读取到运算符+
;再然后递归调用exp()
函数读取右操作数,此时就需要计较下。还以上面两个语句为例:
a + b - c
,读到b
就结束并作为右操作数;然后执行加法a + b
;然后再次循环处理后面的- c
部分;a + b * c
,读到b
之后还要继续往下,读取并执行整个b * c
并将执行结果作为右操作数;然后执行加法;并结束循环。
- +
/ \ / \
+ c a *
/ \ / \
a b b c
那么在语法分析时,如何判断是上述哪种情况?读到b
后,是停止解析先算加法,还是继续解析?这取决于下一个运算符和当前运算符的优先级:
- 下一个运算符优先级不大于当前运算符时,就是第一种情况,停止解析,而先完成当前的运算;
- 下一个运算符优先级大于当前运算符时,就是第二种情况,需要继续解析。
为此,参考Lua语言中给所有运算符优先级列表:
or
and
< > <= >= ~= ==
|
~
&
<< >>
..
+ -
* / // %
unary operators (not # - ~)
^
由上往下,优先级依次变高。其中连接符..
和求幂^
都是右结合,其他运算符都是左结合。上面列出的判断规则里,对于相等优先级的情况是停止解析(而非继续解析),所以默认是左结合。于是对于2个右结合的运算符需要特殊处理,即给他们向左和向右定义不同的优先级,向左的更高,这样就会变成右结合。
综上,定义优先级函数:
fn binop_pri(binop: &Token) -> (i32, i32) {
match binop {
Token::Pow => (14, 13), // right associative
Token::Mul | Token::Mod | Token::Div | Token::Idiv => (11, 11),
Token::Add | Token::Sub => (10, 10),
Token::Concat => (9, 8), // right associative
Token::ShiftL | Token::ShiftR => (7, 7),
Token::BitAnd => (6, 6),
Token::BitNot => (5, 5),
Token::BitOr => (4, 4),
Token::Equal | Token::NotEq | Token::Less | Token::Greater | Token::LesEq | Token::GreEq => (3, 3),
Token::And => (2, 2),
Token::Or => (1, 1),
_ => (-1, -1)
}
}
对于不是二元运算符的Token,则返回-1
,即最低的优先级,无论当前运算符是什么,都可以停止解析。按照Rust的习惯做法,这个函数应该返回Option<(i32, i32)>
类型,然后不是二元运算符的Token就返回None
。但是返回-1
在调用的地方更简单,不需要多一次Option的处理。
这个函数看上去是Token
类型的属性,所以貌似适合定义为Token
的方法。但Token
类型是在lex.rs
中定义的;而优先级是语法分析的概念,应该在parse.rs
中实现。Rust语言不允许在类型的非定义的文件中添加方法。所以上述函数就在parse.rs
文件中定义为个普通函数(而非其他函数那样是ParseProto
的方法)。
现在,按照优先级,再次修改exp()
函数:
fn exp(&mut self) -> ExpDesc {
self.exp_limit(0)
}
fn exp_limit(&mut self, limit: i32) -> ExpDesc {
// OTHERS
let mut desc = match self.lex.next() {
// 这里省略原有的各种OTHERS类型处理
};
// A' := binop exp A' | Epsilon
loop {
let (left_pri, right_pri) = binop_pri(self.lex.peek());
if left_pri <= limit {
return desc; // 停止解析
}
// 继续解析
let binop = self.lex.next();
let right_desc = self.exp_limit(right_pri);
desc = self.process_binop(binop, desc, right_desc);
}
}
首先为exp()
增加一个limit
参数,作为当前运算符的优先级,限制后续的解析范围。但这个参数属于语句内部概念,对于此函数的调用者而言,无需知晓此参数;所以增加exp_limit()
这个实际处理函数,而把exp()
变成一个外层封装函数,用limit=0
来调用前者。初始调用之所以使用limit=0
,是因为0
小于binop_pri()
函数中定义的任何二元运算符优先级,所以第一个运算符都会被继续解析(而不是return退出循环);但0
又大于非运算符的优先级-1
,所以如果后面紧跟非运算符,也会正常退出。
上述解析代码结合了循环和递归调用,对于不熟悉算法的人来说难度很大,很难直接写出完整代码。但是依照消除左递归后的BNF规范,就可以完成循环和递归,再根据优先级加上条件退出,就可以很轻松完成这个函数。
另外,需要注意到上面运算符优先级表单中也列出了一元运算符,所以上一节解析一元运算语句时,读取操作数的表达式时,就不能使用exp()
函数(初始优先级0),而应该指定初始优先级为12:
fn exp_unop(&mut self) -> ExpDesc {
self.exp_limit(12) // 12 is all unary operators' priority
}
求幂运算^
的优先级居然高于一元运算符,所以语句-a^10
的执行顺序是:先求幂,再取负。
求值顺序
上述解析代码有个非常隐晦的bug,是关于操作数求值的顺序。
每个操作数的处理需要2步:首先调用exp()
函数读取操作数并返回ExpDesc,然后调用discharge()
函数把操作数discharge到栈上以便字节码操作。二元运算有2个操作数,就一共需要4步。现在讨论下这4步的顺序。
按照当前版本的exp()
函数中对二元运算的处理逻辑:
- 先读取第一个操作数,
desc
; - 然后判断是二元运算后,递归调用
exp_limit()
,读取第二个操作数,right_desc
; - 然后在
process_binop()
函数中把上述两个操作数的ExpDesc一起discharge到栈上。
简化下就是:
- 解析第一个操作数;
- 解析第二个操作数;
- discharge第一个操作数;
- discharge第二个操作数。
在解析和discharge阶段,都可能生成字节码。所以按照这个顺序,两个操作数相关的字节码是可能穿插的。比如下面的例子:
local a = -g1 + -g2
忽略前面的局部变量定义,也忽略未定义全局变量的运算会抛异常,这里重点只看后面的加法语句。用当前版本的解释器生成如下字节码序列:
constants: ['g1', 'g2']
byte_codes:
GetGlobal(0, 0) # 解析第一个操作数
GetGlobal(1, 1) # 解析第二个操作数
Neg(2, 0) # discharge第一个操作数
Neg(3, 1) # discharge第二个操作数
Add(0, 2, 3)
可以看到这里两个操作数相关的字节码是穿插的。在这个例子里,穿插并没什么问题。但有的情况下,解析第二个操作数是会影响第一个操作数的求值的,这时穿插就会造成问题。比如下面的例子:
local t = { k = 1 }
local function f(t) t.k = 100; return 2 end -- 修改t.k的值
local r = t.k + f(t)*3
对于最后一句,我们预期是1 + 2*3
,但是如果按照现在的求值顺序:
- 先解析左操作数
t.k
,生成ExpDesc::IndexField
,但并不discharge; - 然后解析右操作数
f(t)*2
,在解析过程中会执行f(t),从而修改t.k的值; - 然后discharge左操作数,生成
GetField
字节码,但此时t.k
已经被上一步修改了!这里就出现了错误。实际执行的就是100 + 2*3
。
综上,我们要确保两个操作数的字节码不能穿插!那么改造exp_limit()
函数如下:
fn exp_limit(&mut self, limit: i32) -> ExpDesc {
// 这里省略原有的各种OTHERS类型处理
loop {
// 省略判断优先级的处理
// discharge第一个操作数!!!
if !matches!(desc, ExpDesc::Integer(_) | ExpDesc::Float(_) | ExpDesc::String(_)) {
desc = ExpDesc::Local(self.discharge_top(desc));
}
// 继续解析
let binop = self.lex.next();
let right_desc = self.exp_limit(right_pri); // 解析第二个操作数
desc = self.process_binop(binop, desc, right_desc);
}
}
在解析第二个操作数前,先把第一个操作数discharge到栈上。不过对于常量类型则无需这么处理,因为:
- 常量不会像上面的例子那样,被第二个操作数影响;
- 常量还要在后续尝试直接折叠。
至此完成二元运算语法分析的exp_limit()
函数改造。至于二元运算的具体处理process_binop()
函数,下面介绍。
字节码
上一节介绍的一元运算只有1个操作数,分2种情况:常量和变量,常量就直接求值,变量就生成字节码。所以每个一元运算都只有一个字节码。二元运算因为涉及2个操作数,所以复杂些。
首先,二元运算符虽然大部分都是数值计算,但因为Lua的元表功能,类似运算符重载,所以其他类型常量(比如字符串、bool等)都可能是合法的操作数。在解析一元运算时,这些类型的常量是直接报错,但对于二元运算需要到执行阶段才能判断是否合法。
其次,如果两个操作数都是数字类型常量(整数和浮点数),那么就可以在语法分析时直接计算出结果,称之为常量折叠。
否则,就生成字节码,由虚拟机执行。类似之前已经支持的读取全局变量和读表操作,每个二元运算符也都设置3个字节码,分别处理右操作数的3种类型:栈上变量、常量、小整数。
而左操作数统一discharge到栈上,因为左操作数是常量的情况并不多见。如果也为常量和小整数类型增加对应的字节码,比如10-a
这种语句,那字节码类型就太多了。
最后,对于满足交换律的加法和乘法,如果左操作是常量,那么可以交换,比如10+a
可以先转换为a+10
,由于右操作数10
是小整数,就可以使用AddInt
字节码。
ExpDesc
类似上一节介绍的一元运算引入的新ExpDesc类型,二元运算因为多了一个操作数,所以也需要一个新的类型:
enum ExpDesc {
UnaryOp(fn(u8,u8)->ByteCode, usize), // (opcode, operand)
BinaryOp(fn(u8,u8,u8)->ByteCode, usize, usize), // (opcode, left-operand, right-operand)
语法分析
至此介绍完二元运算语句的基本要求。下面看代码实现,即exp()
函数中调用的process_binop()
函数:
fn process_binop(&mut self, binop: Token, left: ExpDesc, right: ExpDesc) -> ExpDesc {
if let Some(r) = fold_const(&binop, &left, &right) { // 常量折叠
return r;
}
match binop {
Token::Add => self.do_binop(left, right, ByteCode::Add, ByteCode::AddInt, ByteCode::AddConst),
Token::Sub => self.do_binop(left, right, ByteCode::Sub, ByteCode::SubInt, ByteCode::SubConst),
Token::Mul => self.do_binop(left, right, ByteCode::Mul, ByteCode::MulInt, ByteCode::MulConst),
// 省略更多类型
}
}
首先尝试常量折叠。这部分功能因为涉及整数和浮点数类型的处理,所以在下一节介绍。因为两个操作数并不一定是常量,并不一定能够折叠,如果没有成功折叠,那么后续还要使用操作符和两个操作数,所以这里fold_const()
函数只能传入引用。
如果不是常量,不能折叠,那么调用do_binop()
函数来返回ExpDesc。这里把enum的tag作为函数来使用,在之前已经介绍过了,这里不再介绍。
下面来看do_binop()
函数:
fn do_binop(&mut self, mut left: ExpDesc, mut right: ExpDesc, opr: fn(u8,u8,u8)->ByteCode,
opi: fn(u8,u8,u8)->ByteCode, opk: fn(u8,u8,u8)->ByteCode) -> ExpDesc {
if opr == ByteCode::Add || opr == ByteCode::Mul { // commutative
if matches!(left, ExpDesc::Integer(_) | ExpDesc::Float(_)) {
// swap the left-const-operand to right, in order to use opi/opk
(left, right) = (right, left);
}
}
let left = self.discharge_top(left);
let (op, right) = match right {
ExpDesc::Integer(i) =>
if let Ok(i) = u8::try_from(i) {
(opi, i as usize)
} else {
(opk, self.add_const(i))
}
ExpDesc::Float(f) => (opk, self.add_const(f)),
_ => (opr, self.discharge_top(right)),
};
ExpDesc::BinaryOp(op, left, right)
}
首先,判断如果是加法或乘法,并且左操作数是数字常量,则交换两个操作数,为了后续能够生成xxCoust
或者xxInt
的字节码。
然后,把左操作数discharge到栈上;
然后,再判断右操作数类型是否为数字常量,否则也discharge到栈上。
最后,生成ExpDesc::BinaryOp
。
至此,二元运算语句的语法分析基本完成。
整数和浮点数
至此,我们介绍了二元运算的大致解析过程,但还有一个细节,即对整数和浮点数类型的不同处理规则。由于这方面内容也不少,而且跟上述主要的解析过程相对独立,所以在下一节中单独介绍。
整数和浮点数
在Lua 5.3之前的版本中,只支持一种类型的数字,默认是浮点数,可以通过修改Lua解释器源码来使用整数。我理解这是因为Lua最初是被用作配置语言,面向的使用者大多不是程序员,是不区分整数和浮点数的,比如5
和5.0
就是两个完全一样的数字。后来随着Lua使用范围的扩大,同时支持整数的需求越发强烈(比如位运算),最终在Lua 5.3版本中区分了整数和浮点数。这也带来了一些复杂度,主要二元运算符对不同类型的处理规则,分为如下三类:
- 支持整数和浮点数,包括
+
、-
、*
、//
和%
。如果两个操作数都是整数,则结果也是整数;否则(两个操作数至少有一个浮点数)结果是浮点数。 - 只支持浮点数,包括
/
和^
。无论操作数是什么类型,结果都是浮点数。比如5/2
,两个操作数虽然都是整数,但会转换为浮点数,然后计算结果为2.5
。 - 只支持整数,包括5个位操作。要求操作数一定是整数,结果也是整数。
对上述三类的处理,在语法分析的常量折叠fold_const()
函数和虚拟机执行时,都会体现。代码很繁琐,这里省略。
类型转换
Lua也定义了上述类型转换的规则(主要是不能完整转换情况下的规则):
- 整型转浮点型:如果不能完整转换,则使用最接近的浮点数。即转换不会失败,只会丢失精度。
- 浮点型转整型:如果不能完整转换,则抛出异常。
而Rust语言中,整型转浮点型规则一样,但浮点型转整型就不同了,没有检查是否能完整转换。这被认为是个bug并会修复。在修复前,我们只能自己做这个完整性的检查,即如果转换失败,则抛出异常。为此我们实现ftoi()
函数:
pub fn ftoi(f: f64) -> Option<i64> {
let i = f as i64;
if i as f64 != f {
None
} else {
Some(i)
}
}
整型转浮点型时直接用as
即可,而浮点型转整型时就需要用这个函数。
在语法分析和虚拟机执行阶段,都会涉及到这个转换,所以新建utils.rs
文件用来放这些通用函数。
比较
Lua语言中,大部分情况下是尽量避免整数和浮点数的区别。最直接的例子就是,这个语句5 == 5.0
的结果是true,所以Value::Integer(5)
和Value::Float(5.0)
,在Lua语言中是相等的。另外一个地方是,用这两个value做table的key的话,也认为是同一个key。为此,我们就要修改之前对Value的两个trait实现。
首先是比较相等的PartialEq
trait:
impl PartialEq for Value {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Value::Integer(i), Value::Float(f)) |
(Value::Float(f), Value::Integer(i)) => *i as f64 == *f && *i == *f as i64,
然后是Hash
trait:
impl Hash for Value {
fn hash<H: Hasher>(&self, state: &mut H) {
match self {
Value::Float(f) =>
if let Some(i) = ftoi(*f) {
i.hash(state)
} else {
unsafe {
mem::transmute::<f64, i64>(*f).hash(state)
}
}
不过,还是有一个地方需要区分类型的,就是在语法分析时,向常量表中添加常量时,查询常量是否已经存在的时候。为此要实现一个区分类型的比较方法:
impl Value {
pub fn same(&self, other: &Self) -> bool {
// eliminate Integer and Float with same number value
mem::discriminant(self) == mem::discriminant(other) && self == other
}
}
测试
至此,二元运算语句的语法分析终于完成。虚拟机执行部分就很简单,这里略过。可以使用如下测试Lua代码:
g = 10
local a,b,c = 1.1, 2.0, 100
print(100+g) -- commutative, AddInt
print(a-1)
print(100/c) -- result is float
print(100>>b) -- 2.0 will be convert to int 2
print(100>>a) -- panic
控制结构
本章介绍控制结构,最明显的变化是自此虚拟机不再只有顺序执行,而出现了跳转。并且由于语法分析时递归调用语法块block的解析,需要处理局部变量作用域,使得block的含义和界限更加清晰。
Lua语言中的几种控制结构很平常,跟其他语言类似,没什么特别之处。接下来第一节首先介绍最简单的if语句的if分支,引入条件跳转和block的处理。然后依次介绍其他控制结构,大部分都是通过条件跳转(Test字节码)和无条件跳转(Jump字节码)来实现。只有数值型for语句由于语义比较复杂,所以为了性能考虑而使用2个专门的字节码。泛型for语句需要用到函数,所以在后续章节引入函数后再介绍。
另外,这一章还讨论并尝试引入了Lua中不存在的continue语句,并保证向后兼容。
再另外,虽然本章从功能上完整地实现了各个控制结构,但在下一章介绍了关系运算和逻辑运算后,会优化这里的实现。
if语句
条件判断语句跟之前已经实现的语句最大的不同是:不再顺序执行字节码了,可能出现跳转。为此,我们新增字节码Test
,关联2个参数:
- 第一个参数,
u8
类型,判断条件在栈上的位置; - 第二个参数,
u16
类型,向前跳转字节码条数。
这个字节码的语义是:如果第一个参数代表的语句为假,那么向前跳转第二个参数指定条数的字节码。控制结构图如下:
+-------------------+
| if condition then |---\ 如果condition为假,则跳过block
+-------------------+ |
|
block |
|
+-----+ |
| end | |
+-----+ |
<-----------------------/
Test字节码的定义如下:
pub enum ByteCode {
// condition structures
Test(u8, u16),
第二个参数是跳转的字节码条数,即相对位置。如果使用绝对位置,那么解析和执行的代码都会稍微简单一些,但表达能力就会差一些。16bit的范围是65536。如果使用绝对位置,那么一个函数内超过65536之后的代码,就不能使用跳转字节码了。而如果使用相对位置,那么就支持在字节码本身的65536范围内跳转,就可以支持很长的函数了。所以我们使用相对位置。这也引入了一个一直忽略的问题,就是字节码中参数的范围,比如栈索引参数都是用的u8
类型,那么如果一个函数中超过了256个局部变量,就会溢出,造成bug。后续需要特别处理参数的范围问题。
按照上面的控制结构图,完成if语句的语法分析代码如下:
fn if_stat(&mut self) {
let icond = self.exp_discharge_top(); // 读取判断语句
self.lex.expect(Token::Then); // 语法:then关键字
self.byte_codes.push(ByteCode::Test(0, 0)); // 生成Test字节码占位,两个参数后续补上
let itest = self.byte_codes.len() - 1;
// 解析语法块!并预期返回end关键字,暂时不支持elseif和else分支
assert_eq!(self.block(), Token::End);
// 修复Test字节码参数。
// iend为字节码序列当前位置,itest为Test字节码位置,两者差就是需要跳转的字节码条数。
let iend = self.byte_codes.len() - 1;
self.byte_codes[itest] = ByteCode::Test(icond as u8, (iend - itest) as u16);
}
代码流程已经在注释里逐行说明。这里需要详细说明的是递归调用的block()
函数。
block的结束
原来的block()
函数实际是整个语法分析的入口,只执行一次(而没有递归调用),一直读到源代码末尾Token::Eos
作为结束:
fn block(&mut self) {
loop {
match self.lex.next() {
// 这里省略其他语句解析
Token::Eos => break, // Eos则退出
}
}
}
现在要支持的if语句中的代码块,预期的结束是关键字end
;后续还会包括elseif
和else
等其他关键字。代码块的结束并不只是Token::Eos
了,就需要修改block()
函数,把不是合法语句开头的Token(比如Eos
,关键字end
等)都认为是block的结束,并交由调用者判断是否为预期的结束。具体编码有2种修改方法:
-
用
lex.peek()
代替上述代码中的lex.next()
,如果看到的Token不是合法语句开头,则退出循环,此时这个Token还没有被消费读取。外部调用者再调用lex.next()
读取这个Token做判断。如果这么做,那么目前所有的语句处理代码,都要在最开始加上一个lex.next()
来跳过看到的Token,比较啰嗦。比如上一段里的if_stat()
函数,就要用lex.next()
跳过关键字if
。 -
仍然用
lex.next()
,对于读到的不是合法语句开头的Token,作为函数返回值,返回给调用者。我们采用这种方法,代码如下:
fn block(&mut self) -> Token {
loop {
match self.lex.next() {
// 这里省略其他语句解析
t => break t, // 返回t
}
}
}
所以上面的if_stat()
函数中,就要判断block()
的返回值为Token::End
:
// 解析语法块!并预期返回end关键字,暂时不支持elseif和else分支
assert_eq!(self.block(), Token::End);
而原来的语法分析入口函数chunk()
也要增加对block()
返回值的判断:
fn chunk(&mut self) {
assert_eq!(self.block(), Token::Eos);
}
block的变量作用域
block()
函数另外一个需要改动的地方是局部变量的作用域。即在block内部定义的局部变量,在外部是不可见的。
这个功能很核心!不过实现却非常简单。只要在block()
入口处记录当前局部变量的个数,然后在退出前清除新增的局部变量即可。代码如下:
fn block(&mut self) -> Token {
let nvar = self.locals.len(); // 记录原始的局部变量个数
loop {
match self.lex.next() {
// 这里省略其他语句解析
t => {
self.locals.truncate(nvar); // 失效block内部定义的局部变量
break t;
}
}
}
}
在后续介绍了Upvalue后,还需要做其他处理。
do语句
上面两小节处理了block的问题。而最简单的创建block的语句是do语句。因为太简单了,就在这里顺便介绍。语法分析代码如下:
// BNF:
// do block end
fn do_stat(&mut self) {
assert_eq!(self.block(), Token::End);
}
虚拟机执行
之前的虚拟机执行是顺序依次执行字节码,用Rust的for语句循环遍历即可:
pub fn execute<R: Read>(&mut self, proto: &ParseProto<R>) {
for code in proto.byte_codes.iter() {
match *code {
// 这里省略所有字节码的预定义逻辑
}
}
}
现在要支持Test
字节码的跳转,就需要在循环遍历字节码序列期间,能够修改下一次遍历的位置。Rust的for语句不支持在循环过程中修改遍历位置,所以需要手动控制循环:
pub fn execute<R: Read>(&mut self, proto: &ParseProto<R>) {
let mut pc = 0; // 字节码索引
while pc < proto.byte_codes.len() {
match proto.byte_codes[pc] {
// 这里省略其他字节码的预定义逻辑
// condition structures
ByteCode::Test(icond, jmp) => {
let cond = &self.stack[icond as usize];
if matches!(cond, Value::Nil | Value::Boolean(false)) {
pc += jmp as usize; // jump if false
}
}
}
pc += 1; // 下一条字节码
}
}
通过字节码位置pc
来控制循环执行。所有字节码执行后pc
自增1,指向下一个字节码;对于跳转字节码Test
则额外会修改pc
。由于Test
字节码最后也会执行pc
自增,所以其跳转的位置其实是目标地址减去1。其实可以在这里加一条continue;
语句,跳过最后面的pc
自增。不知道这两种做法哪个更好。
上述代码的判断中可以看到,Lua语言中的假值只有2个:nil和false。其他值比如0,空表等,都是真值。
测试
至此我们实现了最简单的if条件判断语句。
由于我们目前还不支持关系运算,所以if后面的判断条件只能用其他语句。测试代码如下:
if a then
print "skip this"
end
if print then
local a = "I am true"
print(a)
end
print (a) -- should be nil
第一个判断语句中的条件语句a
是未定义全局变量,值为nil
,是假,所以内部的语句不执行。
第二个判断语句中的条件语句print
是已经定义的全局变量,是真,所以内部语句会执行。block内部定义了局部变量a
,在内部正常执行,但在block结束后,a
就无效了,再引用就是作为未定义全局变量了,打印就是nil
。
elseif和else分支
上一节支持了if语句。这一节继续完成elseif和else分支。
完整的BNF规范如下:
if exp then block {elseif exp then block} [else block] end
除了if判断外,还可以有连续多个可选的elseif判断分支,最后跟一个可选的else分支。控制结构图如下:
+-------------------+
| if condition then |-------\ 如果condition为假,则跳到下一个elseif分支
+-------------------+ |
|
block |
/<---- |
| +-----------------------+<--/
| | elseif condition then |-----\ 如果condition为假,则跳到下一个elseif分支
| +-----------------------+ |
| |
| block |
+<---- |
| +-----------------------+<----/
| | elseif condition then |-------\ 如果condition为假,则跳到else分支
| +-----------------------+ |
| |
| block |
+<---- |
| +------+ |
| | else | |
| +------+<-----------------------/
|
| block
|
| +-----+
| | end |
| +-----+
\---> 所有block执行完后跳转到这里。
最后一个block执行完后自动到这里,就无需显式跳转。图中最后一个block为else分支。
上图描述了有2个elseif分支和1个else分支的情况。除了右上角if的判断跳转外,其余都是要新增的跳转。跳转分2种:
- 图右侧的条件跳转,由上节新增的
Test
字节码执行; - 图左侧的无条件跳转,需要新增
Jump
字节码,定义如下:
pub enum ByteCode {
// condition structures
Test(u8, u16),
Jump(u16),
语法分析过程如下:
-
对于if判断分支,跟上一节相比,条件跳转的位置不变,还是block的结束位置;但需要在block最后新增一个无条件跳转指令,跳转到整个if语句的最后面;
-
对于elseif分支,跟if分支的处理方式一样。
-
对于else分支,无需处理。
最终生成的字节码序列的格式应该如下,其中...
代表内部代码块的字节码序列:
Test --\ if分支
... |
/<-- Jump |
| /<---/
| Test ----\ 第一个elseif分支
| ... |
+<-- Jump |
| /<-----/
| Test ------\ 第二个elseif分支
| ... |
+<-- Jump |
| /<-------/
| ... else分支
|
\--> 整个语句结尾
语法分析代码如下:
fn if_stat(&mut self) {
let mut jmp_ends = Vec::new();
// if分支
let mut end_token = self.do_if_block(&mut jmp_ends);
// 可选的多个elseif分支
while end_token == Token::Elseif { // 如果上一个block以关键字elseif结尾
end_token = self.do_if_block(&mut jmp_ends);
}
// 可选的一个else分支
if end_token == Token::Else { // 如果上一个block以关键字else结尾
end_token = self.block();
}
assert_eq!(end_token, Token::End); // 语法:最后end结尾
// 修复所有if和elseif分支内block最后的无条件跳转字节码,跳到当前位置
let iend = self.byte_codes.len() - 1;
for i in jmp_ends.into_iter() {
self.byte_codes[i] = ByteCode::Jump((iend - i) as i16);
}
}
其中对if和elseif分之的处理函数do_if_block()
如下:
fn do_if_block(&mut self, jmp_ends: &mut Vec<usize>) -> Token {
let icond = self.exp_discharge_top(); // 读取判断语句
self.lex.expect(Token::Then); // 语法:then关键字
self.byte_codes.push(ByteCode::Test(0, 0)); // 生成Test字节码占位,参数留空
let itest = self.byte_codes.len() - 1;
let end_token = self.block();
// 如果还有elseif或else分支,那么当前block需要追加一个无条件跳转字节码,
// 跳转到整个if语句末尾。由于现在还不知道末尾的位置,所以参数留空,并把
// 字节码索引记录到jmp_ends中。
// 如果没有其他分支,则无需跳转。
if matches!(end_token, Token::Elseif | Token::Else) {
self.byte_codes.push(ByteCode::Jump(0));
jmp_ends.push(self.byte_codes.len() - 1);
}
// 修复之前的Test字节码。
// iend为字节码序列当前位置,itest为Test字节码位置,两者差就是需要跳转的字节码条数。
let iend = self.byte_codes.len() - 1;
self.byte_codes[itest] = ByteCode::Test(icond as u8, (iend - itest) as i16);
return end_token;
}
虚拟机执行
新增的无条件跳转字节码Jump
的执行非常简单。跟之前的条件跳转字节码Test
相比,只是去掉了条件判断即可:
// 条件跳转
ByteCode::Test(icond, jmp) => {
let cond = &self.stack[icond as usize];
if matches!(cond, Value::Nil | Value::Boolean(false)) {
pc += jmp as usize; // jump if false
}
}
// 无条件跳转
ByteCode::Jump(jmp) => {
pc += jmp as usize;
}
while和break语句
本节介绍while语句,并引入break语句。
while语句
跟if语句的简单形式(不包括elseif和else分支)相比,while语句只是在内部block的末尾增加一个无条件跳转字节码,跳转回语句开头。如下图中左边的跳转所示:
/--->+----------------------+
| | while condition then |---\ 如果condition为假,则跳过block
| +----------------------+ |
| |
| block |
\<---- |
+-----+ |
| end | |
+-----+ |
<--------------------------/
最终生成的字节码序列的格式如下,其中...
代表内部代码块的字节码序列:
/--> Test --\ if分支
| ... |
\--- Jump |
<----/ 整个while语句结尾
语法分析过程和代码,也是在if语句的基础上增加一个无条件跳转字节码,这里略过。需要改造的一个地方是,这里的无条件跳转是向后跳转。之前Jump
字节码的第2个参数是u16
类型,只能向前跳转。现在需要改为i16
类型,用负数表示向后跳转:
pub enum ByteCode {
Jump(i16),
相应的,虚拟机执行部分需要修改为:
// 无条件跳转
ByteCode::Jump(jmp) => {
pc = (pc as isize + jmp as isize) as usize;
}
相对于C语言,Rust的类型管理更加严格,所以看起来比较啰嗦。
break语句
while语句本身非常简单,但引入了另外一个语句:break。break语句本身也很简单,无条件跳转到block结尾处即可,但问题在于并不是所有block都支持break,比如之前介绍的if内部的block就不支持break,只有循环语句的block支持break。准确说,break要跳出的是最近一层的循环的block。比如下面示例:
while 123 do -- 外层循环block,支持break
while true do -- 中层循环block,支持break
a = a + 1
if a < 10 then -- 内层block,不支持break
break -- 跳出 `while true do` 的循环
end
end
end
代码中有3层block,外层和中层的while的block支持break,内层if的block不支持break。此时break就是跳出中层的block。
如果break语句不处于循环block之内,则语法错误。
为实现上述功能,可以在block()
函数中增加一个参数,以便在递归调用时表示最近一次的循环block。由于在生成跳转字节码时block还未结束,还不知道跳转目的地址,所以只能先生成跳转字节码占位,而参数留空;后续在block结束的位置再修复字节码参数。所以block()
函数的参数就是最近一层循环block的break跳转字节码索引列表。调用block()
函数时,
- 如果是循环block,则创建一个新索引列表作为调用参数,在调用结束后,用当前地址(即block结束位置)修复列表中字节码;
- 如果不是循环block,则使用当前列表(也就是当前最近循环block)作为调用参数。
但是block()
函数的递归调用并不是直接递归,而是间接递归。如果要这样传递参数,那么所有的语法分析函数都要加上这个参数了,太复杂。所以把这个索引列表放到全局的ParseProto
中。牺牲了局部性,换来了编码方便。
下面来看具体编码实现。首先在ParseProto
中添加break_blocks
字段,类型是“跳转字节码索引列表”的列表:
pub struct ParseProto<R: Read> {
break_blocks: Vec::<Vec::<usize>>,
在解析while语句时,调用block()
函数前,追加一个列表;调用后,修复列表中的跳转字节码:
fn while_stat(&mut self) {
// 省略条件判断语句处理部分
// 调用block()前,追加一个列表
self.break_blocks.push(Vec::new());
// 调用block()
assert_eq!(self.block(), Token::End);
// 调用block()后,弹出刚才追加的列表,并修复其中的跳转字节码
for i in self.break_blocks.pop().unwrap().into_iter() {
self.byte_codes[i] = ByteCode::Jump((iend - i) as i16);
}
}
在准备好block后,就可以实现break语句了:
fn break_stat(&mut self) {
// 取最近的循环block的字节码列表
if let Some(breaks) = self.break_blocks.last_mut() {
// 生成跳转字节码占位,参数留空
self.byte_codes.push(ByteCode::Jump(0));
// 追加到字节码列表中
breaks.push(self.byte_codes.len() - 1);
} else {
// 如果没有循环block,则语法错误
panic!("break outside loop");
}
}
continue语句?
实现了break语句后,自然就想到continue语句。而且continue的实现跟break类似,区别就是一个跳转到循环结束,一个跳转到循环开头,加上这个功能就是顺手的事情。不过Lua不支持continue语句!其中有一小部分原因跟repeat..until语句有关。我们在下一节介绍完repeat..until语句后再详细讨论continue语句。
repeat..until和continue语句
本节介绍 repeat..until语句,并讨论和尝试引入Lua语言并不支持的continue语句。
repeat..until语句
repeat..until语句跟while语句很像,只不过是把判断条件放在了后面,从而保证内部代码块至少执行一次。
+--------+
| repeat |
+--------+
/--->
| block
|
| +-----------------+
\----| until condition |
+-----------------+
最终生成的字节码序列的格式如下,其中...
代表内部代码块的字节码序列:
... <--\
Test ---/ until判断条件
跟while语句的字节码序列相比,看上去就是把Test放到最后,并替换掉原来的Jump字节码。但情况并没有这么简单!把判断条件语句放到block后面,会引入一个问题,判断条件语句中可能会使用block中定义的局部变量。比如下面例子:
-- 如果请求失败,则一直重试,直到成功为止
repeat
local ok = request_xxx()
until ok
最后一行until后面的变量ok
,本意明显是要引用第二行中定义的局部变量。但是,之前的代码块分析函数block()
在函数结尾就已经删除了内部定义的局部变量,代码参见这里。也就是说,按照之前的语法分析逻辑,在解析到until
时,内部定义的ok
局部变量已经失效,无法使用了。这显然是不可接受的。
为了支持在until时依然能读到内部局部变量,需要修改原来的block()
函数(代码就是被这些奇怪的需求搞乱的),把对局部变量的控制独立出来。为此,新增一个block_scope()
函数,只做语法分析;而内部局部变量的作用域由外层的block()
函数完成。这样原来调用block()
函数的地方(比如if、while语句等)就不用修改,而这个特别的repeat..until语句调用block_scope()
函数,做更细的控制。代码如下:
fn block(&mut self) -> Token {
let nvar = self.locals.len();
let end_token = self.block_scope();
self.locals.truncate(nvar); // expire internal local variables
return end_token;
}
fn block_scope(&mut self) -> Token {
... // 原有的block解析过程
}
然后,repeat..until语句的分析代码如下:
fn repeat_stat(&mut self) {
let istart = self.byte_codes.len();
self.push_break_block();
let nvar = self.locals.len(); // 内部局部变量作用域控制!
assert_eq!(self.block_scope(), Token::Until);
let icond = self.exp_discharge_top();
// expire internal local variables AFTER condition exp.
self.locals.truncate(nvar); // 内部局部变量作用域控制!
let iend = self.byte_codes.len();
self.byte_codes.push(ByteCode::Test(icond as u8, -((iend - istart + 1) as i16)));
self.pop_break_block();
}
上述代码中,中文注释的2行,就是完成了原来block()
函数中内部局部变量作用域的控制。在调用完exp_discharge_top()
解析完条件判断语句之后,才去删除内部定义的局部变量。
continue语句
上面花了很大篇幅来说明repeat..until语句中变量作用域的问题,这跟Lua中并不存在的continue语句也有很大关系。
在上一节支持break语句时,提到了Lua语言并不支持continue语句。关于这个问题的争论非常多,在Lua中加入continue语句的呼声也很高,早在2012年就有相关的提案,其中详细罗列了加入continue语句的好处和坏处以及相关讨论。20年过去了,倔强的Lua即使在5.2版本加入了goto语句,也仍然没有加入continue语句。
“非官方FAQ”对此的解释是:
- continue语句只是众多控制语句之一,类似的包括goto、带label的break等。而continue语句并没有什么特殊,没有必要新增这个语句;
- 跟现有的repeat..until语句冲突。
另外,Lua的作者Roberto的一封邮件更能代表官方态度。其中说的原因就是上述第1点,即continue语句只是众多控制语句之一。一个有意思的地方是,这封邮件里举了两个例子,除continue外另外一个例子刚好也是repeat..until。上面非官方FAQ里也提到这两个语句冲突。
这两个语句冲突的原因是,如果repeat..until内部代码块中有continue语句,那么就会跳转到until的条件判断位置;如果条件判断语句中使用了内部定义的局部变量,而continue语句又跳过了这个局部变量的定义,那这个局部变量就没有意义了。这就是冲突所在。比如下面的代码:
repeat
continue -- 跳转到until,跳过了ok的定义
local ok = request_xxx()
until ok -- 这里ok如何处理?
对比下,C语言中跟repeat..until语句等价的是do..while语句,是支持continue的。这是因为C语言的do..while语句中,while后面的条件判断是在内部代码块的作用域之外的。比如下面代码就会编译错误:
do {
bool ok = request_xx();
} while (ok); // error: ‘ok’ undeclared
这样的规范(条件判断是在内部代码块的作用域之外)虽然在有的使用场景下不太方便(如上面的例子),但也有很简单的解决方法(比如把ok
定义挪到循环外面),而且语法分析也更简单,比如就不需要拆出block_scope()
函数了。那Lua为什么规定把条件判断语句放到内部作用域之内呢?推测如下,假如Lua也按照C语言的做法(条件判断是在内部代码块的作用域之外),然后用户写出下面的Lua代码,until后面的ok
就被解析为一个全局变量,而不会像C语言那样报错!这并不是用户的本意,于是造成一个严重的bug。
repeat
local ok = request_xxx()
until ok
总结一下,就是repeat..until语句为了避免大概率出现的bug,需要把until后面的条件判断语句放到内部代码块的作用域之内;那么continue语句跳转到条件语句中时,就可能跳过局部变量的定义,进而出现冲突。
尝试添加continue语句
Lua官方不支持continue语句的理由主要是他们认为continue语句的使用频率很低,不值得支持。但是在我个人编程经历中,无论是Lua还是其他语言,continue语句的使用频率还是很高的,虽然可能比不上break,但是远超goto和带label的break语句,甚至也超过repeat..until语句。而现在Lua中实现continue功能的方式(repeat..until true加break,或者goto)都比直接使用continue要啰嗦。那么能不能在我们的解释器中增加continue语句呢?
首先,自然是要解决上面说的跟repeat..until的冲突。有几个解决方案:
-
规定repeat..until中不支持continue语句,就像if语句不支持continue一样。但这样非常容易造成误会。比如一段代码有两层循环,外层是while循环,内层是repeat循环;用户在内层循环中写了continue语句,本意是想在内层repeat循环生效,但由于实际上repeat不支持continue,那么就会在外层while循环生效,continue了外层的while循环。这是严重的潜在bug。
-
规定repeat..until中禁止continue语句,如果有continue则报错,这样可以规避上面方案的潜在bug,但是这个禁止过分严格了。
-
规定repeat..until中如果定义了内部局部变量,则禁止continue语句。这个方案比上个更宽松了些,但可以更加宽松。
-
规定repeat..until中出现continue语句后,就禁止再定义内部局部变量;或者说,continue禁止向局部变量定义之后跳转。这个跟后续的goto语句的限制类似。不过,还可以更加宽松。
-
在上一个方案的基础上,只有until后面的条件判断语句中使用了continue语句后面定义的局部变量,才禁止。只不过判断语句中是否使用局部变量的判定很复杂,如果后续再支持了函数闭包和Upvalue,就基本不可能判定了。所以这个方案不可行。
最终选择使用倒数第2个方案。具体编码实现,原来在ParseProto
中有break_blocks
用来记录break语句,现在新增类似的continue_blocks
,但成员类型是(icode, nvar)
。其中第一个变量icode和break_blocks
的成员一样,记录continue语句对应的Jump字节码的位置,用于后续修正;第二个变量nvar
代表continue语句时局部变量的个数,用于后续检查是否跳转过新的局部变量。
其次,新增continue语句不能影响现有的代码。为了支持continue语句需要把continue
作为一个关键字(类似break
关键字),那么很多现存Lua代码中使用continue
作为label,甚至是变量名或函数名(本质也是变量名)的地方就会解析失败。为此,一个tricky的解决方案是不把continue
作为关键字,而是在解析语句时判断如果开头是continue
并且后面紧跟块结束Token(比如end
等),就认为是continue语句。这样在其他大部分地方,continue
仍然会被解释为普通的Name。
对应的block_scope()
函数中,以Token::Name开头的部分,新增代码如下:
loop {
match self.lex.next() {
// 省略其他类型语句的解析
t@Token::Name(_) | t@Token::ParL => {
// this is not standard!
if self.try_continue_stat(&t) { // !! 新增 !!
continue;
}
// 以下省略标准的函数调用和变量赋值语句解析
}
其中try_continue_stat()
函数定义如下:
fn try_continue_stat(&mut self, name: &Token) -> bool {
if let Token::Name(name) = name {
if name.as_str() != "continue" { // 判断语句开头是"continue"
return false;
}
if !matches!(self.lex.peek(), Token::End | Token::Elseif | Token::Else) {
return false; // 判断后面紧跟这3个Token之一
}
// 那么,就是continue语句。下面的处理跟break语句处理类似
if let Some(continues) = self.continue_blocks.last_mut() {
self.byte_codes.push(ByteCode::Jump(0));
continues.push((self.byte_codes.len() - 1, self.locals.len()));
} else {
panic!("continue outside loop");
}
true
} else {
false
}
}
在解析到循环体的代码块block前,要先做准备,是push_loop_block()
函数。block结束后,再用pop_loop_block()
处理breaks和continues。breaks对应的Jump是跳转到block结束,即当前位置;而continues对应的Jump跳转位置是根据不同循环而定(比如while循环是跳转到循环开始,而repeat循环是跳转到循环结尾),所以需要参数来指定;另外,处理continus时要检查之后有没有新增局部变量的定义,即对比当前局部变量的数量跟continue语句时局部变量的数量。
// before entering loop block
fn push_loop_block(&mut self) {
self.break_blocks.push(Vec::new());
self.continue_blocks.push(Vec::new());
}
// after leaving loop block, fix `break` and `continue` Jumps
fn pop_loop_block(&mut self, icontinue: usize) {
// breaks
let iend = self.byte_codes.len() - 1;
for i in self.break_blocks.pop().unwrap().into_iter() {
self.byte_codes[i] = ByteCode::Jump((iend - i) as i16);
}
// continues
let end_nvar = self.locals.len();
for (i, i_nvar) in self.continue_blocks.pop().unwrap().into_iter() {
if i_nvar < end_nvar { // i_nvar为continue语句时局部变量的数量,end_nvar为当前局部变量的数量
panic!("continue jump into local scope");
}
self.byte_codes[i] = ByteCode::Jump((icontinue as isize - i as isize) as i16 - 1);
}
}
至此,我们在保证向后兼容情况下,实现了continue语句!可以使用下述代码测试:
-- validate compatibility
continue = print -- continue as global variable name, and assign it a value
continue(continue) -- call continue as function
-- continue in while loop
local c = true
while c do
print "hello, while"
if true then
c = false
continue
end
print "should not print this!"
end
-- continue in repeat loop
repeat
print "hello, repeat"
local ok = true
if true then
continue -- continue after local
end
print "should not print this!"
until ok
-- continue skip local in repeat loop
-- PANIC!
repeat
print "hello, repeat again"
if true then
continue -- skip `ok`!!! error in parsing
end
local ok = true
until ok
repeat..until的存在
上面可以看到由于在until部分需要扩展block中定义的局部变量的作用域,repeat..until语句的存在引入了两个问题:
- 编程实现中,需要特意新建
block_scope()
函数; - 跟continue语句有冲突。
我个人认为,为了支持repeat..until这么一个使用频率很低的语句而引入上面两个问题,有些得不偿失。如果是我来设计Lua语言,是不会支持这个语句的。
官方的《Lua程序设计(第4版)》一书的 8.4练习 一节中,提出了如下问题:
练习8.3:很多人认为,由于repeat-until很少使用,因此在想Lua语言这样的简单的编程语言中最后不要出现,你怎么看?
我是真想知道作者对这个问题的回答,但可惜这本书的练习题都没有给答案。
数值型for语句
Lua的for语句支持两种类型:
- 数值型:
for Name ‘=’ exp ‘,’ exp [‘,’ exp] do block end
- 泛型:
for namelist in explist do block end
泛型for需要函数支持,在下一章介绍了函数后再实现。本节实现数值型for。从BNF定义中可见,这两个类型的前2个Token一样,数值型的第3个Token是=
。通过这个区别可以区分两种类型:
fn for_stat(&mut self) {
let name = self.read_name();
if self.lex.peek() == &Token::Assign {
self.for_numerical(name); // 数值型
} else {
todo!("generic for"); // 泛型
}
}
控制结构
数值型for语句的语义很明显,等号=
后面3个表达式依次是初始值init、限制limit、和步长step。step可以是正数或负数,不能是0。控制结构图如下(图中假设step>0):
+--------------+
/--->| i <= limit ? |--No--\ 如果超过limit则跳转到结尾
| +--------------+ |
| |
| block |
| |
| +-----------+ |
\----| i += step | |
+-----------+ |
<-----------------/
图中方框中的执行逻辑都可以分别用1条字节码实现,每此循环都要执行2条字节码:先i+=step
,然后判断i<=limit
。为了性能,可以把第1条字节码的判断功能也加到下面的字节码中,这样每次循环只用执行1条字节码。控制结构图如下:
+--------------+
| i <= limit ? |--No--\ 如果超过limit则跳转到结尾
+--------------+ |
/------> |
| block |
| |
| +--------------+ |
| | i += step | |
\--Yes--| i <= limit ? | |
+--------------+ |
<----------------/
新增2条字节码:
pub enum ByteCode {
// for-loop
ForPrepare(u8, u16),
ForLoop(u8, u16),
这2个字节码分别对应上图中两个方框的字节码,关联的两个参数都分别是栈起始位置和跳转位置。后续会看到第一个字节码除了判断跳转以外,还需要做其他的准备工作,所以名叫prepare。
变量存储
上面2个字节码关联的第1个参数都是栈的起始位置。准确说就是存储上述3个值(init,limit,step)的位置。这3个值自然是需要存储在栈上的,因为栈的功能之一就是存储临时变量,另外也因为没有其他地方可用。这3个值依次存储,所以只需要一个参数就可以定位3个值。
另外,for语句还有个控制变量,可以复用init的栈上位置。在语法分析时,创建一个内部临时变量,名字就是BNF中的Name,指向栈上第一个变量的位置。为了让另外2个临时变量的位置不被占用,需要再创建2个匿名局部变量。所以,执行时的栈如下:
| |
sp +--------+
| init/i | 控制变量Name
sp+1 +--------+
| limit | 匿名变量""
sp+2 +--------+
| step | 匿名变量""
+--------+
| |
数值型for语句就上面的3个临时变量比较特殊,其余部分跟之前介绍的控制结构类似,无非就是根据条件判断语句做跳转。语法分析代码如下:
fn for_numerical(&mut self, name: String) {
self.lex.next(); // skip `=`
// 读取3个表达式:init、limit、step(默认是1),依次放置到栈上
match self.explist() {
2 => self.discharge(self.sp, ExpDesc::Integer(1)),
3 => (),
_ => panic!("invalid numerical for exp"),
}
// 创建3个局部变量,用以占住栈上位置。后续如果内部block需要局部或临时变量,
// 就会使用栈上这3个变量之后的位置。
self.locals.push(name); // 控制变量,可以在内部block中被引用
self.locals.push(String::from("")); // 匿名变量,纯粹占位用
self.locals.push(String::from("")); // 同上
self.lex.expect(Token::Do);
// 生成ForPrepare字节码
self.byte_codes.push(ByteCode::ForPrepare(0, 0));
let iprepare = self.byte_codes.len() - 1;
let iname = self.sp - 3;
self.push_loop_block();
// 内部block
assert_eq!(self.block(), Token::End);
// 删除3个临时变量
self.locals.pop();
self.locals.pop();
self.locals.pop();
// 生成ForLoop字节码,并修复之前的ForPrepare
let d = self.byte_codes.len() - iprepare;
self.byte_codes.push(ByteCode::ForLoop(iname as u8, d as u16));
self.byte_codes[iprepare] = ByteCode::ForPrepare(iname as u8, d as u16);
self.pop_loop_block(self.byte_codes.len() - 1);
}
整数和浮点数类型
之前支持的语句类型,都主要介绍语法分析部分;而虚拟机执行部分只是按照字节码对栈进行简单的操作。但数值型for循环的语法分析部分相对比较简单(主要是因为跟之前的几个控制结构类似),而虚拟机执行部分却很复杂。其实也不难,就是繁琐。繁琐的原因是因为Lua支持2种数值类型,整数和浮点数。数值型for语句中一共3个语句(或者称为变量),init、limit、和step,每个都可能是两种类型之一,一共就是8种可能。虽然某些情况下在语法分析阶段就可以确定某些变量的类型的(比如是常量),但对这种特殊情况单独处理的意义不大,最终还是需要处理全部3个变量都是未知类型的情况,这就需要在虚拟机执行阶段处理。
逐个处理8种类型实在太复杂;又不能完全归为一种类型,因为整数和浮点数的表示范围不一样。对此,Lua语言规定分为2类:
- 如果init和step是整数,那么按照整数处理;
- 否则,都按照浮点数处理。
至于在第一类里为什么没有考虑第2个limit的变量,就不清楚了。我想到有一些可能的原因,但都不确定,这里就不讨论了。就按照Lua的规定实现即可。但这也确实带来了一些复杂。
需要在某个地方把8种可能归类为上述2种类型。在语法分析阶段做不到,而在每次执行循环时又太费性能,所以就在循环开始的时候归类一次。这也就是ForPrepare字节码要做的事情:
- 如果init和step是整数,那么把limit也转换为整数;
- 否则,把3个变量都转换为浮点数。
这样,在每次执行循环时,即ForLoop字节码时,只需要处理2种情况即可。
第2类中把整数转换为浮点数简单,但第1类中把浮点数limit转换为整数,就要注意下面两点:
- 如果step为正,则limit向下取整;如果step为负,则limit向上取整。
- 如果limit超过整数的表示范围,那么就转换为整数的最大或最小值。这里就有个极端情况,比如step为负,init为整数最大值,limit超过了整数的最大值,那么init就小于limit,又因为Lua明确规定数值型for循环的控制变量不会溢出反转,所以预期是不会执行循环。但按照上述转换,limit由于超过整数的最大值,就被转换为最大值,就等于init了,就会执行一次循环。所以要特殊处理,可以把init和limit分别设置为0和1,这样就不会执行循环了。
limit变量转换的具体代码如下:
fn for_int_limit(limit: f64, is_step_positive: bool, i: &mut i64) -> i64 {
if is_step_positive {
if limit < i64::MIN as f64 {
*i = 0; // 一并修改init,保证不会执行循环
-1
} else {
limit.floor() as i64 // 向下取整
}
} else {
if limit > i64::MAX as f64 {
*i = 0;
1
} else {
limit.ceil() as i64 // 向上取整
}
}
}
虚拟机执行
介绍完上述整数和浮点数类型和转换细节后,接下来就实现相关两个字节码的虚拟机执行部分。
ForPrepare字节码做2件事情:首先根据变量类型分为整数和浮点数类型循环;然后比较init和limit判断是否执行第一次循环。代码如下:
ByteCode::ForPrepare(dst, jmp) => {
// clear into 2 cases: integer and float
// stack: i, limit, step
if let (&Value::Integer(mut i), &Value::Integer(step)) =
(&self.stack[dst as usize], &self.stack[dst as usize + 2]) {
// integer case
if step == 0 {
panic!("0 step in numerical for");
}
let limit = match self.stack[dst as usize + 1] {
Value::Integer(limit) => limit,
Value::Float(limit) => {
let limit = for_int_limit(limit, step>0, &mut i);
self.set_stack(dst+1, Value::Integer(limit));
limit
}
// TODO convert string
_ => panic!("invalid limit type"),
};
if !for_check(i, limit, step>0) {
pc += jmp as usize;
}
} else {
// float case
let i = self.make_float(dst);
let limit = self.make_float(dst+1);
let step = self.make_float(dst+2);
if step == 0.0 {
panic!("0 step in numerical for");
}
if !for_check(i, limit, step>0.0) {
pc += jmp as usize;
}
}
}
ForLoop字节码也做2件事情:首先控制变量加上step;然后比较控制变量和limit判断是否执行下一次循环。这里省略代码。
至此,我们完成数值型for语句。
goto语句
本节介绍goto语句。
goto语句和label配合,可以进行更加方便的代码控制。但goto语句也有如下限制:
- 不能跳转到更内层block定义的label,但可以向更外层block跳转;
- 不能跳转到函数外(注意上面一条规则已经限制了跳转到函数内),我们现在还不支持函数,先忽略这条;
- 不能跳转进局部变量的作用域,即不能跳过local语句。这里需要注意作用域终止于最后一条非void语句,label被认为是void语句。我个人理解就是不生成字节码的语句。比如下面代码:
while xx do
if yy then goto continue end
local var = 123
-- some code
::continue::
end
其中的continue
label是在局部变量var的后面,但由于是void语句,所以不属于var的作用域,所以上面的goto是合法跳转。
goto语句的实现自然用到Jump
字节码即可。语法分析时主要任务就是匹配goto和label,在goto语句的地方生成Jump
字节码跳转到对应的label处。由于goto语句可以向前跳转,所以在遇到goto语句时可能还没有遇到对应label的定义;也可以向后跳转,所以遇到label语句时,需要保存起来以便后续的goto匹配。因此需要在ParseProto
中新增两个列表,分别保存语法分析时遇到的goto和label信息:
struct GotoLabel {
name: String, // 要跳转到的/定义的label名
icode: usize, // 当前字节码索引
nvar: usize, // 当前局部变量个数,用以判断是否跳转进局部变量作用域
}
pub struct ParseProto<R: Read> {
gotos: Vec<GotoLabel>,
labels: Vec<GotoLabel>,
这两个列表的成员类型一样,都是GotoLabel
。其中的nvar
是当前的局部变量个数,要确保配对的goto语句对应的nvar不能小于label语句对应的nvar,否则说明在goto和lable语句之间有新的局部变量定义,也就是goto跳转进了局部变量的作用域。
匹配goto语句和lable的实现方式有2种:
-
block结束时一次性匹配:
- 遇到goto语句,新建
GotoLabel
加入列表,并生成占位Jump字节码; - 遇到label语句,新建
GotoLabel
加入列表。
最后在block结束时,一次性匹配,并修复占位字节码。
- 遇到goto语句,新建
-
实时匹配:
- 遇到goto语句,从现有的label列表中尝试匹配,如果匹配成功则直接生成完整的Jump字节码;否则新建
GotoLabel
,并生成占位Jump字节码; - 遇到label语句,从现有的的goto列表中尝试匹配,如果匹配到则修复对应的占位字节码;由于后续还可能有其他goto语句调整至此,所以仍然需要新建
GotoLabel
。
分析完毕后就能完成所有的匹配。
- 遇到goto语句,从现有的label列表中尝试匹配,如果匹配成功则直接生成完整的Jump字节码;否则新建
可以看到实时匹配虽然略微复杂一点点,但是更内聚,无需最后再执行一个收尾函数。但是这个方案有个大问题:很难判断非void语句。比如本节开头的例子中,解析到continue
label时并不能判断后续还有没有其他非void语句。如果有,则是非法跳转。只有解析到block结束才能判断。而在第一个一次性匹配的方案里,是在block结束时做匹配,这个时候就可以方便判断非void语句了。所以,我们这里选择一次性匹配。需要说明的是,在后续介绍Upvalue时会发现一次性匹配方案是有缺陷的。
介绍完上述细节后,语法分析总体流程如下:
- 进入block后,首先记录下之前(外层)的goto和label的个数;
- 解析block,记录goto和label语句信息;
- block结束前,把这个block内出现的goto语句和所有的(包括外层)label语句做匹配:如果有goto语句没匹配到,则仍然放回到goto列表中,因为可能是跳转到block退出后外层定义的label;最后删去block内定义的所有label,因为退出block后就不应该有其他goto语句跳转进来。
- 在整个Lua chunk结束前,判断goto列表是否为空,如果不为空则说明有的goto语句没有目的地,报错。
对应代码如下:
在解析block开始记录外层已有的goto和label数量;并在block结束之前匹配并清理内部定义的goto和label:
fn block_scope(&mut self) -> Token {
let igoto = self.gotos.len(); // 记录之前外层goto个数
let ilabel = self.labels.len(); // 记录之前外层lable个数
loop {
// 省略其他语句分析
t => { // block结束
self.close_goto_labels(igoto, ilabel); // 退出block前,匹配goto和label
break t;
}
}
}
具体的匹配代码如下:
// 参数igoto和ilable是当前block内定义的goto和label的起始位置
fn close_goto_labels(&mut self, igoto: usize, ilabel: usize) {
// 尝试匹配 “block内定义的goto” 和 “全部label”。
let mut no_dsts = Vec::new();
for goto in self.gotos.drain(igoto..) {
if let Some(label) = self.labels.iter().rev().find(|l|l.name == goto.name) { // 匹配
if label.icode != self.byte_codes.len() && label.nvar > goto.nvar {
// 检查是否跳转进局部变量的作用域。
// 1. label对应的字节码不是最后一条,说明后续有非void语句
// 2. label对应的局部变量数量大于goto的,说明有新定义的局部变量
panic!("goto jump into scope {}", goto.name);
}
let d = (label.icode as isize - goto.icode as isize) as i16;
self.byte_codes[goto.icode] = ByteCode::Jump(d - 1); // 修复字节码
} else {
// 没有匹配上,则放回去
no_dsts.push(goto);
}
}
self.gotos.append(&mut no_dsts);
// 删除函数内部定义的label
self.labels.truncate(ilabel);
}
最后,在chunk解析完毕前,检查所有goto都已经匹配上:
fn chunk(&mut self) {
assert_eq!(self.block(), Token::Eos);
if let Some(goto) = self.gotos.first() {
panic!("goto {} no destination", &goto.name);
}
}
至此完成goto语句。
逻辑运算和关系运算
本章介绍逻辑运算和关系运算。这两类运算都有两个应用场景:条件判断和求值。比如下面的代码:
-- 逻辑运算
if a and b then -- 条件判断
print(t.k or 0) -- 求值
end
-- 关系运算
if a > b then -- 条件判断
print(c > d) -- 求值
end
-- 逻辑运算和关系运算结合
if a > b and c < d then -- 条件判断
print (x > 0 and x or -x) -- 求值
end
这两个场景下的解析方式略有差别。一般说来,条件判断出现的情况明显多于求值,所以本章在介绍这两类运算时,都是先介绍在条件判断场景下的解析,并针对其进行优化;然后再完成求值场景。
条件判断的场景是源于上一章的控制结构,这也是在第5章数值运算后没有立即介绍这两类运算,而必须在控制结构之后才介绍的原因。
条件判断中的逻辑运算
逻辑运算包括3个:与and、或or、非not。其中最后一个“非not”是一元运算,已经在之前一元运算一节中介绍过了。本章只介绍前面两个“与and”和“或or”。
那为什么没有在之前的二元运算一节中介绍与and和或or呢?因为“短路”!在主流编程语言(比如C、Rust)中逻辑运算都是短路的。比如对于与and运算,如果第一个操作数是false,那么就没必要(也不能)求第二个操作数了。比如语句is_valid() and count()
,假如is_valid()
的返回值是false,那么就不能执行后续的count()
。所以,逻辑运算的执行过程是:1.先判断左操作数,2.如果是false则退出,3.否则判断右操作数。而之前介绍二元数值运算的执行过程是:1.先求左操作数,2.再求右操作数,3.最后计算。可见逻辑运算跟数值运算的流程不同,不能套用之前的做法。
在具体介绍逻辑运行之前,先来看逻辑运算的两个使用场景:
- 作为判断条件,比如上一章中if、while等语句中的判断条件语句,比如
if t and t.k then ... end
; - 求值,比如
print(v>0 and v or -v)
。
其实第1种场景可以看做是第2种场景的一种特殊情况。比如上述的if语句例子,就等价于下面的代码:
local tmp = t and t.k
if tmp then
...
end
就是先对t and t.k
这个运算语句进行求值,然后把值放到临时变量中,最后再判断这个值的真假来决定是否跳转。但是,这里我们其实并不关心具体的求值结果是t
还是t.k
,而只关心true或者false,所以可以省去临时变量!下面可以看到省去临时变量可以省掉一个字节码,是很大的优化。由于逻辑运算大部分应用是第1种场景,所以是值得把这个场景从第2种通用场景中独立出来特地做优化的,通过省去临时变量,直接根据求值结果来判断是否跳转。
如本节标题所示,本节只介绍第1种场景;下一节再介绍第2种场景。
跳转规律
上面介绍了逻辑运算的短路特性,在每次判断完一个操作数后,都可能发生跳转,跳过下一个操作数。逻辑运算最终对应的字节码,就是根据每个操作数做跳转。不同的运算组合就会导致各种各样的跳转组合。现在就要从各种跳转组合中归纳出跳转规律,以便用作后续的解析规则。这可能是整个解释器中最绕的一部分。
下面都用最简单的if语句作为应用场景,并先看最基础的and和or运算。下面两图分别是if A and B then ... end
和if X or Y then ... end
的跳转示意图:
A and B X or Y
+-------+ +-------+
| A +-False-\ /--True-+ X |
+---+---+ | | +---+---+
|True | | |False
V | | V
+-------+ | | +-------+
| B +-False>+ | | Y +-False-\
+---+---+ | | +---+---+ |
|True | \---------->|True |
V | V |
block | block |
| | | |
+<----------/ +<--------/
V V
左图是与and运算。两个操作数A和B判断后的处理一样,都是True则继续执行;False则跳转到代码块结尾。
右图是或or运算。两个操作数的处理流程不一样。第一个操作数X的处理是:False则继续执行,True则跳转到下面代码块开始。而第二个操作数Y的处理跟之前A、B的处理方式一样。
不过只看这两个例子是总结不出通用规律的。还需要看些复杂的:
A and B and C X or Y or Z (A and B) or Y A and (X or Y)
+-------+ +-------+ +-------+ +-------+
| A +-False-\ /--True-+ X | | A |-False-\ | A +-False-\
+---+---+ | | +---+---+ +---+---+ | +---+---+ |
|True | | |False |True | |True |
V | | V V | V |
+-------+ | | +-------+ +-------+ | +-------+ |
| B +-False>+ +<-True-+ Y | /--True-+ B | | /--True-+ X | |
+---+---+ | | +---+---+ | +---+---+ | | +---+---+ |
|True | | |False | False|<---------/ | |False |
V | | V | V | V |
+-------+ | | +-------+ | +-------+ | +-------+ |
| C +-False>+ | | Z +-False-\ | | Y +-False-\ | | Y +-False>+
+---+---+ | | +---+---+ | | +---+---+ | | +---+---+ |
|True | \---------->|True | \---------->|True | \---------->|True |
V | V | V | V |
block | block | block | block |
| | | | | | | |
+<---------/ +<----------/ +<---------/ +<---------/
V V V V
根据这4个图可以归纳如下规律(这里省略了归纳的具体步骤。实际中可能需要更多的例子才能归纳,但是举太多例子又太多臃肿):
-
跳转条件取决于语句(比如上面例子中的A,B,X,Y等)后面的逻辑运算符(也就是and或者or):
-
如果后面跟
and
运算,则False跳转而True继续执行。比如第1个图中的A和B,后面都是and运算,所以都是False跳转。 -
如果后面跟
or
运算,则True跳转而False继续执行。比如第2个图中的X和Z,后面都是or运算,所以都是True跳转。 -
如果后面没有逻辑运算符,也就是整条判断语句结束,则False跳转而True继续执行。这个规则跟上面
and
的相同。上面4个图最后一个判断语句都是如此。
-
-
跳转目标位置的规则:
-
如果连续相同的跳转条件,则跳转到同样位置。比如第1个图中连续3个False跳转,第2个图中连续2个True跳转;而第3个图中的两个False跳转并不连续,所以跳转位置不同。那么在语法分析时,如果两个操作数具有相同的跳转条件,就合并跳转列表。
-
如果遇到不同的跳转条件,则终结前面的跳转列表,并跳转到当前判断语句之后。比如第2个图中Z的False终结前面的两个True的跳转列表,并跳转到Z语句后面;再比如第3个图中B的True终结之前的False跳转列表,并跳转到B语句后面。
-
不过第4个图貌似没有遵守上述两条规则,两个False跳转并不连续但也连起来了,或者说X的True跳转并没有终结A的False跳转列表。这是因为A并不是跟
X
运算,而是跟(X or Y)
运算;要先求(X or Y)
,此时X的True跳转是全新的,并不知道前面的A的False跳转列表;然后再求A and (X or Y)
时,就是True和False两个跳转列表并存了;最终语句结束的False,合并之前A的False跳转列表,并终结X的True跳转列表。 -
判断语句的结束对应的是False跳转,所以会终结True跳转列表,并继续False跳转列表。在block结束后,终结False跳转列表到block结尾。上面4个图中都是如此。
-
至此,介绍完准备知识。下面开始编码实现。
字节码
上一章控制结构的几个条件判断语句,包括if、while、和repeat..until等,对判断条件的处理都是False跳转,所以只有一个测试并跳转的字节码,即Test
。而现在需要2种跳转,False跳转和True跳转。为此我们去掉之前的Test
,并新增2个字节码:
pub enum ByteCode {
TestAndJump(u8, i16), // 如果Test为True,则Jump。
TestOrJump(u8, i16), // 如果Test为False,则Jump。跟上一章的`Test`功能相同。
命名中的“And”和“Or”,跟本节介绍的逻辑运算并无关系,而是源自Rust语言中Option和Error类型的方法名,分别是“那么就”和“否则就”的意思。不过本节最开头的两个例子中,t and t.k
可以描述为:如果t存在“那么就”取t.k,t.k or 100
可以描述为:如果t.k存在就取其值“否则就”取100。也可以说是相关联的。
只不过上面介绍的跳转规则第1条,如果后面跟and
运算,则False跳转,对应的是TestOrJump
。这里的and
和Or
没有对应上,不过关系不大。
官方Lua实现中,仍然只是一条字节码TEST
,关联两个参数分别是:判断条件的栈地址(跟我们的一样),和跳转条件(True跳转还是False跳转)。而具体的跳转位置,则需要再加一条无条件跳转的JUMP
字节码。看上去2条字节码不太高效。这么做是为了跟另外一个应用场景,在下一节中介绍。
ExpDesc
在解析逻辑运算符生成跳转字节码时,还不知道跳转的目的位置。只能先生成一个字节码占位,而留空跳转位置的参数。在后续确定目的位置后再填补参数。这个做法跟上一章介绍控制结构时是一样的。而不一样的是,上一章里只会有1个跳转字节码,而这次可能会出现多个字节码拉链的情况,比如上面的第1个图,3个字节码跳转到同一位置。这个拉链可能是True跳转,也可能是False跳转,也可能这两条链同时存在,比如上面第4个图中解析到Y时候。所以需要一个新的ExpDesc类型来保存跳转链表。为此,新增Test
类型,定义如下:
enum ExpDesc {
Test(usize, Vec<usize>, Vec<usize>), // (condition, true-list, false-list)
关联3个参数。第1个是判断条件在栈上的位置,无论什么类型(常量、变量、表索引等)都会先discharge到栈上,然后再判断真假。后面2个参数是True和False这2条跳转链表,内容分别是需要补齐的字节码的位置。
Lua官方实现中,跳转表是通过跳转字节码中留空的参数来实现的。比如上面第1个图中连续3个False的跳转,判断A、B、C生成的字节码分别是JUMP 0
, JUMP $A
, JUMP $B
,然后在ExpDesc中保存$C
。这样通过$C
就可以找到$B
,通过$B
就可以找到$A
,而参数0
表示链表末尾。最后一边遍历,一边统一修复为JUMP $end
。这种设计很高效,无需额外存储,利用暂时留空的Jump参数就可以实现拉链。同时也略显晦涩,容易出错。这种充分利用资源,按bit微操内存,是很典型的C语言项目的做法。而Rust语言标准库中提供了列表Vec,虽然会产生在堆上的内存分配,稍微影响性能,但是逻辑清晰很多,一目了然。只要不是性能瓶颈,就应该尽量避免晦涩而危险的做法,尤其是在使用追求安全的Rust语言时。
语法分析代码
现在终于可以语法分析了。从exp()
函数的二元运算部分开始。之前介绍二元数值运算的求值顺序,要先处理第一个操作数。本节开头也介绍了,对于逻辑运算的处理顺序,由于短路的特性,也要先处理第一个操作和可能的跳转,然后才能解析第二个操作数。所以,在继续解析第二个操作数前,先处理跳转:
fn preprocess_binop_left(&mut self, left: ExpDesc, binop: &Token) -> ExpDesc {
match binop {
Token::And => ExpDesc::Test(0, Vec::new(), self.test_or_jump(left)),
Token::Or => ExpDesc::Test(0, self.test_and_jump(left), Vec::new()),
_ => // 省略discharge其他类型的部分
}
}
这个函数中,新增了对逻辑运算的处理部分。以and为例,生成ExpDesc::Test
类型,临时保存处理后的2条跳转列表,而关联的第1个参数没有用,这里填0。调用test_or_jump()
函数来处理跳转列表。按照上面介绍的规则,and运算符对应的是False跳转,是会终结之前的True跳转列表,所以test_or_jump()
函数会终结之前的True跳转列表,并只返回False跳转列表。那么这里就新建一个列表Vec::new()
作为True跳转列表。
再看test_or_jump()
的具体实现:
fn test_or_jump(&mut self, condition: ExpDesc) -> Vec<usize> {
let (icondition, true_list, mut false_list) = match condition {
// 为True的常量,无需测试或者跳转,直接跳过。
// 例子:while true do ... end
ExpDesc::Boolean(true) | ExpDesc::Integer(_) | ExpDesc::Float(_) | ExpDesc::String(_) => {
return Vec::new();
}
// 第一个操作数已经是Test类型,说明这不是第一个逻辑运算符。
// 直接返回已有的两个跳转列表即可。
ExpDesc::Test(icondition, true_list, false_list) =>
(icondition, Some(true_list), false_list),
// 第一个操作数是其他类型,说明这是第一个逻辑运算符。
// 只需要discharge第一个操作数到栈上即可。
// 之前也没有True跳转列表,所以返回None。
// 也没有False跳转列表,所以新建一个列表,用来保存本次跳转指令。
_ => (self.discharge_any(condition), None, Vec::new()),
};
// 生成TestOrJump,但第二个参数留空
self.byte_codes.push(ByteCode::TestOrJump(icondition as u8, 0));
// 把刚生成的字节码,假如到False跳转列表中,以便后续修复
false_list.push(self.byte_codes.len() - 1);
// 终结之前的True跳转列表,并跳转到这里,如果有的话
if let Some(true_list) = true_list {
self.fix_test_list(true_list);
}
// 返回False跳转列表
false_list
}
对于or运算符和对应的test_and_jump()
函数,大同小异,只是翻转下True和False跳转列表。这里不再介绍。
处理完第一个操作数和跳转后,再来处理第二个操作数就很简单了,只需要连接跳转列表即可:
fn process_binop(&mut self, binop: Token, left: ExpDesc, right: ExpDesc) -> ExpDesc {
match binop {
// 省略其他二元运算符处理
Token::And | Token::Or => {
// 第一个操作数已经在上面的preprocess_binop_left()中被转换为ExpDesc::Test
if let ExpDesc::Test(_, mut left_true_list, mut left_false_list) = left {
let icondition = match right {
// 如果第二个操作数也是Test类型,比如本节上面第4个图中`A and (X or Y)`的例子,
// 那么分别连接两个跳转列表。
ExpDesc::Test(icondition, mut right_true_list, mut right_false_list) => {
left_true_list.append(&mut right_true_list);
left_false_list.append(&mut right_false_list);
icondition
}
// 如果第二个操作数是其他类型,则无需处理跳转链表
_ => self.discharge_any(right),
};
// 返回连接后想新跳转列表
ExpDesc::Test(icondition, left_true_list, left_false_list)
} else {
panic!("impossible");
}
}
处理完二元运算部分,接下来就是应用场景。本节只介绍作为判断条件的应用场景,而在下一节中再介绍求值。上一章中的几个控制结构语句(if、while、repeat..until等)都是直接处理跳转字节码,代码逻辑类似。本节开头介绍的跳转规则中,整条逻辑运算的判断语句结束,是False跳转,所以调用刚才介绍的test_or_jump()函数处理,可以代替并简化上一章的直接处理字节码的代码逻辑。这里仍然用if语句为例:
fn do_if_block(&mut self, jmp_ends: &mut Vec<usize>) -> Token {
let condition = self.exp();
// 上一章,这里是生成Test字节码。
// 现在,替换并简化为test_or_jump()函数。
// 终结True跳转列表,并返回新的False跳转列表。
let false_list = self.test_or_jump(condition);
self.lex.expect(Token::Then);
let end_token = self.block();
if matches!(end_token, Token::Elseif | Token::Else) {
self.byte_codes.push(ByteCode::Jump(0));
jmp_ends.push(self.byte_codes.len() - 1);
}
// 上一章,这里是修复刚才生成的一条Test字节码。
// 现在,需要修改一条False跳转列表。
self.fix_test_list(false_list);
end_token
}
至此完成语法分析部分。
虚拟机执行
虚拟机执行部分,首先是要处理新增的2个字节码,都很简单,这里忽略不讲。需要讲的是一个栈操作的细节。之前向栈上赋值时的函数如下:
fn set_stack(&mut self, dst: u8, v: Value) {
let dst = dst as usize;
match dst.cmp(&self.stack.len()) {
Ordering::Equal => self.stack.push(v),
Ordering::Less => self.stack[dst] = v,
Ordering::Greater => panic!("fail in set_stack"),
}
}
首先判断目标地址dst是否在栈的范围内:
- 如果在,则直接赋值;
- 如果不在并且刚好是下一个位置,则使用
push()
压入栈中; - 如果不在,并且超过下一个位置,之前是不可能出现的,所以调用
panic!()
。
但是逻辑运算的短路特性,是可能导致上述第3种情况出现的。比如下面的语句:
if (g1 or g2) and g3 then
end
按照我们的解析方式,会生成如下临时变量,占用栈上位置:
| |
+------+
| g1 |
+------+
| g2 |
+------+
| g3 |
+------+
| |
但在执行过程中,如果g1
为真,则会跳过对g2
的处理,而直接处理g3
,此时上图中g2的位置并未设置,那么g3就会超过栈顶的位置,如下图所示:
| |
+------+
| g1 |
+------+
| |
: :
: : <-- 设置g3,超过栈顶位置
所以,要修改上述set_stack()
函数,支持设置超过栈顶的元素。这可以通过调用set_vec()
实现。
测试
至此,完成了逻辑运算在条件判断中的应用场景。可以通过本节开头的几个图中的例子来测试。这里省略。
求值中的逻辑运算
上一节介绍了逻辑运算在条件判断中的应用。这一节介绍另外一个应用场景,即在求值时的处理。
上一节中,逻辑运算在 条件判断 场景中的语法分析过程可以分为两部分:
-
处理逻辑运算本身,具体说就是在
exp()
函数中遇到and或or运算符后,生成对应的字节码并处理True和False跳转列表; -
在整条逻辑运算语句解析完毕后,把解析结果放到if等语句的条件判断场景中,首先终结True跳转列表,然后在block结束后终结False跳转列表。
而在本节要介绍的 求值 场景中,也是分为两部分:
-
处理逻辑运算本身,这部分跟上一节完全一样;
-
在整条逻辑运算语句解析完毕后,对语句求值,这就是本节中要介绍的部分。
如下图所示,上一节完成了(a)和(b)部分,本节在(a)的基础上,实现(c)部分。
+------------+
/--->| (b)条件判断 |
+---------------+ ExpDesc::Test | +------------+
| (a)处理逻辑运算 |------------------>+
+---------------+ | +---------+
\--->| (c)求值 |
+---------+
结果类型
Lua中的逻辑运算跟C、Rust中的逻辑运算有所不同。C和Rust语言中逻辑运算的结果是布尔类型,只分真假,比如下面C语言代码:
int i=10, j=11;
printf("%d\n", i && j); // 输出:1
会输出1
,因为&&
运算符会先把两个操作数转换为布尔类型(这个例子里都是true),然后再执行&&
运算,结果是true,在C语言里就是1。而Rust语言更严格,强制要求&&
的两个操作数都必须是布尔类型,那么结果自然也是布尔类型。
但是Lua中的逻辑运算的求值结果是最后一个 求值 的操作数。比如下面都是很常见的用法:
-
print(t and t.k)
,先判断t是否存在,再对t求索引。如果t不存在那么就不用判断t.k了,所以结果就是t即nil;否则就是t.k。 -
print(t.k or 100)
,索引表并提供默认值。先判断t中是否有k,如果有那么就不用判断100了,所以结果就是t.k;否则就是100。 -
print(v>0 and v or -v)
,求绝对值。如果是正数则结果是v,否则就是-v。模拟C语言中的?:
三元运算符。
求值规律
为了更清楚地理解“逻辑运算的求值结果是最后一个求值的操作数”这句话,下面通过一些例子展示。这里仍然用上一节开头的流程图为例。先看最基本的运算:
A and B X or Y
+-------+ +-------+
| A +-False-\ /--True-+ X |
+---+---+ | | +---+---+
|True | | |False
V | | V
+-------+ | | +-------+
| B | | | | Y |
+---+---+ | | +---+---+
|<----------/ \---------->|
V V
左图中,如果A为False,则求值结果即为A;否则,对B求值,由于B是最后一个操作数,所以无需再做判断,B就是求值结果。
右图中,如果X为True,则求值结果即为X;否则,对Y求值,由于Y是最后一个操作数,所以无需再做判断,Y就是求值结果。
再来看几个复杂的例子:
A and B and C X or Y or Z (A and B) or Y A and (X or Y)
+-------+ +-------+ +-------+ +-------+
| A +-False-\ /--True-+ X | | A |-False-\ | A +-False-\
+---+---+ | | +---+---+ +---+---+ | +---+---+ |
|True | | |False |True | |True |
V | | V V | V |
+-------+ | | +-------+ +-------+ | +-------+ |
| B +-False>+ +<-True-+ Y | /--True-+ B | | /--True-+ X | |
+---+---+ | | +---+---+ | +---+---+ | | +---+---+ |
|True | | |False | False|<---------/ | |False |
V | | V | V | V |
+-------+ | | +-------+ | +-------+ | +-------+ |
| C | | | | Z | | | Y | | | Y | |
+---+---+ | | +---+---+ | +---+---+ | +---+---+ |
|<---------/ \---------->| \---------->| \---------->|<---------/
V V V V
这里省略根据这4个图归纳总结的过程,直接给出求值的规则:
-
最后一个操作数无需判断,只要前面的判断没有跳过这最后的操作数,那么这最后的操作数就是最终的求值结果。比如上面第1个图中,如果A和B都是True,就会执行到C,那么C就是整条语句的求值结果。C本身是不需要再做判断的。
-
在语法分析阶段,整条逻辑运算语句解析结束后,没有被终结的跳转列表上的操作数都可能作为最终的求值结果。这个说法比较绕,下面举例说明。比如上面第1个图中,A和B的True跳转列表分别终结在B和C,但是False跳转列表都没有终结,那么A和B都可能是最终的求值结果,比如A如果是False那么A就是最终求值结果。再举个反例,比如上面第3个图中的A的True和False两个跳转列表分别终结在B和Y,也就是说在整条语句解析完毕的时候,A的跳转列表都终结了,那么A就不可能是求值结果,无论哪种情况A都不会走到语句结尾。除了这第3个图以外其他图中的所有判断条件都可能作为最终的求值结果。
总结出求值的规则后,下面开始编码实现。
ExpDesc
上一节中引入了表示逻辑运算的新ExpDesc类型,定义如下:
enum ExpDesc {
Test(usize, Vec<usize>, Vec<usize>), // (condition, true-list, false-list)
后面两个参数分别表示两个跳转链表,这里不做介绍,主要关注第一个参数:判断条件语句在栈上的位置。上一节中说过,所有的语句(比如变量、常量、表索引等)要判断真假,都要先discharge到栈上,所以这里使用usize
类型的栈索引表示语句即可。这在上一节里是没问题的,但是在这一节里的求值场景下,如上面所述,最后一个操作数是无需判断的,所以就可能不需要discharge到栈上。比如下面的例子:
local x = t and t.k
按照现在的做法,是先把后面第2个操作数t.k discharge到栈上临时变量;如果t为真,则通过Move
字节码把临时变量赋值给x。很明显这个临时变量是不需要的,是可以把t.k直接赋值给x的。为此,我们需要对条件语句延迟求值,或者说延迟discharge。那么就需要改造ExpDesc::Test
类型。
Lua官方的做法是,给ExpDesc的所有类型都配上两个跳转列表:
typedef struct expdesc {
expkind k; // 类型tag
union {
// 各种expkind关联的数据,这里省略
} u;
int t; /* patch list of 'exit when true' */
int f; /* patch list of 'exit when false' */
} expdesc;
上述代码中的t
和f
分别是True和False的跳转列表。但是在Rust语言中也这么定义的话,就有点不方便。因为Rust的enum是包括了tag和关联数据的,对应上面的k
和u
,本来一个enum就可以定义ExpDesc;但如果增加两个跳转列表,就需要再在外面封装一层struct定义了。而且Rust语言中定义struct变量时必须显式初始化所有成员,那么现在代码里所有定义ExpDesc的地方,都要初始化t
和f
为Vec::new()。为了这一个类型而影响其他类型,实在不值得。
我们的做法是递归定义。把ExpDesc::Test
的第一个参数类型,从usize
修改为ExpDesc
。当然不能直接定义,而是需要封装一层Box指针:
enum ExpDesc {
Test(Box<ExpDesc>, Vec<usize>, Vec<usize>), // (condition, true-list, false-list)
这么定义,对现有代码中其他类型的ExpDesc完全没有影响。对现有代码中的Test
类型,也只需要去掉discharge的处理即可。
字节码
上一节中新增的两个字节码TestAndJump
和TestOrJump
的功能都是:“测试”+“跳转”。而我们现在需要的功能是:“测试”+“赋值”+“跳转”。为此,我们再新增2个字节码:
pub enum ByteCode {
Jump(i16),
TestAndJump(u8, i16),
TestOrJump(u8, i16),
TestAndSetJump(u8, u8, u8), // 新增
TestOrSetJump(u8, u8, u8), // 新增
TestAndSetJump
的功能是:如果测试第1个参数的栈索引的值为真,则赋值到第2个参数的栈位置,并跳转到第3个参数的字节码位置。TestOrSetJump
的类似。
这里带来一个问题。之前的跳转字节码(上面代码中的前3个)中,跳转参数都是2个字节,i16
类型,可以跳转范围很大。而新增的2个字节码都关联了3个参数,那么留给跳转参数的只剩一个字节了。
这也就是为什么上一节中提到的,Lua官方实现中,用了2个字节码来表示条件跳转指令。比如跟TestAndJump(t, jmp)
相对的,就是 TEST(t, 0); JUMP(jmp)
;而本节介绍的求值场景中,需要新增一个目标地址参数dst,就是TESTSET(dst, t, 0); JUMP(jmp)
。这样就保证跳转参数有2个字节空间。并且,虽然是2条字节码,但是在虚拟机执行过程中,在执行到TEST
或TESTSET
字节码时,如果需要跳转,那么可以直接取下一条字节码JUMP的参数并执行跳转,而无需再为JUMP执行一次指令分发。相当于是1条字节码,而JUMP只是作为扩展参数,所以并不影响执行时的性能。
但我们这里仍然使用1条字节码,并使用1个字节来表示跳转参数。上一节的条件判断场景中,最后一个操作数的判断是要跳转到整个block结尾处,跳转距离可能很长,是需要2字节空间的。而本节的求值场景中,只是在逻辑运算语句内部跳转,可以参考上面的6个图,跳转距离不会很长;而且由于只会向前跳转,无需表示负数。所以1个字节u8
类型表示256距离足够覆盖。条件允许的情况下,1条字节码总归是比2条要好的。
语法分析
介绍完上面的修改点后,现在开始语法分析。所谓求值,就是discharge。所以只需要完成discharge()
函数中ExpDesc::Test
类型即可。上一节中,这里是没有完成的。具体的discharge方法是:先discharge递归定义的条件语句,然后修复两条跳转列表中的判断字节码。
fn discharge(&mut self, dst: usize, desc: ExpDesc) {
let code = match desc {
// 省略其他类型
ExpDesc::Test(condition, true_list, false_list) => {
// fix TestSet list after discharging
self.discharge(dst, *condition); // 先discharge递归定义的条件语句
self.fix_test_set_list(true_list, dst); // 修复True跳转列表中的判断字节码
self.fix_test_set_list(false_list, dst); // 修复False跳转列表中的判断字节码
return;
}
修复跳转列表fix_test_set_list()
函数需要做2件事情:
- 填充之前留空的跳转参数;
- 把之前生成的
TestAndJump
和TestOrJump
字节码,分别替换为TestAndSetJump
和TestOrSetJump
。
具体代码如下:
fn fix_test_set_list(&mut self, list: Vec<usize>, dst: usize) {
let here = self.byte_codes.len();
let dst = dst as u8;
for i in list.into_iter() {
let jmp = here - i - 1; // should not be negative
let code = match self.byte_codes[i] {
ByteCode::TestOrJump(icondition, 0) =>
if icondition == dst { // 如果条件语句刚好就在目标位置,就不需要改为TestAndSetJump
ByteCode::TestOrJump(icondition, jmp as i16)
} else { // 修改为TestAndSetJump字节码
ByteCode::TestOrSetJump(dst as u8, icondition, jmp as u8)
}
ByteCode::TestAndJump(icondition, 0) =>
if icondition == dst {
ByteCode::TestAndJump(icondition, jmp as i16)
} else {
ByteCode::TestAndSetJump(dst as u8, icondition, jmp as u8)
}
_ => panic!("invalid Test"),
};
self.byte_codes[i] = code;
}
}
测试
至此,完成了逻辑运算在求值中的应用场景。可以通过本节开头的几个图中的例子来测试。这里省略。
条件判断中的关系运算
前面两节介绍了逻辑运算,接下来两节介绍关系运算。
关系运算,即比较大小,共6个运算符:等于、不等、大于、小于、大于等于、小于等于。前两节介绍逻辑运算时说过,逻辑运算不能用第5章中的二元数值运算的解析流程,是因为短路这个特性。而关系运算也没有用第5章的解析流程,是不同的原因:为了性能。
如果不考虑性能,关系运算是可以用第5章的解析流程的。比如对于等于运算,可以生成如下字节码:EQ $r $a $b
,即比较a和b,并把布尔类型的结果赋值给r。如果要考虑性能,就要看关系运算的应用场景。这部分跟前两节介绍的逻辑运算几乎一样,也有两个应用场景:
- 作为判断条件,比如上一章中if、while等语句中的判断条件语句,比如
if a == b then ...
; - 求值,比如
print(a == b)
。
跟逻辑运算一样,第1种场景可以看做是第2种场景的简化版,不需要具体求值,只需要判断真假。比如上述的if语句例子,也可以按照第2种场景来解释,认为是先对a == b
求值到临时变量,然后再判断临时变量是否为真,来决定是否跳转。这里可以省去临时变量!由于关系运算大部分应用是第1种场景,所以是值得把这个场景从第2种通用场景中独立出来特地做优化的,通过省去临时变量,直接根据求值结果来判断是否跳转。
如本节标题所示,本节只介绍第1种场景;下一节再介绍第2种场景。
字节码
还是用if语句和等于运算为例,在if a == b then ... end
场景下,最先想到的字节码序列如下:
EQ $tmp $a $b # 比较a和b是否相等,结果存在临时变量中
TEST $tmp $jmp # 根据临时变量来决定是否跳转
现在要省去临时变量$tmp,合并两条字节码,如下:
EQ $a $b $jmp # 比较a和b是否相等,来决定是否跳转
但问题是这样需要3个参数,留给最后的跳转参数的只有1个字节的空间,表示范围太小了。为此可以再拆成2个字节码:
EQ $a $b # 判断a和b是否相等,如果相等则跳过下一条语句,即pc++
JUMP $jmp # 无条件跳转
这样就可以用2个字节来表示跳转参数了。但是,既然还是需要2条字节码,那跟最开始的“EQ+TEST”方案,又有什么区别呢?搞这么复杂是为了什么呢?
-
虚拟机执行时,如果判断a和b相等,跳过下面JUMP字节码,那么就只执行了1条字节码;而最开始的“EQ+TEST”方案总是会执行2条字节码。对于if语句判断为真的概率不知道,但对于while语句判断为真的概率还是很大的,所以这里相当于是大概率省去了1条字节码的执行;
-
即便判断为假,需要执行下面的JUMP字节码,那么也可以在执行EQ字节码的时候,直接读取下一条字节码,而不用再走一次指令分发。这里的JUMP字节码相当于是EQ字节码的扩展参数,而不是一条独立执行的字节码。Lua的官方实现就是这么做的,这也是因为C语言中可以忽略字节码的类型,通过位运算直接读取字节码中的参数。但是在Rust语言中如果不用unsafe,是不能忽略enum的tag而直接读取参数的,所以我们的解释器里不能实现这个优化。
-
根据判断结果可以直接决定是否跳转。而最开始的“EQ+TEST”方案,需要先把判断结果写入栈上临时变量,然后在TEST字节码执行时再读取临时变量,然后再次判断真假,这样就多了一次临时变量的读和写,也多了一次真假的判断。
优势呢就是这么个优势。有,但不多。尤其是跟其带来的实现复杂度相比,就更显得少了。最开始的“EQ+TEST”方案只需要在之前介绍的二元数值运算中,增加几个运算符即可;但新的方案需要跟前面讲的逻辑运算配合。不过我们还是选择跟随Lua官方实现,用实现的复杂度换一些执行效率优化。
另外,关于字节码中两个操作数的类型,按照之前字节码参数类型的说明,跟二元数值运算的字节码相似,每个关系运算符也都对应3个字节码,比如对于相等运算符有:Equal
、EqualInt
和EqualConst
共3个字节码。一共6个关系运算符,就是18个字节码。
跟逻辑运算相结合
关系运算和逻辑运算相结合是非常常见的。以a>b and b<c
语句为例,按照前面两节的介绍,这是一条逻辑运算语句,两个操作数分别是a>b
和b<c
,需要把这两个操作数discharge到栈上临时变量以便判断真假。这里为了避免使用临时变量,就需要让关系运算和逻辑运算互相配合。
对于关系运算语句,需要新增ExpDesc类型:Compare
。下面来看如果要跟逻辑运算相结合,即对于以关系运算为操作数的逻辑运算语句,那么这个类型需要关联什么参数。
首先,如果不转换为ExpDesc::Test
类型,那么Compare
类型就需要自己维护True和False两条跳转链表;
其次,对于True和False这两种跳转,之前的逻辑运算是通过2个字节码来区分的,TestAndJump
和TestOrJump
。对于关系运算,也可以这么做,比如等于运算用EqualTrue
和EqualFalse
字节码。但是关系运算符一共有18个字节码,如果还要每个字节码都再区分True和False跳转,那么需要36个字节码了。这就太多了。还好有另外一种方法,上面介绍的EQ
字节码只有2个参数,可以再增加一个布尔类型的参数,来表示True还是False跳转。
最后,对于True和False这两种跳转,是需要根据后面的逻辑运算符来决定的。比如上面的a>b and b<c
的例子,在解析到a>b
时还不能确定,只有解析到and
时才能确定。所以在解析关系运算语句时还不能生成完整的字节码,就只能先把相关信息存入Compare
类型中,然后在确定跳转类型后,再生成字节码。
综上,关系运算的新类型定义如下:
enum ExpDesc {
Compare(fn(u8,u8,bool)->ByteCode, usize, usize, Vec<usize>, Vec<usize>),
前面3个参数是字节码类型和前2个参数,用于在确定跳转类型后以生成字节码;后面2个参数是True和False跳转列表。整个类型相当于是BinaryOp
和Test
类型的结合。
这里跟前面介绍的逻辑运算遇到的是同样的问题,都是在生成字节码的时候还不能确定跳转的目的地址,不能立即生成完整的字节码,需要后续确定目的地址后再处理。但是,这里跟之前的逻辑运算的解决方法不一样。之前的逻辑运算的做法是:先生成一个字节码占位,而只把跳转目的地址的参数留空;后续确定目的地址后再修复字节码中的对应参数(fix_test_list()
函数)。而这里的关系运算的做法是,把信息都存到ExpDesc::Compare
中(导致这个类型的定义很长),然后等后续确定目的地址后再直接生成完整的字节码。
其实对于关系运算的处理,理论上也可以采用逻辑运算那种先生成字节码再修复的方法,但是关系运算对应的字节码有18个,太多了,如果还按照fix_test_list()
函数的做法先匹配再生成字节码,代码就显得太复杂了。如果是在C语言中,可以通过位操作直接修正字节码内的参数,而忽略字节码类型;而在Rust中直接修改enum内关联参数就需要unsafe了。
另外一个区别是,在解析逻辑运算时,必须立即生成字节码用来占位。而关系运算的Compare
类型操作数会在紧接着的test_or_jump()
函数中就确定跳转类型,就可以生成字节码了,所以并不需要占位,也就没必要先生成字节码然后再修复了。
语法分析
关系运算的语法分析分为两部分:
-
解析运算本身,根据运算符生成对应的
ExpDesc::Compare
,这部分跟二元数值运算类似,这里略过。 -
关系运算和逻辑运算的结合,即
ExpDesc::Compare
和ExpDesc::Test
的结合。在之前逻辑运算解析部分,都增加对ExpDesc::Compare
的处理。
比如在逻辑运算左操作数时,生成字节码,并处理两条跳转列表:
fn test_or_jump(&mut self, condition: ExpDesc) -> Vec<usize> {
let (code, true_list, mut false_list) = match condition {
ExpDesc::Boolean(true) | ExpDesc::Integer(_) | ExpDesc::Float(_) | ExpDesc::String(_) => {
return Vec::new();
}
// 新增Compare类型。
// 生成2个字节码。
// 两个跳转列表的处理方式跟下面的`ExpDesc::Test`的一样。
ExpDesc::Compare(op, left, right, true_list, false_list) => {
// 确定为True跳转,即关联的第3个参数,就可以生成完整字节码。
self.byte_codes.push(op(left as u8, right as u8, true));
// 生成Jump字节码,但还不知道跳转目的地址,需要后续修复。为此,
// fix_test_list()中要新增对Jump字节码的处理。
(ByteCode::Jump(0), Some(true_list), false_list)
}
ExpDesc::Test(condition, true_list, false_list) => {
let icondition = self.discharge_any(*condition);
(ByteCode::TestOrJump(icondition as u8, 0), Some(true_list), false_list)
}
_ => {
let icondition = self.discharge_any(condition);
(ByteCode::TestOrJump(icondition as u8, 0), None, Vec::new())
}
};
在比如在处理右操作数时:
fn process_binop(&mut self, binop: Token, left: ExpDesc, right: ExpDesc) -> ExpDesc {
match binop {
Token::And | Token::Or => {
if let ExpDesc::Test(_, mut left_true_list, mut left_false_list) = left {
match right {
// 新增Compare类型。
// 处理方式类似下面的`ExpDesc::Test`类型。
ExpDesc::Compare(op, l, r, mut right_true_list, mut right_false_list) => {
left_true_list.append(&mut right_true_list);
left_false_list.append(&mut right_false_list);
ExpDesc::Compare(op, l, r, left_true_list, left_false_list)
}
ExpDesc::Test(condition, mut right_true_list, mut right_false_list) => {
left_true_list.append(&mut right_true_list);
left_false_list.append(&mut right_false_list);
ExpDesc::Test(condition, left_true_list, left_false_list)
}
_ => ExpDesc::Test(Box::new(right), left_true_list, left_false_list),
}
} else {
panic!("impossible");
}
}
虚拟机执行
一共6种关系运算符。由于我们之前已经为Value
实现了Eq
trait,所以其中的等于和不等于运算可以使用==
和!=
来直接比较Value操作数。但对于另外4个运算符,就需要再给Value
实现新的trait了,就是PartialOrd
。之所以不是Ord
是因为不同类型的Value是不能比较大小的。而不需要使用PartialEq
是因为不同类型的Value是可以比较是否相等的,返回结果为False。比如对下面两条语句:
print (123 == 'hello') -- 打印false
print (123 > 'hello') -- 抛异常
Lua的大小比较运算符,只支持数字和字符串类型。所以Value
的PartialOrd
实现如下:
impl PartialOrd for Value {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
match (self, other) {
// numbers
(Value::Integer(i1), Value::Integer(i2)) => Some(i1.cmp(i2)),
(Value::Integer(i), Value::Float(f)) => (*i as f64).partial_cmp(f),
(Value::Float(f), Value::Integer(i)) => f.partial_cmp(&(*i as f64)),
(Value::Float(f1), Value::Float(f2)) => f1.partial_cmp(f2),
// strings
(Value::ShortStr(len1, s1), Value::ShortStr(len2, s2)) => Some(s1[..*len1 as usize].cmp(&s2[..*len2 as usize])),
(Value::MidStr(s1), Value::MidStr(s2)) => Some(s1.1[..s1.0 as usize].cmp(&s2.1[..s2.0 as usize])),
(Value::LongStr(s1), Value::LongStr(s2)) => Some(s1.cmp(s2)),
// strings of different types
(Value::ShortStr(len1, s1), Value::MidStr(s2)) => Some(s1[..*len1 as usize].cmp(&s2.1[..s2.0 as usize])),
(Value::ShortStr(len1, s1), Value::LongStr(s2)) => Some(s1[..*len1 as usize].cmp(s2)),
(Value::MidStr(s1), Value::ShortStr(len2, s2)) => Some(s1.1[..s1.0 as usize].cmp(&s2[..*len2 as usize])),
(Value::MidStr(s1), Value::LongStr(s2)) => Some(s1.1[..s1.0 as usize].cmp(s2)),
(Value::LongStr(s1), Value::ShortStr(len2, s2)) => Some(s1.as_ref().as_slice().cmp(&s2[..*len2 as usize])),
(Value::LongStr(s1), Value::MidStr(s2)) => Some(s1.as_ref().as_slice().cmp(&s2.1[..s2.0 as usize])),
(_, _) => None,
}
}
}
对于浮点数需要调用partial_cmp()
方法是因为浮点数的Nan不能比较大小。
实现了PartialOrd
trait的类型就可以直接使用>
、<
、>=
和<=
等几个比较大小的符号了。但是PartialOrd
对于大小比较其实有3个返回结果:真、假、和不能比较。对应于Lua语言就分别是真、假、和抛出异常。而上述4个比较符号只能给出2个结果,对于不能比较的情况也是返回假。所以为了能判断出不能比较的情况,我们不能直接使用这4个符号,还是要用原始的partial_cmp()
函数。下面是LesEq
和Less
两个字节码的执行代码:
ByteCode::LesEq(a, b, r) => {
let cmp = &self.stack[a as usize].partial_cmp(&self.stack[b as usize]).unwrap();
if !matches!(cmp, Ordering::Greater) == r {
pc += 1;
}
}
ByteCode::Less(a, b, r) => {
let cmp = &self.stack[a as usize].partial_cmp(&self.stack[b as usize]).unwrap();
if matches!(cmp, Ordering::Less) == r {
pc += 1;
}
}
这里用unwarp()
来抛出异常。后续在规范错误处理时,这里需要做改进。
求值中的关系运算
上一节介绍了关系运算在 条件判断 中的应用。这一节介绍另外一个应用场景,即在 求值 时的处理。
跟逻辑运算类似,处理求值中的关系判断,也只需要把上一节中解析得到的ExpDesc::Compare
discharge到栈上。如下图所示,上一节完成了(a)和(b)部分,本节在(a)的基础上,实现(c)部分。
+------------+
/--->| (b)条件判断 |
+---------------+ ExpDesc::Compare | +------------+
| (a)处理关系运算 |--------------------->+
+---------------+ | +---------+
\--->| (c)求值 |
+---------+
逻辑运算的求值,是把两条跳转链表中的TestAndJump
和TestOrJump
字节码,分别替换为TestAndSetJump
和TestOrSetJump
。对于关系运算,虽然也可以这么做,但是把18条字节码都增加个Set版本就太啰嗦了。我们这里参考Lua官方实现的方式。对于如下Lua代码:
print (123 == 456)
编译可得字节码序列:
luac -l tt.lua
main <tt.lua:0,0> (9 instructions at 0x6000037fc080)
0+ params, 2 slots, 1 upvalue, 0 locals, 1 constant, 0 functions
1 [1] VARARGPREP 0
2 [1] GETTABUP 0 0 0 ; _ENV "print"
3 [1] LOADI 1 456
4 [1] EQI 1 123 1
5 [1] JMP 1 ; to 7
6 [1] LFALSESKIP 1
7 [1] LOADTRUE 1
8 [1] CALL 0 2 1 ; 1 in 0 out
9 [1] RETURN 0 1 1 ; 0 out
其中第4、5条字节码为比较运算。关键就在于后面紧跟的两条字节码:
- 第6条字节码
LFALSESKIP
,专门用于对关系运算的求值,功能是向目标地址设置False,并跳过下一条语句; - 第7条字节码
LOADTRUE
,功能是加载True到目标地址。
这两条字节码再配合上面第4、5条字节码,就能实现求布尔值的功能:
- 假如第4条字节码比较结果为真,则执行第5条的JMP,跳过下一条语句,执行第7条语句,设置True;
- 假如第4条字节码比较结果为假,则跳过第5条,而执行第6条的LFALSESKIP,设置False并跳过下一条。
很巧妙,也很啰嗦。如果按照之前二元数值运算的方式,上面的功能只需要一条字节码:EQ $dst $a $b
。之所以现在搞的这么复杂,就是为了对关系运算在 条件判断 场景下进行优化,而牺牲了在 求值 场景下的性能,毕竟后者出现的太少了。
语法分析
求值过程,就是把ExpDesc::Compare
discharge到栈上,
fn discharge(&mut self, dst: usize, desc: ExpDesc) {
let code = match desc {
// 省略其他类型的处理
// 之前介绍的逻辑运算的求值
ExpDesc::Test(condition, true_list, false_list) => {
self.discharge(dst, *condition);
self.fix_test_set_list(true_list, dst);
self.fix_test_set_list(false_list, dst);
return;
}
// 关系运算的求值
ExpDesc::Compare(op, left, right, true_list, false_list) => {
// 生成关系运算的2条字节码
self.byte_codes.push(op(left as u8, right as u8, false));
self.byte_codes.push(ByteCode::Jump(1));
// 终结False跳转列表,到`SetFalseSkip`字节码,求值False
self.fix_test_list(false_list);
self.byte_codes.push(ByteCode::SetFalseSkip(dst as u8));
// 终结True跳转列表,到`LoadBool(true)`字节码,求值True
self.fix_test_list(true_list);
ByteCode::LoadBool(dst as u8, true)
}
};
self.byte_codes.push(code);
相比起来,逻辑运算ExpDesc::Test
的求值都显得简单了。
函数
本章介绍函数。Lua中的函数分为两种:
- Lua函数,在Lua中定义;
- 外部函数,一般是解释器语言实现,比如在Lua的官方实现中就是C函数,而在我们这里就是Rust函数。比如这个项目最开始的
print
函数就是在解释器中用Rust实现的。
前者的定义(语法分析)和调用(虚拟机执行)都是在Lua语言中,流程完整,所以接下来先讨论并实现前者。然后再介绍后者和相关API。
定义和调用
我们的解释器最开始只支持顺序执行,后来加入控制结构后支持条件跳转,并且block也有控制变量作用域的功能。而函数在解析、执行或作用域等方面都是更加独立的存在。为此,需要修改当前的语法分析和虚拟机执行的框架。
改造ParseProto
函数的定义是可以嵌套的,即可以在函数内部再次定义函数。如果把整个代码看做是主函数,那么我们当前的语法分析相当于只支持这一个函数。为了支持函数的嵌套定义,需要对语法分析进行改造。首先改造数据结构。
当前,语法分析过程的上下文结构体是ParseProto
,同时这也是返回给虚拟机执行的结构体。定义如下:
pub struct ParseProto<R: Read> {
pub constants: Vec<Value>,
pub byte_codes: Vec<ByteCode>,
sp: usize,
locals: Vec<String>,
break_blocks: Vec<Vec<usize>>,
continue_blocks: Vec<Vec<(usize, usize)>>,
gotos: Vec<GotoLabel>,
labels: Vec<GotoLabel>,
lex: Lex<R>,
}
每个字段的具体含义之前已经详细介绍过,这里忽略。这里只按照函数的独立性来区分这些字段:
- 最后的
lex
字段是贯穿整个代码解析的; - 其余所有字段都是函数内部的数据。
为了支持函数的嵌套定义,需要把全局部分(lex
字段)和函数部分(其他字段)拆开。新定义函数解析的数据结构PerFuncProto_
(因为我们最终不会采用这个方案,所以结构体名字最后加了_
),包括原来的ParseProto
中去掉lex
后剩下的其他字段:
struct PerFuncProto_ {
pub constants: Vec<Value>,
pub byte_codes: Vec<ByteCode>,
sp: usize,
... // 省略更多字段
}
为了支持函数的嵌套,就需要支持同时有多个函数解析体。最直观的思路是定义一个函数体列表:
struct ParseProto<R: Read> {
funcs: Vec<PerFuncProto_>, // 刚才定义的函数解析体PerFuncProto_的列表
lex: Lex<R>, // 全局数据
}
每次新嵌套一层函数,就向funcs
字段中压入一个新成员;解析完函数后弹出。funcs
的最后一个成员就代表当前函数。这样定义很直观,但是有个问题,访问当前函数的所有字段都很麻烦,比如要访问constants
字段,就需要 self.funcs.last().unwrap().constants
读取或者 self.funcs.last_mut().unwrap().constants
写入。太不方便了,执行效率应该也受影响。
如果是C语言,那么这个问题很好解决:在ParseProto
中再新增一个PerFuncProto_
类型的指针成员,比如叫current
,指向funcs
的最后一个成员。每次压入或弹出函数体时,都更新这个指针。然后就可以直接使用这个指针来访问当前函数了,比如 self.current.constants
。这个做法很方便但是Rust认为这是不“安全”的,因为在Rust的语法层面无法保证这个指针的有效性。虽然对这个指针的更新只有两个地方,相对安全,但是既然用了Rust,就要按照Rust的规矩。
对于Rust而言,可行的解决方案是增加一个索引(而非指针),比如叫icurrent
,指向funcs
的最后一个成员。同样也是每次在压入或弹出函数体时,都更新这个索引。而在访问当前函数信息时就可以用 self.funcs[icurrent].constants
。虽然Rust语言允许这么做,但这其实只是上面指针方案的变种,仍然可能由于索引的错误更新导致bug。比如索引超过funcs
长度则会panic,而如果小于预期则会出现更难调试的代码逻辑bug。另外在执行时,Rust的列表索引会跟列表长度做比较,也会稍微影响性能。
还有一个不那么直观但没有上述问题的方案:利用递归。在解析嵌套的函数时,最自然的方法就是递归调用解析函数的代码,那么每次调用都会有独立的栈(Rust的调用栈),于是可以每次调用时都创建一个函数解析体并用于解析当前Lua函数,在调用结束后就返回这个解析体供外层函数处理。这个方案中,解析过程中只能访问当前函数的信息,不能访问外层函数的信息,自然也就没有刚才说的访问当前函数信息不方便的问题了。比如访问constants依然是用self.constants
,甚至无需修改现有代码。唯一要解决的是全局数据Lex
,这个可以作为解析函数的参数一直传递下去。
这个方案中,无需定义新的数据结构,只需要把原来的ParseProto
中的lex
字段从Lex
类型修改为&mut Lex
即可。解析Lua函数的语法分析函数定义原来是ParseProto
的方法,定义为:
impl<'a, R: Read> ParseProto<'a, R> {
fn chunk(&mut self) {
...
}
现在改为普通函数,定义为:
fn chunk(lex: &mut Lex<impl Read>) -> ParseProto {
...
}
其中参数lex
是全局数据,每次递归调用都直接传入下一层。返回值是在chunk()
内创建的当前Lua函数的解析信息。
另外,chunk()
函数内部调用block()
函数解析代码,后者返回block的结束Token。之前chunk()
函数只用来处理整个代码块,所以结束Token只可能是Token::Eos
;而现在也可能被用来解析其他的内部函数,此时预期的结束Token就是Token::End
。所以chunk()
函数要新增一个参数,表示预期的结束Token。于是定义改成:
fn chunk(lex: &mut Lex<impl Read>, end_token: Token) -> ParseProto {
...
}
新增FuncProto
刚才改造了ParseProto
,修改了lex
的类型。现在顺便再做个小的优化。ParseProto
中前面两个pub
修饰的字段同时也是返回给虚拟机执行使用的;后面的大部分字段只是语法分析时使用的,是内部数据,并不需要返回给虚拟机。可以把这两部分拆开,从而只返回给虚拟机需要的部分。为此,新增FuncProto
数据结构:
// 返回给虚拟机执行的信息
pub struct FuncProto {
pub constants: Vec<Value>,
pub byte_codes: Vec<ByteCode>,
}
#[derive(Debug)]
struct ParseProto<'a, R: Read> {
// 返回给虚拟机执行的信息
fp: FuncProto,
// 语法分析内部数据
sp: usize,
locals: Vec<String>,
break_blocks: Vec<Vec<usize>>,
continue_blocks: Vec<Vec<(usize, usize)>>,
gotos: Vec<GotoLabel>,
labels: Vec<GotoLabel>,
lex: Lex<R>,
// 全局数据
lex: &'a mut Lex<R>,
}
于是chunk()
函数的返回值就从ParseProto
改为FuncProto
。其完整定义如下:
fn chunk(lex: &mut Lex<impl Read>, end_token: Token) -> FuncProto {
// 生成新的ParseProto,用以解析当前新的Lua函数
let mut proto = ParseProto::new(lex);
// 调用block()解析函数
assert_eq!(proto.block(), end_token);
if let Some(goto) = proto.gotos.first() {
panic!("goto {} no destination", &goto.name);
}
// 只返回FuncProto部分
proto.fp
}
这样,在语法分析Lua内嵌函数时,只要递归调用chunk(self.lex, Token::End)
即可。下面介绍具体的语法分析。
语法分析
上面介绍了解析Lua函数的大致流程,现在来看具体的语法分析。到现在语法分析应该已经轻车熟路了,按照BNF执行即可。Lua的函数定义有3个地方:
- 全局函数;
- 局部函数:
- 匿名函数,是表达式
exp
语句的一种情况。
其BNF规则分别如下:
stat :=
`function` funcname funcbody | # 1.全局函数
`local` `function` Name funcbody | # 2.局部函数
# 省略其他情况
exp := functiondef | 省略其他情况
functiondef := `function` funcbody # 3.匿名函数
funcbody ::= ‘(’ [parlist] ‘)’ block end # 函数定义
由上述规则可见这3种定义的区别只是在开头,而最后都是归于funcbody
。这里只介绍最简单的第2种情况,局部函数。
fn local_function(&mut self) {
self.lex.next(); // 跳过关键字`function`
let name = self.read_name(); // 函数名,或者称为局部变量名
println!("== function: {name}");
// 暂时不支持参数,跳过 `()`
self.lex.expect(Token::ParL);
self.lex.expect(Token::ParR);
// 调用chunk()解析函数
let proto = chunk(self.lex, Token::End);
// 把解析的结果FuncProto,放入常量表中
let i = self.add_const(Value::LuaFunction(Rc::new(proto)));
// 通过LoadConst字节码加载函数
self.fp.byte_codes.push(ByteCode::LoadConst(self.sp as u8, i as u16));
// 创建局部变量
self.locals.push(name);
}
解析过程很简单。需要说明的是,对chunk()
函数返回的函数原型FuncProto的处理方法,是作为一个常量放到常量表中。可以对比字符串是由一系列字符序列组成的常量;而函数原型FuncProto就是由一系列常量表和字节码序列组成的常量。同样也是存在常量表中,同样也是用LoadConst
字节码来加载。
为此,需要新增一种Value类型LuaFunction
来代表Rust函数,并把原来代表Lua函数的类型从Function
改为RustFunction
:
pub enum Value {
LongStr(Rc<Vec<u8>>),
LuaFunction(Rc<FuncProto>),
RustFunction(fn (&mut ExeState) -> i32),
LuaFunction
关联的数据类型是Rc<FuncProto>
,从这里也可以看到跟字符串常量的相似。
以上完成了“定义函数”的语法分析,跟函数相关的还有“调用函数”的语法分析。但是在“调用函数”的时候,Lua函数和Rust函数是同等对待的,Lua程序员在调用函数时甚至不知道这个函数是用什么实现的;由于之前已经完成了Rust函数print()
调用的语法分析,所以这里无需特定为Lua函数的调用再做语法分析。
虚拟机执行
跟语法分析一样,我们之前的虚拟机执行部分也是只支持一层Lua函数。为了支持函数调用,最简单的办法就是递归调用虚拟机执行,即execute()
函数。代码如下:
ByteCode::Call(func, _) => {
self.func_index = func as usize;
match &self.stack[self.func_index] {
Value::RustFunction(f) => { // 之前就支持的Rust函数
f(self);
}
Value::LuaFunction(f) => { // 新增的Lua函数
let f = f.clone();
self.execute(&f); // 递归调用虚拟机!
}
f => panic!("invalid function: {f:?}"),
}
}
但是,需要对栈做特殊处理。语法分析时,每次解析新函数,栈指针(ParseProto
结构中的sp
字段)都是从0开始。因为在语法分析时,并不知道在虚拟机执行时栈的绝对起始地址。那么,在虚拟机执行的时候,在访问栈时,使用的字节码中的栈索引,需要加上当前函数的栈起始地址的偏移。比如对于如下Lua代码:
local a, b = 1, 2
local function foo()
local x, y = 1, 2
end
foo()
在语法分析foo()
函数定义时,局部变量x和y的栈地址分别是0和1。在执行最后一行代码,调用foo()
函数时,函数foo
放在栈的绝对索引2处,此时局部变量x和y的绝对索引就是3和4。那么虚拟机执行的时候,就需要把相对地址0和1,转换为3和4。
绝对地址 相对地址
+-----+ <---主函数的base
0 | a | 0
+-----+
1 | b | 1
+-----+
2 | foo | 2
+-----+ <---foo()函数的base
3 | x | 0
+-----+
4 | y | 1
+-----+
| |
之前在执行Rust函数print()
时,为了让print()
函数能读取到参数,所以在ExeState
中设置了func_index
成员,用来指向函数在栈上的地址。现在调用Lua函数,依然如此。只不过,这里把func_index
改名为base
,并指向函数的下一个地址。
ByteCode::Call(func, _) => {
self.base += func as usize + 1; // 设置函数在栈上的绝对地址
match &self.stack[self.base-1] {
Value::RustFunction(f) => {
f(self);
}
Value::LuaFunction(f) => {
let f = f.clone();
self.execute(&f);
}
f => panic!("invalid function: {f:?}"),
}
self.base -= func as usize + 1; // 恢复
}
之前所有对栈的写操作,都是调用的set_stack()
方法,现在需要加上self.base偏移:
fn set_stack(&mut self, dst: u8, v: Value) {
set_vec(&mut self.stack, self.base + dst as usize, v); // 加上self.base
}
之前所有对栈的读操作都是直接self.stack[i]
,现在也要提取一个新函数get_stack()
,并在访问栈时加上self.base偏移:
fn get_stack(&self, dst: u8) -> &Value {
&self.stack[self.base + dst as usize] // 加上self.base
}
至此,我们完成了Lua函数的最基本的定义和调用。有赖于递归的力量,代码改动并不大。但距离完整的函数功能,这只是一个起步。下一节增加参数和返回值的支持。
参数
上一节介绍了Lua函数的定义和调用流程。这一节介绍函数的参数。
参数这个名词有两个概念:
- 形参,parameter,指函数原型中的参数,包括参数名和参数类型等信息;
- 实参,argument,指函数调用时的参数,是具体的值。
本节后面介绍语法分析和虚拟机执行时,都要明确区分形参和实参。
很重要的一点是:在Lua语言中,函数的参数就是局部变量!在语法分析时,形参也会放到局部变量表中起始位置,这样后续代码中如果有对形参的引用,也会在局部变量表中定位到。在虚拟机执行阶段,实参被加载到栈上紧跟函数入口的位置,后面再跟局部变量,与语法分析阶段局部变量表中的顺序一致。比如对于如下函数:
local function foo(a, b)
local x, y = 1, 2
end
在执行foo()
函数时,栈布局如下(栈右边的数字0-3是相对索引):
| |
+-----+
| foo |
+=====+ <---base
| a | 0 \
+-----+ + 参数
| b | 1 /
+-----+
| x | 2 \
+-----+ + 局部变量
| y | 3 /
+-----+
| |
参数和局部变量唯一的区别就是,参数的值是在调用时由调用者传入的,而局部变量是在函数内部赋值的。
形参的语法分析
形参的语法分析,也就是函数定义的语法分析。上一节介绍函数定义时,语法分析的过程省略了参数部分,现在加上。函数定义的BNF是funcbody,其定义如下:
funcbody ::= `(` [parlist] `)` block end
parlist ::= namelist [`,` `...`] | `...`
namelist ::= Name {`,` Name}
由此可以看到,形参列表由两个可选的部分组成:
-
可选的多个Name,是固定参数。上一节在解析新函数,创建
FuncProto
结构时,局部变量表locals
字段被初始化为空列表。现在要改为初始化为形参列表。这样形参就在局部变量表的最前面,后续新建的局部变量跟在后面,与本节开头的栈布局图一致。另外,由于Lua语言中调用函数的实参个数允许跟形参个数不等。多则舍去,少则补nil。所以FuncProto
结果中也要增加形参的个数,用以在虚拟机执行时做比较。 -
最后一个可选的
...
,表明这个函数支持可变参数。如果支持,那么在后续的语法分析中,函数体内就可以使用...
来引用可变参数,并且在虚拟机执行阶段,也要对可变参数做特殊处理。所以在FuncProto
中需要增加一个标志位,表明这个函数是否支持可变参数。
综上,一共有三个改造点。在FuncProto
中增加两个字段:
pub struct FuncProto {
pub has_varargs: bool, // 是否支持可变参数。语法分析和虚拟机执行中都要使用。
pub nparam: usize, // 固定参数个数。虚拟机执行中使用。
pub constants: Vec<Value>,
pub byte_codes: Vec<ByteCode>,
}
另外在初始化ParseProto
结构时,用形参列表来初始化局部变量locals
字段。代码如下:
impl<'a, R: Read> ParseProto<'a, R> {
// 新增has_varargs和params两个参数
fn new(lex: &'a mut Lex<R>, has_varargs: bool, params: Vec<String>) -> Self {
ParseProto {
fp: FuncProto {
has_varargs: has_varargs, // 是否支持可变参数
nparam: params.len(), // 形参个数
constants: Vec::new(),
byte_codes: Vec::new(),
},
sp: 0,
locals: params, // 用形参列表初始化locals字段
break_blocks: Vec::new(),
continue_blocks: Vec::new(),
gotos: Vec::new(),
labels: Vec::new(),
lex: lex,
}
}
至此,完成形参的语法分析。其中涉及到可变参数、虚拟机执行等部分,下面再详细介绍。
实参的语法分析
实参的语法分析,也就是函数调用的语法分析。这个在之前章节实现prefixexp的时候已经实现过了:通过explist()
函数读取参数列表,并依次加载到栈上函数入口的后面位置。与本节开头的栈布局图一致,相当于是给形参赋值。这里解析到实际的参数个数,并写入到字节码Call
的参数中,用于在虚拟机执行阶段跟形参做比较。
但当时的实现并不完整,还不支持可变参数。本节后面再详细介绍。
虚拟机执行
上面的实参语法分析中,已经把实参加载到栈上,相当于是给形参赋值,所以虚拟机执行函数调用时,本来就无需再处理参数了。但是在Lua语言中,函数调用时实参个数可能不等于形参个数。如果实参多于形参,那无需处理,就认为多出的部分是个占据栈位置但无用的临时变量;但如果实参少于形参,那么需要对不足的部分设置为nil,否则后续字节码对这个形参的引用就会导致Lua的栈访问异常。除此之外,Call
字节码的执行就不需要对参数做其他处理。
上面语法分析时已经介绍过,形参和实参的个数,分别在FuncProto
结构中的nparam
字段和Call
字节码的关联参数中。所以函数调用的虚拟机执行代码如下:
ByteCode::Call(func, narg) => { // narg是实际传入的实参个数
self.base += func as usize + 1;
match &self.stack[self.base - 1] {
Value::LuaFunction(f) => {
let narg = narg as usize;
let f = f.clone();
if narg < f.nparam { // f.nparam是函数定义中的形参个数
self.fill_stack(narg, f.nparam - narg); // 填nil
}
self.execute(&f);
}
至此,完成了固定参数的部分,还是比较简单的;下面介绍可变参数部分,开始变得复杂起来。
可变参数
在上面形参的语法分析中已经提到了可变参数,其功能比较简单,代表这个函数支持可变参数。本节接下来主要介绍可变参数作为实参的处理,也就是执行函数调用时实际传入的参数。
本节最开始就介绍函数的参数就是局部变量,并画了栈的布局图。不过这个说法只适合固定的实参,而对于可变实参就不适合了。在之前的foo()
函数中加上可变参数作为示例,代码如下:
local function foo(a, b, ...)
local x, y = 1, 2
print(x, y, ...)
end
foo(1, 2, 3, 4, 5)
加上可变参数后,栈布局该变成什么样?或者说,可变实参要存在哪里?上述代码中最后一行foo()
调用时,其中1
和2
分别对应形参a
和b
,而后面的3
、4
和5
就是可变实参。在调用开始前,栈布局如下:
| |
+-----+
| foo |
+=====+ <-- base
| 1 | \
+-----+ + 固定实参,对应a和b
| 2 | /
+-----+
| 3 | \
+-----+ |
| 4 | + 可变实参,对应...
+-----+ |
| 5 | /
+-----+
| |
那进入到foo()
函数后,后面的三个实参要存在哪里?最直接的想法是保持上面的布局不变,也就是可变实参存到固定实参的后面。但是,这样是不行的!因为这样就会挤占局部变量的空间,即示例里的x
和y
就要后移,后移的距离是可变实参的个数。但是在语法分析阶段是不能确定可变实参的个数的,就无法确定局部变量在栈上的位置,就无法访问局部变量了。
Lua官方的实现是,在语法分析阶段忽略可变参数,让局部变量仍然在固定参数的后面。但是在虚拟机执行时,在进入到函数中后,把可变参数挪到函数入口的前面,并且记录可变实参的个数。这样后续在访问可变参数时,根据函数入口位置和可变实参的个数,就可以定位栈位置,即stack[self.base - 1 - 实参个数 .. self.base - 1]
。下面是栈布局图:
| |
+-----+
| 3 | -4 \
+-----+ | num_varargs: usize // 记录下可变实参的个数
| 4 | -3 + 相对于上图, +-----+
+-----+ | 把可变实参挪到函数入口前面 | 3 |
| 5 | -2 / +-----+
+-----+
| foo | <-- 函数入口
+=====+ <-- base
| a=1 | 0 \
+-----+ + 固定实参,对应a和b
| b=2 | 1 /
+-----+
| x | 2 \
+-----+ + 局部变量
| y | 3 / 仍然紧跟固定参数后面
既然这个方案需要在虚拟机执行时需要记录额外信息(可变实参的个数),并且还要移动栈上参数,那么更简单的做法是直接记录可变实参:
| |
+-----+
| foo | <-- 函数入口 varargs: Vec<Value> // 直接记录可变实参
+=====+ +-----+-----+-----+
| a=1 | 0 \ | 3 | 4 | 5 |
+-----+ + 固定实参,对应a和b +-----+-----+-----+
| b=2 | 1 /
+-----+
| x | 2 \
+-----+ + 局部变量
| y | 3 /
相比于Lua的官方实现,这个方法没有利用栈,而是使用Vec,会有额外的堆上内存分配。但是更加直观清晰。
确定下可变实参的存储方式后,就可以进行语法分析和虚拟机执行了。
ExpDesc::VarArgs和应用场景
上面讲的是函数调用时传递可变参数,接下来介绍在函数体内如何访问可变参数。
访问可变实参是一个独立的表达式,语法是也...
,在exp_limit()
函数中解析,并新增一种表达式类型ExpDesc::VarArgs
,这个类型没有关联参数。
读取这个表达式很简单,先检查当前函数是否支持可变参数(函数原型中有没有...
),然后返回ExpDesc::VarArgs
即可。具体代码如下:
fn exp_limit(&mut self, limit: i32) -> ExpDesc {
let mut desc = match self.lex.next() {
Token::Dots => {
if !self.fp.has_varargs { // 检查当前函数是否支持可变参数?
panic!("no varargs");
}
ExpDesc::VarArgs // 新增表达式类型
}
但是读到的ExpDesc::VarArgs
如何处理?这就要先梳理使用可变实参的3种场景:
-
当
...
作为函数调用的最后一个参数、return语句的最后一个参数、表构造的最后一个列表成员时,代表实际传入的全部实参。比如下面示例:print("hello: ", ...) -- 最后一个实参 local t = {1, 2, ...} -- 最后一个列表成员 return a+b, ... -- 最后一个返回值
-
当
...
作为局部变量定义语句、或赋值语句的等号=
后面最后一个表达式时,会按需求扩展或缩减个数。比如下面示例:local x, y = ... -- 取前2个实参,分别赋值给x和y t.k, t.j = a, ... -- 取前1个实参,赋值给t.j
-
其他地方都只代表实际传入的第一个实参。比如下面示例:
local x, y = ..., b -- 不是最后一个表达式,只取第1个实参并赋值给x t.k, t.j = ..., b -- 不是最后一个表达式,只取第1个实参并赋值给t.k if ... then -- 条件判断 t[...] = ... + f -- 表索引,和二元运算操作数 end
其中,第1个场景是最基本的,但也是实现起来最复杂的;后面两个场景属于特殊情况,实现起来相对简单。下面对这3种场景依次分析。
场景1:全部可变实参
先介绍第1种场景,即加载全部可变实参。这个场景中的3个语句如下:
-
函数调用的最后一个参数,是把当前函数的可变实参作为调用函数的可变实参,涉及两个可变实参,有点绕,不方便描述;
-
return语句的最后一个参数,但是现在还不支持返回值,要在下一节介绍;
-
表构造的最后一个列表成员。
这3个语句的实现思路类似,都是在解析表达式列表的时候,只discharge前面的表达式,而保留最后一个表达式不discharge;然后在解析完整个语句后,单独检查最后一个语句是否为ExpDesc::VarArgs
:
-
如果不是,则正常discharge。这种情况下,在语法分析时就能确定所有表达式的数量,而这个数量就可以编码进对应的字节码中。
-
如果是,则用新增的字节码
VarArgs
加载全部可变参数,而实际参数的个数在语法分析时不知道,要在虚拟机执行时才能知道,所以总的表达式的数量也不知道,也就无法编码到对应的字节码中,就需要用特殊值或新字节码来处理。
这3个语句中第3个语句表构造相对而言最简单,下面先介绍表构造语句。
之前表构造的语法分析流程是:在循环读取全部成员过程中,如果解析到数组成员,则立即discharge到栈上;在循环读取完毕后,所有数组成员依次被加载到栈上,然后生成SetList
字节码将其添加到表里。这个SetList
字节码的第2个关联参数就是成员数量。为了简单起见,这里忽略超过50个成员时分批加载的处理。
现在修改流程:为了单独处理最后一个表达式,在解析到数组成员时,要延迟discharge。具体做法比较简单但不容易描述,可以参见下面代码。代码摘自table_constructor()
函数,只保留跟本节相关内容。
// 新增这个变量,用来保存最后一个读到的数组成员
let mut last_array_entry = None;
// 循环读取全部成员
loop {
let entry = // 省略读取成员的代码
match entry {
TableEntry::Map((op, opk, key)) => // 省略字典成员部分的代码
TableEntry::Array(desc) => {
// 使用replace()函数,用新成员desc替换出上一个读到的成员
// 并discharge。而新成员,也就是当前的“最后一个成员”,被
// 存到last_array_entry中。
if let Some(last) = last_array_entry.replace(desc) {
self.discharge(sp0, last);
}
}
}
}
// 处理最后一个表达式,如果有的话
if let Some(last) = last_array_entry {
let num = if self.discharge_expand(last) {
// 可变参数。在语法分析阶段无法得知具体的参数个数,所以用0来代表栈上全部
0
} else {
// 计算出总的成员个数
(self.sp - (table + 1)) as u8
};
self.fp.byte_codes.push(ByteCode::SetList(table as u8, num));
}
上述代码整理流程比较简单,这里不一一介绍。在处理最后一个表达式时,有几个细节需要介绍:
- 新增的
discharge_expand()
方法,用以特殊处理ExpDesc::VarArgs
类型表达式。可以预见这个函数后面还会被其他两个语句(return语句和函数调用语句)用到。其代码如下:
fn discharge_expand(&mut self, desc: ExpDesc) -> bool {
match desc {
ExpDesc::VarArgs => {
self.fp.byte_codes.push(ByteCode::VarArgs(self.sp as u8));
true
}
_ => {
self.discharge(self.sp, desc);
false
}
}
}
-
最后一个表达式如果是可变参数,那么
SetList
字节码的第2个关联参数则设置为0
。之前(不支持可变数据表达式的时候)SetList
字节码的这个参数不可能是0,因为如果没有数组成员,那不生成SetList
字节码即可,而没必要生成一个关联参数是0的SetList
。所以这里可以用0
作为特殊值。相比而言,这个场景里的其他两个语句(return语句和函数调用语句)本来就支持0个表达式,即没有返回值和没有参数,那就不能用0
作为特殊值了。到时候再想其他办法。当然这里也可以不用
0
这个特殊值,而是新增一个字节码,比如叫SetListAll
,专门用来处理这种情况。这两种做法差不多,我们还是选择使用特殊值0
。 -
虚拟机执行时,对于
SetList
第二个关联参数是0
的情况,就取栈上表后面的全部的值。也就是从表的位置一直到栈顶,都是用来初始化的表达式。具体代码如下,增加对0
的判断:
ByteCode::SetList(table, n) => {
let ivalue = self.base + table as usize + 1;
if let Value::Table(table) = self.get_stack(table).clone() {
let end = if n == 0 { // 0,可变参数,直至栈顶的全部表达式
self.stack.len()
} else {
ivalue + n as usize
};
let values = self.stack.drain(ivalue .. end);
table.borrow_mut().array.extend(values);
} else {
panic!("not table");
}
}
- 既然对于可变参数的情况,可以在虚拟机执行时根据栈顶来获取实际的表达式数量,那之前固定表达式的情况是不是也可以在执行时决定表达式数量,而不用在语法分析阶段就确定?这样一来
SetList
关联的第2个参数是不是就没用了?答案是否定的,因为栈上可能有临时变量!比如下面的代码:
t = { g1+g2 }
表达式g1+g2
的两个操作数都是全局变量,在对整个表达式求值前,要都分别加载到栈上,需要占用2个临时变量的位置。栈布局如下:
| |
+-------+
| t |
+-------+
| g1+g2 | 先把g1加载到这里。然后在求值g1+g2时,结果也加载到这里,覆盖原来的g1。
+-------+
| g2 | 在求值g1+g2时,把全局变量g2加载到这里的临时位置
+-------+
| |
此时栈顶是g2,如果也按照从表后直至栈顶的做法,那么g2也会被认为是表的一个成员。所以,对于之前的情况(固定数量的表达式)还是需要在语法分析阶段确定表达式的数量。
- 那么,为什么对于可变参数的情况就可以根据栈顶来确定表达式数量呢?这就要求虚拟机在执行加载可变参数的字节码时,清理掉临时变量。这一点非常重要。具体代码如下:
ByteCode::VarArgs(dst) => {
self.stack.truncate(self.base + dst as usize); // 清理临时变量!!!
self.stack.extend_from_slice(&varargs); // 加载可变参数
}
至此,完成了可变参数作为表构造最后一个表达式的语句的处理。相关代码并不多,但理清思路和一些细节并不简单。
场景1:全部可变实参(续)
上面介绍了第1种场景下的表构造语句,现在介绍可变参数作为函数调用的最后一个参数的情况,光听这个描述就很绕。这两个语句对可变参数的处理方法差不多,这里只介绍下不同的地方。
本节上面介绍实参的语法分析时已经说明,所有实参通过explist()
函数依次加载到栈顶,并把实参个数写入到Call
字节码中。但当时的实现并不支持可变参数。现在为了支持可变参数,就要对最后一个表达式做特殊处理。为此我们修改explist()
函数,保留并返回最后一个表达式,而只是把前面的表达式依次加载到栈上。具体代码比较简单,这里略过。复习一下,在赋值语句中,读取等号=
右边的表达式列表时,也需要保留最后一个表达式不加载。这次改造了exp_list()
函数后,在赋值语句中就也可以使用这个函数了。
改造explist()
函数后,再结合上面对表构造语句的介绍,就可以实现函数调用中的可变参数了。代码如下:
fn args(&mut self) -> ExpDesc {
let ifunc = self.sp - 1;
let narg = match self.lex.next() {
Token::ParL => { // 括号()包裹的参数列表
if self.lex.peek() != &Token::ParR {
// 读取实参列表。保留和返回最后一个表达式last_exp,而把前面的
// 表达式依次加载到栈上并返回其个数nexp。
let (nexp, last_exp) = self.explist();
self.lex.expect(Token::ParR);
if self.discharge_expand(last_exp) {
// 可变实参。生成新增的VarArgs字节码,读取全部可变实参!!
None
} else {
// 固定实参。last_exp也被加载到栈上,作为最后1个实参。
Some(nexp + 1)
}
} else { // 没有参数
self.lex.next();
Some(0)
}
}
Token::CurlyL => { // 不带括号的表构造
self.table_constructor();
Some(1)
}
Token::String(s) => { // 不带括号的字符串常量
self.discharge(ifunc+1, ExpDesc::String(s));
Some(1)
}
t => panic!("invalid args {t:?}"),
};
// 对于n个固定实参,转换为n+1;
// 对于可变实参,转换为0。
let narg_plus = if let Some(n) = narg { n + 1 } else { 0 };
ExpDesc::Call(ifunc, narg_plus)
}
跟之前介绍的表构造语句不一样的地方是,表构造语句对应的字节码是SetList
,在固定成员的情况下,其关联的用于表示数量的参数不会是0
;所以就可以用0
作为特殊值,来表示可变数量的成员。但是,对于函数调用语句,本来就支持没有实参的情况,也就是说字节码Call
关联的用户表示实参数量的参数本来就可能是0
,所以就不能简单把0
作为特殊值。那么,就有2个方案:
- 换一个特殊值,比如用
u8::MAX
,即255作为特殊值; - 仍然用
0
做特殊值,但是在固定实参的情况下,把参数加1。比如5个实参,那么就在Call
字节码中写入6;N个字节码就写入N+1;这样就可以确保固定参数的情况下,这个参数肯定是大于0的。
我感觉第1个方案稍微好一点,更清晰,不容易出错。但是Lua官方实现用的是第2个方案。我们也采用第2个方案。对应到上述代码中的两个变量:
narg: Option<usize>
表示实际的参数数量,None
表示可变参数,Some(n)
代表有n
个固定参数;narg_plus: usize
是修正后的值,用来写入到Call
字节码中。
跟之前介绍的表构造语句一样的地方是,既然用0
这个特殊值来表示可变参数,那么虚拟机执行的时候,就需要有办法知道实际参数的个数。只能通过栈顶指针和函数入口的距离来计算出实际参数的个数,那也就需要确保栈顶都是参数,而没有临时变量。对于这个要求,有两种情况:
- 实参也是可变参数,也就是最后一个实参是
VarArgs
,比如调用语句是foo(1, 2, ...)
,那么由于之前介绍过VarArgs
的虚拟机执行会确保清理临时变量,所以这个情况下就无需再次清理; - 实参是固定参数,比如调用语句是
foo(g1+g2)
,那么就需要清理可能存在的临时变量。
对应的,在虚拟机执行阶段的函数调用,也就是Call
字节码的执行,需要如下修改:
- 修正关联参数narg_plus;
- 在需要时,清理栈上可能的临时变量。
代码如下:
ByteCode::Call(func, narg_plus) => { // narg_plus是修正后的实参个数
self.base += func as usize + 1;
match &self.stack[self.base - 1] {
Value::LuaFunction(f) => {
// 实参数量
let narg = if narg_plus == 0 {
// 可变实参。上面介绍过,VarArgs字节码的执行会清理掉可能的
// 临时变量,所以可以用栈顶来确定实际的参数个数。
self.stack.len() - self.base
} else {
// 固定实参。需要减去1做修正。
narg_plus as usize - 1
};
if narg < f.nparam { // 填补nil,原有逻辑
self.fill_stack(narg, f.nparam - narg);
} else if f.has_varargs && narg_plus != 0 {
// 如果被调用的函数支持可变形参,并且调用是固定实参,
// 那么需要清理栈上可能的临时变量
self.stack.truncate(self.base + narg);
}
self.execute(&f);
}
至此,我们完成了可变参数的第1种场景的部分。这部分是最基本的,也是最复杂的。下面介绍另外两种场景。
场景2:前N个可变实参
现在介绍可变参数的第2种场景,需要固定个数的可变实参。这个场景中需要使用的参数个数固定,可以编入字节码中,比上个场景简单很多。
这个场景包括2条语句:局部变量定义语句和赋值语句。当可变参数作为等号=
后面最后一个表达式时,会按需求扩展或缩减个数。比如下面的示例代码:
local x, y = ... -- 取前2个实参,分别赋值给x和y
t.k, t.j = a, ... -- 取前1个实参,赋值给t.j
这两个语句的处理方式基本一样。这里只介绍第一个局部变量定义语句。
之前这个语句的处理流程是,首先把=
右边的表达式依次加载到栈上,完成对局部变量的赋值。如果当=
右边表达式的个数小于左边局部变量的个数时,则生成LoadNil
字节码对多出的局部变量进行赋值;如果不小于则无需处理。
现在需要对最后一个表达式特殊处理:如果表达式的个数小于局部变量的个数,并且最后一个表达式是可变参数...
,那么就按需读取参数;如果不是可变参数,那还是回退成原来的方法,即用LoadNil
来填补。刚才改造过的explist()
函数就又派上用场了,具体代码如下:
let want = vars.len();
// 读取表达式列表。保留和返回最后一个表达式last_exp,而把前面的
// 表达式依次加载到栈上并返回其个数nexp。
let (nexp, last_exp) = self.explist();
match (nexp + 1).cmp(&want) {
Ordering::Equal => {
// 如果表达式跟局部变量个数一致,则把最后一个表达式也正常
// 加载到栈上即可。
self.discharge(self.sp, last_exp);
}
Ordering::Less => {
// 如果表达式少于局部变量个数,则需要尝试特殊处理最后一个表达式!!!
self.discharge_expand_want(last_exp, want - nexp);
}
Ordering::Greater => {
// 如果表达式多于局部变量个数,则调整栈顶指针;最后一个表达式
// 也就无需处理了。
self.sp -= nexp - want;
}
}
上述代码中,新增的逻辑是discharge_expand_want()
函数,用以加载want - nexp
个表达式到栈上。代码如下:
fn discharge_expand_want(&mut self, desc: ExpDesc, want: usize) {
debug_assert!(want > 1);
let code = match desc {
ExpDesc::VarArgs => {
// 可变参数表达式
ByteCode::VarArgs(self.sp as u8, want as u8)
}
_ => {
// 对于其他类型表达式,还是用之前的方法,即用LoadNil来填补
self.discharge(self.sp, desc);
ByteCode::LoadNil(self.sp as u8, want as u8 - 1)
}
};
self.fp.byte_codes.push(code);
}
这个函数跟上面第1种场景中的discharge_expand()
函数很像,但有两个区别:
-
之前是需要实际执行中所有的可变参数,但这个函数有确定的个数需求,所以多了一个参数
want
; -
之前函数需要返回是否为可变参数,以便调用者再做区别处理;但这个函数因为需求明确,不需要调用者做区别处理,所以没有返回值。
跟上面第1个场景相比,还有一个重要改变是VarArgs
字节码新增一个关联参数,用以表示需要加载具体多少个参数到栈上。因为在这种场景下,这个参数肯定不小于2,而在下一种场景下,这个参数固定是1,都没有用到0,所以可以用0作为特殊值,来表示上面第1种场景中的执行时实际所有参数。
这个字节码的虚拟机执行代码也改变如下:
ByteCode::VarArgs(dst, want) => {
self.stack.truncate(self.base + dst as usize);
let len = varargs.len(); // 实际参数个数
let want = want as usize; // 需要参数个数
if want == 0 { // 需要实际全部参数,流程不变
self.stack.extend_from_slice(&varargs);
} else if want > len {
// 需要的比实际的多,则用fill_stack()填补nil
self.stack.extend_from_slice(&varargs);
self.fill_stack(dst as usize + len, want - len);
} else {
// 需要的比实际的一样或更少
self.stack.extend_from_slice(&varargs[..want]);
}
}
至此,完成可变参数第2种场景部分。
场景3:只取第1个可变实参
前面介绍的两种场景都是在特定的语句上下文中,分别通过discharge_expand_want()
或discharge_expand()
函数,把可变参数加载到栈上。而第3种场景是除了上述特定语句上下文外的其他所有地方。所以从这个角度说,第3个场景可以算是通用场景,那么也就要用通用的加载方式。在本节介绍可变参数这个表达式之前,其他所有表达式都是通过调用discharge()
函数加载到栈上,可以看做是通用的加载方式。于是这个场景下,也要通过discharge()
函数来加载可变参数表达式。
其实上面已经遇到了这种场景。比如,在上述第2种场景中,如果=
右边的表达式个数和局部变量个数相等时,最后一个表达式就是通过discharge()
函数处理的:
let (nexp, last_exp) = self.explist();
match (nexp + 1).cmp(&want) {
Ordering::Equal => {
// 如果表达式跟局部变量个数一致,则把最后一个表达式也正常
// 加载到栈上即可。
self.discharge(self.sp, last_exp);
}
这里discharge()
的最后一个表达式也可能是可变参数表达式...
,那么就是当前场景。
再比如,上述两个场景中都调用了explist()
函数来处理表达式列表。除了最后一个表达式外,前面的表达式都会被这个函数通过调用discharge()
来加载到栈上。如果前面的表达式里就有可变参数表达式...
,比如foo(a, ..., b)
,那么也是当前场景。
另外,上面也罗列了可变表达式在其他语句中的示例,都是属于当前场景。
既然这个场景属于通用场景,那么在语法分析阶段就不需要做什么改造,而只需要补齐discharge()
函数中对可变表达式ExpDesc::VarArgs
这个表达式的处理即可。这个处理也很简单,就是使用上面介绍的VarArgs
字节码,只加载第1个参数到栈上:
fn discharge(&mut self, dst: usize, desc: ExpDesc) {
let code = match desc {
ExpDesc::VarArgs => ByteCode::VarArgs(dst as u8, 1), // 1表示只加载第1个参数
这就完成了第3种场景。
至此,终于介绍完可变参数的所有场景。
小结
本节开始分别介绍了形参和实参的机制。对于形参,语法分析把形参加到局部变量表中,作为局部变量使用。对于实参,调用者把参数加载到栈上,相当于给参数赋值。
后面大部分篇幅介绍了可变参数的处理,包括3种场景:实际全部实参,固定个数实参,和通用场景下第1个实参。
返回值
本节介绍Lua函数的返回值。首先介绍固定数量返回值的情况,再介绍可变数量的情况。
跟上一节中的参数特性涉及形参和实参两部分类似,要实现函数返回值功能,也涉及两个地方:
-
被调用函数在退出前生成返回值。这个由Lua中的return语句完成。对应地需要增加
Return
字节码。 -
调用者读取并处理返回值。这部分功能在
Call
字节码中实现。之前Call
字节码只是调用了函数,而没有处理返回值。
跟参数是由栈来传递一样,返回值的这两部分之间也是由栈来衔接的。
接下来先介绍第一部分,即函数退出并生成返回值的return语句和Return
字节码。
Return字节码
被调用函数和调用者之间,是使用栈来传递返回值的。被调用函数生成返回值并加载到栈上,然后把返回值在栈上的位置通知调用者,调用者从栈上读取返回值。
Lua语言的函数支持多个返回值。如果这些返回值在栈上的位置不连续,那么就很难把具体的返回值告知给调用者。所以要求所有返回值在栈上连续排列,这样就可以通过在栈上的起始索引和数量来告知调用者了。为此,需要把所有返回值依次加载到栈顶。比如下面的例子:
local function foo()
local x, y = 1, 2
return x, "Yes", g1+g2
end
在函数返回前的栈布局如下:
| |
+-------+
| foo | 调用者加载foo到栈上
+=======+ <--base
| x | 0 \
+-------+ + 局部变量
| y | 1 /
+-------+
| x | 2 \
+-------+ |
| "yes" | 3 + 返回值
+-------+ |
| g1+g2 | 4 /
+-------+
| g2 | 5<-- 临时变量
+-------+
| |
栈右边的数字0~5是相对地址。其中2~4是返回值在栈上的位置,那么这个函数要返回的信息就是(2, 3)
,其中2
是返回值在栈上的起始位置,3
是返回值个数。由此可知新增的字节码Return
就需要关联2个参数。
除了上述的普遍情况外,还有两个特殊情况,即返回值个数为0和1的情况。
首先,对于返回值个数为0的情况,也就是没有返回值的return语句,虽然也可以使用Return
字节码来返回(0, 0)
,但是为了清晰,再增加一个不需要关联参数的字节码Return0
。
其次,对于返回值个数为1的情况,在语法分析时可以优化。上述多个返回值的情况下,强制把所有返回值依次加载到栈上,是为了连续,为了能够通知调用者返回值的位置。而如果只有1个返回值,就不要求连续了,那么对于本来就在栈上的局部变量,就无需再次加载到栈上了。当然对于其他类型的返回值(比如全局变量、常量、表索引等等)还是需要加载的。比如下面的例子:
local function foo()
local x, y = 1, 2
return x
end
在函数返回前的栈布局如下:
| |
+-------+
| foo | 调用者加载foo到栈上
+=======+ <--base
| x | 0 \ <-----返回值
+-------+ + 局部变量
| y | 1 /
+-------+
| |
只有一个返回值x
,并且是局部变量,已经在栈上,返回(0, 1)
即可,无需再次加载到栈顶。
综上,新增的两个字节码定义如下:
pub enum ByteCode {
Return0,
Return(u8, u8),
返回值语句的解析流程如下:
- 对于无返回值,生成
Return0
字节码; - 对于单个返回值,按需加载到栈上,并生成
Return(?, 1)
字节码; - 对于多个返回值,强制依次加载到栈上,并生成
Return(?, ?)
字节码。
return语句的语法分析
上面总结了返回语句的解析流程,现在开始语法分析。return语句的BNF定义如下:
retstat ::= return [explist] [‘;’]
除了可选的多个返回值表达式外,还可以有1个可选的;
。另外还有一个规则,即return语句后面必须紧跟一个block的结束Token,比如end
、else
等。这个语句比较简单,只是细节比较多。下面先列出代码:
fn ret_stat(&mut self) {
let code = match self.lex.peek() {
// return ;
Token::SemiColon => {
self.lex.next();
ByteCode::Return0 // 没有返回值
}
// return
t if is_block_end(t) => {
ByteCode::Return0 // 没有返回值
}
_ => { // 有返回值
let mut iret = self.sp;
// 读取表达式列表。只保留最后一个并返回ExpDesc,而把前面的加载到栈上。
// 返回值:nexp为前面加载的表达式个数,last_exp为最后一个表达式。
let (nexp, last_exp) = self.explist();
// check optional ';'
if self.lex.peek() == &Token::SemiColon {
self.lex.next();
}
// check block end
if !is_block_end(self.lex.peek()) {
panic!("'end' expected");
}
if nexp == 0 {
// 单个返回值,按需加载
iret = self.discharge_any(last_exp);
} else {
// 多个返回值,其他返回值已经依次加载到栈顶,现在需要把最后一个
// 表达式也强制加载到栈顶,排在其他返回值的后面
self.discharge(self.sp, last_exp);
}
ByteCode::Return(iret as u8, nexp as u8 + 1)
}
};
self.fp.byte_codes.push(code);
}
因为对单个和多个返回值的处理有区别,所以在读取返回值列表时,要保留最后一个表达式不要直接加载到栈上。此时上一节改造过的explist()
函数再次派上了用场。如果只有最后这一个表达式,即nexp == 0
,那么就是单个表达式的情况,则按需加载到栈上;否则,就是多个返回值的情况,其他返回值已经依次加载到栈顶,需要把最后一个表达式也强制加载到栈顶,排在其他返回值的后面。
复习一下,上面代码中,单个返回值的情况中的discharge_any()
方法是按需加载,即并不处理已经在栈上的表达式(局部变量或临时变量等);而多个返回值的情况中的discharge()
方法是强制加载。
Return字节码的执行
完成语法分析后,接下来介绍虚拟机对Return
字节码的执行。需要做两件事情:
-
从当前函数的执行
execute()
中退出,使用Rust的return语句即可; -
告知给调用者返回值的位置,最直观的方法就是返回
Return
字节码关联的两个参数:返回值在栈上的起始位置和个数。不过这里的起始位置要从相对位置转换为绝对位置。代码如下:
ByteCode::Return(iret, nret) => {
return (self.base + iret as usize, nret as usize);
}
这样有点啰嗦,并且还有2个问题:
-
Lua中Rust函数类型(比如
print
函数)的原型是fn (&mut ExeState) -> i32
,只有一个返回值i32
,代表的是Rust函数返回值的个数。如果Lua函数类型的返回两个值,那么这两类函数的返回信息不一致,后续不方便处理。 -
本节后面会支持Lua函数的可变数量返回值,具体的返回值个数需要根据执行情况计算得出。
所以这里也改成只返回Lua函数返回值的个数,而不用返回起始位置。为此,需要把栈上可能的临时变量清理掉,以确保返回值在栈顶。这样,调用者只根据返回值的个数,就能确定返回值的位置。还用上面的例子:
| |
+-------+
| foo | 调用者加载foo到栈上
+=======+ <--base
| x | 0 \
+-------+ + 局部变量
| y | 1 /
+-------+
| x | 2 \
+-------+ |
| "yes" | 3 + 返回值
+-------+ |
| g1+g2 | 4 /
+-------+
| | <--清理掉临时变量g2
这个例子里,在清理掉栈顶的临时变量g2
后,对调用函数只返回3
即可,调用函数就可以读取栈顶的3个值作为返回值。
那么为什么Return
字节码中需要关联2个参数呢?除了返回值的个数,还要编入返回值的起始位置?这是因为语法分析阶段很难确定在执行过程中栈顶是否有临时变量(比如上面例子中的g2
),即便能够确定也对这些临时变量无能为力(除非增加一个字节码去清理临时变量)。所以只通过个数是无法表示返回值的。而在虚拟机执行阶段,由于可以清理可能的临时变量,没有了临时变量的干扰,就无须再返回起始地址了。
综上,Return
字节码的执行代码如下:
ByteCode::Return(iret, nret) => {
let iret = self.base + iret as usize; // 相对地址转换为绝对地址
self.stack.truncate(iret + nret as usize); // 清理临时变量,确保栈顶nret都是返回值
return nret as usize;
}
ByteCode::Return0 => {
return 0;
}
相应地,虚拟机执行的入口函数execute()
也要修改原型,从没有返回值,修改为usize类型返回值:
pub fn execute(&mut self, proto: &FuncProto) -> usize {
字节码遍历和函数退出
既然说到了execute()
函数,那么就说下字节码序列的遍历和退出。
这个项目最开始的时候只支持顺序执行,使用Rust Vec的迭代器即可:
for code in proto.byte_codes.iter() {
match *code {
后来支持跳转语句后,就要手动来遍历,并通过pc是否超出字节码序列来判断退出:
let mut pc = 0;
while pc < proto.byte_codes.len() {
match proto.byte_codes[pc] {
现在支持了Lua的return语句,对应的Return
字节码的执行会退出execute()
函数。如果所有的Lua函数最终都包含Return
字节码,那就不需要通过pc是否超出字节码序列来判断退出了。这样execute()
函数中原来的while
循环就可以改成loop
循环,减少一次条件判断:
let mut pc = 0;
loop {
match proto.byte_codes[pc] {
ByteCode::Return0 => { // Return或Return0字节码,退出函数
return 0;
}
为此,我们在所有Lua函数的结尾都加上Return0
字节码:
fn chunk(lex: &mut Lex<impl Read>, end_token: Token) -> FuncProto {
let mut proto = ParseProto::new(lex);
assert_eq!(proto.block(), end_token);
if let Some(goto) = proto.gotos.first() {
panic!("goto {} no destination", &goto.name);
}
// 所有Lua函数的结尾都加上`Return0`字节码
proto.fp.byte_codes.push(ByteCode::Return0);
proto.fp
}
至此,完成了函数退出并生成返回值的功能。接下来介绍第二部分:调用者读取返回值。
读取返回值:位置
被调用函数通过return语句返回后,虚拟机执行序列就重新回到外层调用函数的Call
字节码,这里也就读取并处理返回值的位置。如何处理返回值?取决于函数调用所处的不同应用场景。因为Lua函数支持多返回值,并且在语法分析阶段不能确定返回值的具体个数,类似上一节的可变参数表达式...
,所以对函数返回值的处理就跟可变参数的处理类似,也包括3种场景:
-
作为函数调用的最后一个参数、return语句的最后一个参数、表构造的最后一个列表成员时,读取全部返回值。比如下面示例:
print("hello: ", foo(1, 2)) -- 最后一个实参 local t = {1, 2, foo()} -- 最后一个列表成员 return a+b, foo() -- 最后一个返回值
-
作为局部变量定义语句、或赋值语句的等号
=
后面最后一个表达式时,会按需求扩展或缩减返回值个数。比如下面示例:local x, y = foo() -- 取前2个实参,分别赋值给x和y t.k, t.j = a, foo() -- 取前1个实参,赋值给t.j
-
其他地方都只代表实际传入的第一个实参。比如下面示例:
local x, y = foo(), b -- 不是最后一个表达式,只取第1个实参并赋值给x t.k, t.j = foo(), b -- 不是最后一个表达式,只取第1个实参并赋值给t.k if foo() then -- 条件判断 t[foo()] = foo() + f -- 表索引,和二元运算操作数 end
除此之外,还有一种场景:
-
单独的函数调用语句,此时忽略返回值。比如下面示例:
print("no results") foo(1, 2, 3)
第4种场景不需要处理返回值,暂时忽略。前面3种场景,都需要把返回值从栈顶挪到函数入口的位置。比如对于print("hello", sqr(3, 4))
语句,在调用sqr()
函数前的栈布局如下面左图所示:
| | | | | |
+-------+ +-------+ +-------+
| print | | print | | print |
+-------+ +-------+ +-------+
|"hello"| |"hello"| |"hello"|
+-------+ +-------+ +-------+
| sqr | | sqr | / | 9 | <--原来sqr入口位置
+-------+ +-------+ <--base /-+ +-------+
| 3 | | 3 | | \ | 16 |
+-------+ +-------+ | +-------+
| 4 | | 4 | | | |
+-------+ +-------+ |
| | | 9 | \ |
+-------+ +返回值--/
| 16 | /
+-------+
| |
左图中,print
函数在栈的最上面,下面依次是参数"hello"
字符串常量和sqr()
函数,再下面是sqr()
函数的两个参数3
和4
。这里的重点是,在语法分析阶段,函数的参数是由explist()
生成字节码,依次加载到栈上,所以sqr()
函数一定位于外层print()
函数的参数位置。那么,sqr()
函数的返回值,就应该挪到sqr()
函数的位置,作为print()
函数的参数,如上图中的最右面图所示。
由此总结上面的3个栈布局图分别是:
-
左图是
sqr()
函数调用前的状态; -
中图是
sqr()
函数调用后,也就是本节之前部分介绍的Return
字节码执行后的状态; -
右图是
sqr()
函数调用后的预期状态,即sqr()
函数的返回值作为print()
函数的返回值。
于是,我们需要做的就是把栈布局从中图变成右图,所以在Call
字节码的处理流程中,把返回值从栈顶挪到函数入口的位置,即下面代码中的最后一行:
ByteCode::Call(func, narg_plus) => {
self.base += func as usize + 1;
match &self.stack[self.base - 1] {
Value::LuaFunction(f) => {
// 这里省略参数的处理。
// 调用函数,nret为位于栈顶返回值的个数
let nret = self.execute(&f);
// 删除从函数入口到返回值起始位置的栈数据,也就可以把
// 返回值挪到函数入口位置。
self.stack.drain(self.base+func as usize .. self.stack.len()-nret);
}
这里并不是直接把返回值挪到函数入口位置,而是通过Vec::drain()
方法把函数入口到返回值起始位置的栈数据清空,来实现返回值就位的。这么做也是为了同时清理被调用函数占用的栈空间,以便及时释放资源。
读取返回值:个数
上面介绍了把返回值放到什么位置,现在介绍如何处理返回值的个数。这一点也跟上一节的可变参数表达式一样,按照上述4种场景,也分为4种:
- 全部返回值;
- 固定前N个返回值;
- 第一个返回值;
- 不需要返回值。
跟VarArgs
字节码类似,Call
字节码也要增加一个参数,用以表示需要多少个返回值:
pub enum ByteCode {
Call(u8, u8, u8) // 增加第3个关联参数,表示需要多少个返回值
但这里有个区别,就是VarArgs
关联的表示个数的参数,取值0
表示全部可变实参。而函数调用这里增加了第4种场景,本来就不需要返回值,即需要0
个返回值,那么Call
字节码的新增关联参数就不能用0
做特殊值来表示全部返回值了。这就像上一节参数个数的场景,即本来就存在0
个参数的情况,就不能简单用0
做特殊值了。对于这个问题有两个解决方案:
-
参考上一节参数个数的处理方式,用
0
代表全部返回值,并且把固定N个返回值的情况改为N+1编入到Call
字节码中。这也是Lua官方实现采用的方案; -
把第4种场景的“不需要返回值”,重新理解为“忽略返回值”,也就是不需要处理返回值,或者说无所谓怎么处理返回值都可以。那么这个场景下这个关联参数随便填任何数都可以。我们这里选择填
0
。
我们选择后面的方案。也就是说,0
这个取值有2种含义:
- 需要全部返回值;
- 不需要返回值。
这两个场景虽然含义不同,但是虚拟机执行时的处理方式是一样的,都是不处理返回值。也就是说所有返回值(如果有的话)都会被放到函数入口的位置。
如果这个参数的值不是0
,就对应上述第2和第3种场景,即需要固定前N个和前1个返回值的情况,则需要处理:
- 如果实际返回值少于预期需求,那么需要补上nil;
- 否则,无需处理。多出的返回值在栈上就被认为是临时变量,并没有什么影响。
下面在虚拟机执行Call
字节码的流程中增加这个填补nil的处理:
ByteCode::Call(func, narg_plus, want_nret) => {
self.base += func as usize + 1;
match &self.stack[self.base - 1] {
Value::LuaFunction(f) => {
let nret = self.execute(&f);
self.stack.drain(self.base+func as usize .. self.stack.len()-nret);
// 按需填补nil
// 如果want_nret==0,那么无需处理,也不会进到if{}分支中。
let want_nret = want_nret as usize;
if nret < want_nret {
self.fill_stack(nret, want_nret - nret);
}
}
至此,完成对Call
字节码的虚拟机执行部分。
返回值场景的语法分析
我们之前所有的功能介绍,都是先语法分析,生成字节码,然后再虚拟机执行,执行字节码。不过这次有所不同,上面只是介绍了不同场景下Call
字节码的虚拟机执行;而没有介绍语法分析,即在各个场景下如何生成Call
字节码。现在补上。
上述第1和第2种场景,跟可变参数表达式的对应场景完全一样,所以这里不需要对这些语句做修改,只需要在discharge_expand()
和discharge_expand_want()
中增加ExpDesc::Call
表达式即可。下面列出discharge_expand()
的代码,而``discharge_expand_want()`类似,这里就省略掉。
fn discharge_expand(&mut self, desc: ExpDesc) -> bool {
let code = match desc {
ExpDesc::Call(ifunc, narg_plus) => { // 新增函数调用表达式
ByteCode::Call(ifunc as u8, narg_plus as u8, 0)
}
ExpDesc::VarArgs => {
ByteCode::VarArgs(self.sp as u8, 0)
}
_ => {
self.discharge(self.sp, desc);
return false
}
};
self.fp.byte_codes.push(code);
true
}
在Lua中,语法分析阶段无法确定值的个数的情况,就只有可变参数和函数调用了。所以这两个函数至此完整了。假如还有其他类似的语句,也可以向这个函数中添加语句,而不需要修改具体的应用场景。
接下来看第3种场景,只取第一个返回值。跟上一节的可变参数语句一样的是,也是在discharge()
函数中完成ExpDesc::Call
表达式的加载。而跟可变参数语句不一样的是,可变参数生成的VarArgs
字节码的第一个关联参数就是目标地址,而这里的Call
字节码关联的3个参数是没有目标地址的。上面介绍了虚拟机执行时是把返回值放到函数的入口地址,但是discharge()
函数是要把表达式的值加载到指定地址的。所以,ExpDesc::Call
表达式的加载可能需要2条字节码:先生成Call
字节码调用函数并把返回值放到函数入口位置,再生成Move
字节码把第一个返回值赋值给目标地址。代码如下:
fn discharge(&mut self, dst: usize, desc: ExpDesc) {
let code = match desc {
ExpDesc::Call(ifunc, narg_plus) => {
// 生成Call,只保留1个返回值,并放在ifunc位置
self.fp.byte_codes.push(ByteCode::Call(ifunc as u8, narg_plus as u8, 1));
// 生成Move,把返回值从ifunc复制到dst位置
self.fp.byte_codes.push(ByteCode::Move(dst as u8, ifunc as u8));
}
比如下面的示例代码:
local x, y
x = foo()
其栈布局如下:
| | | | | |
+-------+ +-------+ +-------+
| x | | x | /---->| x |
+-------+ +-------+ | +-------+
| y | | y | | | y |
+-------+ +-------+ | +-------+
| foo | /---->| 100 |----/ | |
+-------+ | +-------+ Move字节码把返回值赋值给目标地址
: : | | |
+-------+ |
| 100 |----/ Call字节码把返回值100
+-------+ 挪到函数入口foo的位置
| |
- 左图是
foo()
函数返回前的栈布局,假设栈顶的100
是函数的返回值; - 中图是
Call
字节码执行完毕后,把返回值挪到函数入口位置,这是本节上面完成的功能; - 右图是
Move
字节码把返回值赋值给目标地址,即局部变量x
。
可以看到这个场景下生成了2条字节码,而返回值也被移动了2次。这里就有优化的空间。之所以需要2条字节码,是因为Call
字节码没有关联目标地址的参数,所以不能直接赋值。而之所以没有关联目标地址参数,是因为Call
字节码中已经塞了3个参数了,没有空间再塞进去目标地址了。
确定了问题后,优化方案也就很明显了。既然这个场景下总是只需要1个返回值,那么Call
字节码中的第3个关联参数(需要的返回值个数)就没有意义。所以可以新增一个专门用于这个场景的字节码,删掉Call
字节码中的第3个参数,腾出空间就可以加上目标地址这个参数了。为此,我们新增CallSet
字节码:
pub enum ByteCode {
Call(u8, u8, u8), // 关联参数:函数入口,参数个数,预期返回值个数
CallSet(u8, u8, u8), // 关联参数:目标地址,函数入口,参数个数
这样,在discharge()
函数中,函数调用语句就只需要一个字节码即可:
fn discharge(&mut self, dst: usize, desc: ExpDesc) {
let code = match desc {
ExpDesc::Call(ifunc, narg) => {
ByteCode::CallSet(dst as u8, ifunc as u8, narg as u8)
}
CallSet
字节码的虚拟机执行如下:
ByteCode::CallSet(dst, func, narg) => {
// 调用函数
let nret = self.call_function(func, narg);
if nret == 0 { // 没有返回值,设置nil
self.set_stack(dst, Value::Nil);
} else {
// use swap() to avoid clone()
let iret = self.stack.len() - nret as usize;
self.stack.swap(self.base+dst as usize, iret);
}
// 清理函数调用占用的栈空间
self.stack.truncate(self.base + func as usize + 1);
}
上述代码中的call_function()
方法,是把Call
字节码的执行流程提取出来的函数。在调用完函数后,如果没有返回值则把目标地址设置nil,否则把第一个返回值赋值给目标地址。最后一行是
清理函数调用占用的栈空间,有两种情况:
- 如果目标地址是局部变量,那么清理的位置是从函数入口;
- 如果目标地址是临时变量,在
discharge_any()
中把函数返回值的目标地址都设置为函数入口位置,所以清理的位置是从函数入口后面1个位置开始。
综上,总是从函数入口位置后面1个位置开始清理,是可以满足上述两个情况的。只是对于局部变量的情况,会多保留一个函数入口而已。
可变数量返回值
上面介绍了返回值的语法分析和虚拟机执行,但还漏掉一个地方。上一节列出的可变参数的3种应用场景时,第1种场景里包括3条语句:表构造、函数实参、和函数返回值。当时只介绍了前2个语句,现在支持了返回值语句后,补上最后一个语句。
本节上面介绍了return语句的语法分析,但当时是把所有返回值的表达式依次加载到栈上,即只支持固定数量的返回值。当函数返回值语句的最后一个表达式是可变参数或者函数调用语句时,那么虚拟机执行时的全部可变参数或者函数全部返回值都会作为这个函数的返回值,也就是说返回值的数量在语法分析阶段无法确定,也就是可变数量的返回值。
可变数量的返回值,语法分析可以参考上一节中表构造或函数实参,即使用改造后的explist()
函数,对最后一个表达式特殊处理。具体代码这里省略。
需要解释的是如何用字节码表示“可变数量”。本节中新增2个返回值相关的字节码,Return0
和Return
。其中Return0
用于没有返回值的情况,所以Return
字节码中关联的返回值个数的参数不会是0
,那么0
就可以作为特殊值,用来表示可变数量的返回值。
可变数量相关语句和场景总结
这里总结下可变数量相关的语句和场景。直接导致出现可变数量的语句包括:
- 可变参数语句,有3个应用场景;
- 函数调用语句,除了可变参数的3个应用场景外,还有一个忽略返回值的场景。
这两条语句的几个应用场景中,第1个场景都是取虚拟机执行时的实际全部参数或返回值,这个场景包括3个语句:
- 表构造,对应
SetList
字节码; - 函数实参,对应
Call/CallSet
字节码; - 函数返回值,对应被调用函数的
Return
字节码,和调用函数的Call/CallSet
字节码。
上述几个字节码中,为了表示“虚拟机执行时实际全部表达式”这个状态,都使用了0
作为特殊值,其中:
-
Call/CallSet
字节码的第2个参数代表实际参数的个数。因为函数调用本来就支持没有参数的情况,所以为了0
能作为特殊值,把固定参数的情况都修正加1,即N个固定参数就在字节码中编入N+1; -
Call/CallSet
字节码的第3个参数代表预期返回值的个数。函数调用本来也支持不需要返回值的情况,但是我们把“不需要”理解为“忽略”,那么读取全部返回值也没问题,于是0
就可以作为特殊值; -
SetList
和Return
字节码的第2个参数都是表示个数。但是这两个字节码用于固定个数的时候并不支持没有表达式,所以0
就可以直接作为特殊值。
另外,需要再次强调的是,当上述字节码中用0
代表特殊值时,具体的表达式个数是通过栈顶来计算得到,这就要确保栈顶没有临时变量,所以虚拟机执行可变参数和函数调用语句时,都要显式清理栈上临时变量。
小结
本节开始介绍了固定数量的返回值。被调用函数通过Return/Return0
字节码把返回值放置在栈顶,然后调用函数在Call/CallSet
字节码中读取返回值。
后续介绍了可变数量的返回值,这个跟上一节的可变参数很类似。
Rust函数和API
本章前面三节介绍的是在Lua中定义的函数,本节来介绍在Rust中定义的函数。后续简单起见,分别称这两类函数为“Lua函数”和“Rust函数”。
其实我们已经接触过Rust函数了,在第一章hello, world!
版本的时候就已经支持的print()
就是Rust函数。当时的解释器就实现了Rust函数的定义和调用流程。其中定义如下:
pub enum Value {
RustFunction(fn (&mut ExeState) -> i32),
这里以print()
函数的实现代码为例:
fn lib_print(state: &mut ExeState) -> i32 {
println!("{}", state.stack[state.base + 1]);
0
}
而Rust函数的调用方法也跟Lua函数类似,也是在Call
字节码中调用Rust函数:
ByteCode::Call(func, _) => {
let func = &self.stack[func as usize];
if let Value::Function(f) = func {
f(self);
上面罗列的代码都是已经实现的Rust函数的功能,不过也只是最基本的定义和调用,还是缺少参数和返回值。本节为Rust函数增加这两个特性。
需要说明的一点是在Lua代码中,函数调用语句是不区分Lua函数和Rust函数的。换句话说,在语法分析阶段是不区分这两种类型的。只是在虚拟机执行阶段,才需要对两种类型区别处理。所以,本节下面介绍的都是虚拟机阶段。
参数
Rust函数的参数也是通过栈来传递的。
可以看到当前print()
函数的实现只支持一个参数,是通过直接读取栈上数据:state.stack[state.base + 1])
,其中self.base
是函数入口地址,+1
就是后面紧跟的地址,也就是第一个参数。
现在要支持多个参数,就要通知Rust函数具体的参数个数。有两个方案:
- 修改Rust函数原型定义,新增一个参数,表达参数的个数。这个方案实现简单,但是跟Lua官方的C函数原型不一致;
- 采用之前Lua函数中可变参数的机制,即通过栈顶位置来确定参数个数。
我们采取后面的方案。这需要在调用函数Rust前清理掉栈顶可能的临时变量:
ByteCode::Call(func, narg_plus) => {
let func = &self.stack[func as usize];
if let Value::Function(f) = func {
// narg_plus!=0,固定参数,需要清理栈顶可能的临时变量
// narg_plus==0,可变参数,无需清理
if narg_plus != 0 {
self.stack.truncate(self.base + narg_plus as usize - 1);
}
f(self);
在清理掉栈顶可能的临时变量后,在Rust函数中就可以通过栈顶来判断具体的参数个数了:state.stack.len() - state.base
;也可以直接读取任意的参数,比如第N个参数:state.stack[state.base + N])
。于是改造print()
函数如下:
fn lib_print(state: &mut ExeState) -> i32 {
let narg = state.stack.len() - state.base; // 参数个数
for i in 0 .. narg {
if i != 0 {
print!("\t");
}
print!("{}", state.stack[state.base + i]); // 打印第i个参数
}
println!("");
0
}
返回值
Rust函数的返回值也是通过栈来传递的。Rust函数在退出前把返回值放到栈顶,并返回数量,也就是Lua函数原型的i32
类型返回值的功能。这跟上一节介绍的Lua函数的机制一样。只需要在Call
字节码执行时,按照上一节中介绍的Lua函数返回值的方式来处理Rust函数的返回值即可:
ByteCode::Call(func, narg_plus) => {
let func = &self.stack[func as usize];
if let Value::Function(f) = func {
if narg_plus != 0 {
self.stack.truncate(self.base + narg_plus as usize - 1);
}
// 返回Rust函数返回值的个数,跟Lua函数一致
f(self) as usize
把Rust函数f()
的返回值从i32
转换为usize
类型并返回,表示返回值的个数。这里i32
到usize
的类型转换是扎眼的,这是因为Lua官方实现中C函数用返回负数来代表失败。我们到目前为止对所有的错误都是直接panic。后续章节会统一处理错误,届时使用Option<usize>
来代替i32
后,就会去掉这个扎眼的转换。
之前的print()
函数没有返回值,返回0
,所以并没有体现出返回值这个特性。下面用带返回值的另一个Lua标准库函数type()
举例。这个函数的功能是返回第一个参数的类型,返回值的类型是字符串,比如"nil"、"string"、"number"等。
fn lib_type(state: &mut ExeState) -> i32 {
let ty = state.stack[state.base + 1].ty(); // 第一个参数的类型
state.stack.push(ty); // 把结果压到栈上
1 // 只有1个返回值
}
这其中的ty()
函数是对Value
类型新增的方法,返回类型描述,这里省略具体代码。
Rust API
至此实现了Rust函数的参数和返回值的特性。但是上面对参数和返回值的访问和处理方式太过直接,给Rust函数的能力太强,不仅可以访问当前函数的参数,还可以方法整个栈空间,甚至整个state
状态。这是不合理的,也是危险的。需要限制Rust函数对state
状态的访问,包括整个栈,这就需要通过API来提供Rust函数访问state
的有限的能力。我们来到了一个新的世界:Rust API,当然在Lua官方实现中被称为C API。
Rust API是由Lua解释器提供的,给Rust函数(Rust实现的Lua库)使用的API。其角色如下:
+------------------+
| Lua代码 |
+---+----------+---+
| |
| +------V------+
| | 标准库(Rust)|
| +------+------+
| |
| |Rust API
| |
+---V----------V---+
| Lua虚拟机(Rust) |
+------------------+
上面小节中Rust函数中有3个功能需求就都应该由API来完成:
- 读取实际参数个数;
- 读取指定参数;
- 创建返回值
下面依次介绍这3个需求。首先是读取实际参数个数的功能,对应Lua官方实现中lua_gettop()
API。为此我们提供get_top()
API:
impl<'a> ExeState {
// 返回栈顶,即参数个数
pub fn get_top(&self) -> usize {
self.stack.len() - self.base
}
这个get_top()
函数虽然也是ExeState
结构的方法,但是是作为API提供给外部调用的。而ExeState
之前的方法(比如execute()
、get_stack()
等)都是虚拟机执行调用的内部方法。为了区分这两类方法,我们给ExeState
结构新增一个impl
块单独用来实现API,以增加可读性。只不过Rust中不允许在不同文件内实现结构体的方法,所以不能拆到另外一个文件中。
然后,读取指定参数的功能,这在Lua官方实现中并不是对应一个函数,而是一系列函数,比如lua_toboolean()
、lua_tolstring()
等,分别针对不同的类型。而借助Rust语言的泛型能力,我们就可以只提供一个API:
pub fn get<T>(&'a self, i: isize) -> T where T: From<&'a Value> {
let narg = self.get_top();
if i > 0 { // 正数索引,从self.base计数
let i = i as usize;
if i > narg {
panic!("invalid index: {i} {narg}");
}
(&self.stack[self.base + i - 1]).into()
} else if i < 0 { // 负数索引,从栈顶计数
let i = -i as usize;
if i > narg {
panic!("invalid index: -{i} {narg}");
}
(&self.stack[self.stack.len() - i]).into()
} else {
panic!("invalid 0 index");
}
}
可以看到这个API也支持负数索引,表示从栈顶开始倒数,这是Lua官方API的行为,也是很常见的使用方法。这也体现出API比直接访问栈的优势。
但是,这里也有跟官方API不一致的行为:当索引超出栈范围时,官方会返回nil
,但我们这里就直接panic。后续在介绍到错误处理时再详细讨论这里。
基于上述两个API,就可以重新print()
函数:
fn lib_print(state: &mut ExeState) -> i32 {
for i in 1 ..= state.get_top() {
if i != 1 {
print!("\t");
}
print!("{}", state.get::<&Value>(i).to_string());
}
println!("");
0
}
最后再来看最后一个功能,创建返回值。跟上面读取参数的API一样,在Lua官方实现里也对应一系列函数,比如lua_pushboolean()
、lua_pushlstring()
等。而这里也可以借助泛型只增加一个API:
pub fn push(&mut self, v: impl Into<Value>) {
self.stack.push(v.into());
}
基于这个API,上面type()
函数最后一行的self.stack.push()
就可以修改为self.push()
。
虽然替换API后,print()
和type()
函数的实现并没有明显变化,但是API对ExeState
提供了封装,在后续逐步增加库函数的过程中,会慢慢体现出方便性和安全性。
尾调用
Lua语言是支持尾调用(tail call)消除的。本节介绍并支持尾调用。
首先介绍尾调用这个概念。当一个函数的最后一个动作是调用另一个函数而没有再进行其他工作时,就形成了尾调用。比如下面的示例代码:
function foo(a, b)
return bar(a + b)
end
foo()
函数的最后一个动作(这个例子里也是唯一的动作)就是调用bar()
函数。下面先看看在不引入尾调用的情况下,foo()
函数的执行过程,如下图所示:
| | | | | | | |
+-------+ +-------+ +-------+ +-------+
| foo() | | foo() | | foo() | / | ret1 |
+-------<< +-------+ +-------<< /-+ +-------+
| a | | a | | a | | \ | ret2 |
+-------+ +-------+ +-------+ | +-------+
| b | | b | | b | | | |
+-------+ +-------+ +-------+ |
| bar() | | bar() | / | ret1 | \ |
+-------+ +-------<< /-+ +-------+ >-/返回值
| a+b | | a+b | | \ | ret2 | /
+-------+ +-------+ | +-------+
| | : : | | |
+-------+ |
| ret1 | \ |
+-------+ >-/返回值
| ret2 | /
+-------+
| |
-
最左边第1个图,是在
foo()
函数内部准备好了调用bar()
函数之前的栈布局。也就是在调用Call(bar)
字节码之前。 -
第2个图是
bar()
函数调用刚刚完成后的栈布局。也就是bar()
函数的Return
字节码执行完毕后,但还没有返回到foo()
函数的Call(bar)
字节码之前。假设这个函数有两个返回值ret1
和ret2
,目前在栈顶。 -
第3个图是
bar()
函数返回后的栈布局。也就是foo()
的Call(bar)
字节码执行完毕。即把两个返回值挪到bar()
的入口函数位置。 -
第4个图是
foo()
函数返回后的栈布局。也就是更外层的调用者的Call(foo)
字节码执行完毕后。即把两个返回值挪到foo()
的入口函数位置。
后面的3个图是连续执行的。观察下其中的优化空间:
-
一个比较明显的优化思路是,最后的两次返回值的复制可以一步完成。但这个很难优化,而且并不会优化多少性能;
-
另外一个不那么明显的地方是,在最左边第1个图
bar()
函数准备好调用后,foo()
函数的栈空间就再没有用到了。所以,可以在调用bar()
函数前,先清理掉foo()
函数占用的栈空间。按照这个思路,下面重新绘制调用流程:
| | | | | | | |
+-------+ +-------+ +-------+ +-------+
| foo() | / | bar() | | bar() | / | ret1 |
+-------<< /-+ +-------<< +-------<< /-+ +-------+
| a | | \ | a+b | | a+b | | \ | ret2 |
+-------+ | +-------+ +-------+ | +-------+
| b | | | | : : | | |
+-------+ | +-------+ |
| bar() | \ | | ret1 | \ |
+-------+ >-/ +-------+ >-/
| a+b | / | ret2 | /
+-------+ +-------+
| | | |
-
最左边第1个图不变,仍然是
bar()
函数调用前的状态; -
第2个图,在调用
bar()
前,先清理掉foo()
函数的栈空间; -
第3个图,对应上面的第2个图,是调用完
bar()
函数后。 -
第4个图,对应上面最后一个图。由于刚才已经清理过
foo()
函数的栈空间,所以跳过了上面的第3个图。
跟上面的普通流程对比,这个新流程的操作步骤虽然有改变,但并没有减少,所以对性能并没有优化。但是,在栈空间的使用上有优化!在bar()
函数执行之前就已经释放了foo()
的栈空间。2层函数调用,但只占用了1层的空间。这带来的优势在这个例子中并不明显,但是在递归调用中就很明显,因为一般递归调用都会有非常多层。如果递归调用的最后一条满足上述尾调用,那么在应用新流程后,就可以支持无限次的递归调用,而不会导致栈溢出!这里的栈溢出,指的是上图中画的Lua虚拟机的栈,而不是Rust程序的栈溢出。
相比于上面的普通流程,这个新流程还有一个小的不同。上面每个图中栈上的<<
代表的是当前self.base
的位置。可以看到在上面的普通流程中,self.base
发生过变化;而在新的流程中,全程没有变化。
在介绍完尾调用的概念后,下面介绍具体实现。
语法分析
在开始语法分析前,再次明确下尾调用的规则:当一个函数的最后一个动作是调用另一个函数而没有再进行其他工作时,就形成了尾调用。下面举一些《Lua程序设计》一书中的反例:
function f1(x)
g(x) -- 在f1()返回前,还要丢掉g(x)的返回值
end
function f2(x)
return g(x) + 1 -- 还要执行+1
end
function f3(x)
return x or g(x) -- 还要把g(x)的返回值限制为1个
end
function f4(x)
return (g(x)) -- 还要把g(x)的返回值限制为1个
end
在Lua语言中,只有形如return func(args)
的调用才是尾调用。当然这里的func
和args
可以很复杂,比如return t.k(a+b.f())
也是尾调用。
在明确规则后,语法分析时判断尾调用就比较简单。在解析return语句时,增加对尾调用的判断:
let iret = self.sp;
let (nexp, last_exp) = self.explist();
if let (0, &ExpDesc::Local(i)) = (nexp, &last_exp) {
// 只有1个返回值并且是局部变量
ByteCode::Return(i as u8, 1)
} else if let (0, &ExpDesc::Call(func, narg_plus)) = (nexp, &last_exp) {
// 新增尾调用:只有1个返回值,并且是函数调用
ByteCode::TailCall(func as u8, narg_plus as u8)
} else if self.discharge_expand(last_exp) {
// 最后一个返回值是可变类型,比如可变参数或者函数调用,
// 则在语法分析阶段无法得知返回值个数
ByteCode::Return(iret as u8, 0)
} else {
// 最后一个返回值不是可变类型
ByteCode::Return(iret as u8, nexp as u8 + 1)
}
上述代码中一共有4个情况。其中第2个情况是新增的尾调用,另外3种情况都是本章之前小节中已经支持的,这里不再介绍。
新增的字节码TailCall
类似于函数调用字节码Call
,但是由于尾调用的返回值肯定是函数调用,返回值个数肯定未知,所以就省略第3个关联参数。至此,函数调用相关的字节码就有3个了:
pub enum ByteCode {
Call(u8, u8, u8),
CallSet(u8, u8, u8),
TailCall(u8, u8), // 新增尾调用
虚拟机执行
接下来看尾调用的虚拟机执行部分。由本节开头介绍的尾调用的流程,可以得出相对于普通的函数调用,尾调用的执行有3点不同:
- 在调用内层函数前,先提前清理外层函数的栈空间,这也是尾调用的意义所在;
- 在内层函数返回后,由于外层函数已经被清理,所以没必要返回给外层函数,而是直接返回给更外层的调用函数。
- 全程不需要调整
self.base
。
由此,可以实现TailCall
字节码的执行流程如下:
ByteCode::TailCall(func, narg_plus) => {
self.stack.drain(self.base-1 .. self.base+func as usize);
return self.do_call_function(narg_plus);
}
非常简单,只有两行代码:
第1行,通过self.stack.drain()
来清理外层函数的栈空间。
第2行,通过return
语句直接从当前execute()
中返回,也就是说当内层函数执行完毕后,无需返回给当前函数,而是直接返回给更外层的调用者。另外,根据上面列出的尾调用的规则,这一行Rust代码本身也属于尾调用。所以只要Rust语言也支持尾调用消除,那么我们的Lua解释器在执行过程中,其本身的栈也不会有增加。
另外,第2行中新增的do_call_function()
方法执行具体的函数调用,其代码是从之前小节中Call
和CallSet
字节码调用的call_function()
方法中提取出来的,只不过去掉了对self.base
的更新。而call_function()
方法就修改为对这个新方法的包装:
fn call_function(&mut self, func: u8, narg_plus: u8) -> usize {
self.base += func as usize + 1; // get into new world
let nret = self.do_call_function(narg_plus);
self.base -= func as usize + 1; // come back
nret
}
测试
至此,我们完成了尾调用。用下面的Lua代码验证下:
function f(n)
if n > 10000 then return n end
return f(n+1)
end
print(f(0))
但是执行时出现栈溢出错误:
$ cargo r -- test_lua/tailcall.lua
thread 'main' has overflowed its stack
fatal runtime error: stack overflow
[1] 85084 abort cargo r -- test_lua/tailcall.lua
起初我以为是Rust的debug版本没有进行尾调用优化,但是后来加上--release
后,也只是可以支持更大的递归深度,推迟了栈溢出,但最终还是会栈溢出。这就要回到刚才说的:“所以只要Rust语言也支持尾调用消除,那么。。。”,这句话前面的假设可能不成立,即Rust语言可能不支持尾调用消除。这里有篇文章介绍了Rust语言对于尾调用的讨论,结论大概就是由于实现太复杂(可能是要涉及资源drop),并且收益有限(如果真有必要那么程序员可以手动改递归为循环),所以最终Rust语言并不支持尾调用消除。这么说来,为了让本节完成的Lua的尾调用消除有意义,只能把对execute()
函数的递归调用改成循环。这个改动本身并不难,但是后续还有两处要修改函数调用流程的地方,一是整个程序的入口函数的调用方式,二是支持协程后函数的状态保存。所以计划在完成最终的函数调用流程后,再做这个改动。
闭包Closure
上一章介绍了函数,而Lua语言中所有的函数其实都是闭包Closure。本章就介绍闭包。
所谓闭包,就是函数原型关联一些变量。在Lua中,这些关联的变量称之为Upvalue。如果你了解Rust中的闭包,那么按照《Rust程序设计语言》中的说法是“捕获环境”,跟“关联变量”是一个意思。所以Upvalue是理解和实现闭包的根本。
本章接下来的第1节介绍Upvalue的最基本的概念;后面第2,3节介绍Upvalue的重要特性,逃逸,这也是使得闭包真正强大的原因;第4节介绍对应Rust函数的Rust闭包。再后面的第5,6两节分别是闭包和Upvalue的两个应用场景。
Upvalue
在介绍闭包前,本节先来介绍闭包的重要组成部分:Upvalue。
本节主要引入Upvalue的概念,并介绍语法分析和虚拟机执行阶段为支持Upvalue所需要做的改动。要实现Upvalue的完整特性是非常复杂的,因此为了聚焦在整体结构和流程的改动上,本节只支持最基本的Upvalue特性,而留到下一节再介绍Upvalue的逃逸(escape)和跨多层函数的复杂特性。
下面的示例代码展示了Upvalue最基本的场景:
local a = 1
local function foo()
print(a) -- `a`是什么类型的变量?局部变量,还是全局变量?
end
整个代码可以看做是一个顶层函数,其中定义了两个局部变量:a
和函数foo
。foo()
函数内部的变量a
引用的是函数外定义的局部变量,那a
在函数内部算是什么变量?首先,它不是在foo()
函数内部定义的,所以不是局部变量;其次,它是在外层函数中定义的局部变量,所以也不是全局变量。像这种引用外层函数的局部变量,在Lua中称为Upvalue。在Rust语言中也有闭包的概念,也可以引用外层函数中的局部变量,称之为“捕获环境”,应该跟Upvalue是一个概念。
Upvalue在Lua中非常常见。除了上述明显的情况外,还有就是调用同级的局部函数也是Upvalue,比如下面代码:
local function foo()
print "hello, world"
end
local function bar()
foo() -- Upvalue
end
在bar()
函数中调用的foo()
函数就是Upvalue。另外,对局部函数的递归调用也是Upvalue。
介绍完Upvalue的概念后,下面Upvalue的语法解析流程。
变量解析流程
之前的解释器只支持局部变量和全局变量这两种变量类型,变量的解析流程如下:
- 在当前函数的局部变量中匹配,如果找到则是局部变量;
- 否则是全局变量。
现在要新增支持Upvalue类型,那么变量的解析流程就要增加一步,修改为:
- 在当前函数的局部变量中匹配,如果找到则是局部变量;
- 在上层函数的局部变量中匹配,如果找到则是Upvalue;(新增步骤)
- 否则是全局变量。
新增的第2步看上去简单,但是具体实现很复杂,而且这里的描述也不准确,这个留在下一节详细介绍。本节重点关注整体流程,即解析到Upvalue后要怎么处理。
类似于局部变量和全局变量,对于Upvalue也要新增一种ExpDesc:
enum ExpDesc {
Local(usize), // 局部变量或栈上临时变量
Upvalue(usize), // Upvalue
Global(usize), // 全局变量
复习一下,局部变量ExpDesc::Local
的关联参数代表在栈上的索引,全局变量ExpDesc::Global
的关联参数代表变量名在常量表中的索引。那Upvalue需要关联什么参数?用下面包含多个Upvalue的示例代码为例:
local a, b, c = 100, 200, 300
local function foo()
print (c, b)
end
上述代码中,foo()
函数中有两个Upvalue,c
和b
,分别对应上层函数中的局部变量的索引2和1(索引以0开始计数),那么很自然的,就可以用ExpDesc::Upvalue(2)
和ExpDesc::Upvalue(1)
来表示。这样在虚拟机执行时,也可以很方便的索引到上一层函数的栈上局部变量。简单而且自然。但是在下一节介绍到Upvalue的逃逸时,这个方案就不能满足要求了。不过简单起见,本节暂时先这么用。
语法分析上下文
上面变量解析流程中新增的第2步,要求能够访问外层函数的局部变量。在上一章解析函数时,利用递归来支持多层函数定义。这样不仅实现简单,而且还能提供一定程度的封装,即只能访问当前函数的信息。这本来是优点,但现在为了支持Upvalue,就需要访问外层函数的局部变量,那么这个封装就变成了需要克服的缺点了。程序都是在这样的不断增加的需求中变得越来越复杂和混乱。
之前递归解析多层函数时,有个贯穿始终的成员,即ParseProto
中的lex: Lex<R>
,在解析所有函数时都需要访问它。现在为了能访问外层函数的局部变量,也就需要一个类似的贯穿始终的成员,存储每一层函数的局部变量。为此,我们创建一个新的数据结构,包含原来的lex
和新的局部变量列表:
struct ParseContext<R: Read> {
all_locals: Vec<Vec<String>>, // 每一层函数的局部变量
lex: Lex<R>,
}
其中的all_locals
成员代表每一层函数的局部变量列表。每次解析新一层的函数时,向其中压入新成员;解析完毕后弹出。所以列表中最后一个成员就是当前函数的局部变量列表。
然后在ParseProto
中,用ctx代替原来的lex,并且删掉原来的locals:
struct ParseProto<'a, R: Read> {
// 删掉:locals: Vec<String>,
ctx: &'a mut ParseContext<R>, // 新增ctx,代替原来的lex
...
并且语法分析代码中所有用到locals字段的地方也都要修改为ctx.all_locals的最后一个成员,也就是当前函数的局部变量列表。这里省略具体代码。
至此,一共有3个语法分析相关的数据结构:
FuncProto
,定义函数原型,是语法分析阶段的输出,也是虚拟机执行阶段的输入,所以所有字段都是pub
的;ParseProto
,语法分析阶段内部使用的,当前函数的状态;ParseContext
,语法分析阶段内部使用的,所有函数层级都可以访问的全局状态。
在改造完ParseProto
,有了访问外层函数的能力后,就可以解析Upvalue了。不过这里只是说有了解析的能力,而具体的解析流程还是要到下一节介绍。
字节码
在解析完Upvalue后,对于其处理,可以参考以前对全局变量的讨论,结论如下:
- 读取,首先加载到栈上,转换为临时变量;
- 赋值,只支持从局部/临时变量和常量赋值,对于其他类型表达式则先加载到栈上临时变量再赋值。
为此,对照全局变量,新增3个Upvalue相关字节码:
pub enum ByteCode {
// global variable
GetGlobal(u8, u8),
SetGlobal(u8, u8),
SetGlobalConst(u8, u8),
// upvalue
GetUpvalue(u8, u8), // 加载Upvalue到栈上
SetUpvalue(u8, u8), // 从栈上赋值
SetUpvalueConst(u8, u8), // 从常量赋值
这3个新增字节码的生成,也完成可以参考全局变量。这里省略具体代码。
虚拟机执行
上面介绍了Upvalue的解析流程,已经对应的字节码,完成了语法分析阶段。剩下就是虚拟机执行阶段。
按照上面对Upvalue的处理方案,即ExpDesc::Upvalue
的关联参数表示上层函数的局部变量索引,在虚拟机执行时,也会遇到跟语法分析相同的问题:当前函数要访问上层函数的局部变量。所以要完成虚拟机执行阶段,也要对目前的代码结构做较大的改动。
不过,上述Upvalue的处理方案只是本节的临时方案,在下一节中为了支持Upvalue的逃逸,会有完全不同的方案,也会有完全不同的虚拟机执行过程。所以为了避免无用功,这里就暂时不实现这个方案下的虚拟机执行了。有兴趣的小伙伴可以试着改一下。
Upvalue的逃逸和闭包
上一节介绍了Upvalue的概念,并以Upvalue最基本的用法为例介绍了为支持Upvalue而对语法分析做的改造。本节就来介绍Upvalue的完整特性,主要是Upvalue的逃逸。
下面参考《Lua程序设计》书中的示例代码:
local function newCounter()
local i = 0
return function ()
i = i + 1 -- upvalue
print(i)
end
end
local c1 = newCounter()
c1() -- 输出:1
c1() -- 输出:2
上述代码中的newCounter()
是个典型的工厂函数,它创造并返回一个匿名函数。这里需要说明的是,返回的匿名函数引用了newCounter()
中的局部变量i
,也就是Upvalue。
下半段代码调用newCounter()
函数并把返回的匿名函数赋值给c1
并调用。此时newCounter()
函数已经结束,看上去其中定义的局部变量i
也已经超出了作用范围,此时再调用c1
去引用局部变量i
会出问题(如果你是C语言程序员应该能明白这个意思)。然而在Lua中,闭包的机制保证了这里调用c1
是没问题的。也就是Upvalue的逃逸。
《Lua程序设计》这本书是面向Lua程序员的,介绍到Upvalue逃逸的概念就足够了。但我们的目的是要实现一个解释器(而不只是使用解释器),所以不仅要知道这样是没问题的,还要知道如何来做到没问题,即如何实现Upvalue的逃逸。
不可行的静态存储方案
最简单的办法是参考C语言中函数内部的static变量,对于被Upvalue引用的局部变量(比如这里newCounter()
函数中的i
)并不放到栈上,而是放到一个静态的区域。但是这个方案并不可行,因为C语言中的static变量是全局唯一的,而Lua中的Upvalue是每次调用都会生成一份新的。比如接着上面的代码,继续如下代码:
local c2 = newCounter()
c2() -- 输出:1。新的计数开始。
c1() -- 输出:3。继续上面c1的输出。
再次调用newCounter()
,会生成一个新的计数器,其中的局部变量i
会重新初始化为0,重新开始计数。此时就存在两个计数器:c1
和c2
,两者各自拥有独立的局部变量i
。于是当调用c2()
的时候,会从1开始重新计数;如果穿插调用之前的c1()
也会继续之前的计数。多么有趣!
--+---+-- +---+ +---+
| i | | i | | i |
--+-^-+-- +-^-+ +-^-+
| | |
/---+---\ | |
| | | |
+----+ +----+ +----+ +----+
| c1 | | c2 | | c1 | | c2 |
+----+ +----+ +----+ +----+
上面左图展示的是把i
放到全局唯一的静态存储,那么所有计数器函数指向唯一的i。这是不满足我们需求的。我们需要的是右图所示,每个计数器函数都有独立的i
。
堆上存储方案
既然不能放在栈上,也不能放在全局静态区,那么就只能放在堆上了。接着的问题是,什么时候放到堆上?有几个可能的方案:
- 刚进入到函数时,就把所有被Upvalue引用的局部变量放到堆上;
- 当一个局部变量被Upvalue引用时,才从栈上挪到堆上;
- 当函数退出时,把所有被Upvalue引用的局部变量挪到堆上;
第1个方案不行,因为一个局部变量在被Upvalue引用前,可能已经被作为局部变量使用过了,已经生成了相关的字节码。第2个方案应该是可行的,但是局部变量在被Upvalue引用后,后续还可能在当前函数内被作为局部变量使用,提前挪到堆上并没有必要,毕竟对栈的访问更快更方便。所以我们选择第3个方案。
这种把局部变量从栈上挪到堆上的操作,我们遵循Lua官方实现的代码,也称之为“关闭close”。
接下来,为了演示一个Upvalue分别在逃逸前和逃逸后被访问的情况,在上面的计数器示例代码的基础上改造下,在内部的匿名函数返回前,先在newCounter()
函数内部调用一次。为此,需要把这个匿名函数赋值给一个局部变量retf
:
local function newCounter()
local i = 0
local function retf()
i = i + 1 -- upvalue
print(i)
end
retf() -- 在newCounter()内部调用
return retf -- 返回retf
end
分两部分介绍这个例子,先是在工厂函数内部调用retf()
,再是工厂函数返回retf
造成的Upvalue逃逸。
首先,在工厂函数内部调用retf()
的时候,retf
要操作的i
仍然还在栈上。示意图如下。
| |
+----------+
base |newCounter|
+----------+
0 | i |<- - - - - - - - - - \
+----------+ |
1 | retf +--+->+-FuncProto-----+--+
+----------+ | |byte_codes: | |
2 | retf +--/ | GetUpvalue(0, 0) |
+----------+ | ... |
| | +------------------+
图中,左边是栈,其中newCounter
是函数调用入口,也是当前函数的base位置,后面i和第一个retf是局部变量,第二个retf是函数调用的栈上入口,两个retf指向同一个函数原型。retf函数原型中的字节码序列中,第一个字节码GetUpvalue
是把Upvalue i
加载到栈上以执行加法。这个字节码有两个关联参数。第1个是加载到栈上的目标地址,这里忽略;第2个是Upvalue的源地址,参考上一节中对Upvalue的语法分析,这个参数的含义是:上一层函数的局部变量的栈索引。在这个例子里,就是i
在newCounter()函数中的索引,也就是0
。到此为止还都是上一节的内容,还没涉及到逃逸。
现在考虑Upvalue的逃逸。在newCounter()
函数退出后,左边的栈上的3个空间都会销毁,i
也就不存在了。为了retf函数后续可以继续访问i
,那在newCounter()
函数退出前,需要关闭局部变量i
,把i
从栈上挪到堆上。示意图如下:
| |
+----------+
base |newCounter| +===+
+----------+ close | i |<- - - \
0 | i +-------->+===+ ?
+----------+ ?
1 | retf +---->+-FuncProto-----?--+
+----------+ |byte_codes: ? |
| | | GetUpvalue(0, 0) |
| ... |
+------------------+
这样虽然保证了i
可以被继续访问,但是有个非常明显的问题:字节码GetUpvalue
关联的第2个参数不能定位到堆上的i
了(图中连续的?
线)。这也就是在上一节里提到的,直接用局部变量在栈上的索引来表示Upvalue的方案,是不可行的。需要在这个方案基础上做改进。
改进:Upvalue中介
为了在关闭局部变量后仍然可以被Upvalue访问到,我们需要一个Upvalue的中介,开始时还是用栈上索引来表示Upvalue,而当外层函数的局部变量被关闭后,就挪到这个中介里。
下面两个图展示了在加上Upvalue中介后的情况。
| | - - - - - - \
+----------+ | |
base |newCounter| | *-----+-+---
+----------+ | |Open(0)|
0 | i |<- - - - - - *-^-----+---
+----------+ |
1 | retf +--+->+-FuncProto-----+--+
+----------+ | |byte_codes: | |
2 | retf +--/ | GetUpvalue(0, 0) |
+----------+ | ... |
| | +------------------+
上图是在newCounter()
函数内部调用retf()
函数的示意图。对比之前的版本,增加了Upvalue中介列表(图中以*
为角的列表),并只有1个成员:Open(0)
,代表这个局部变量还没关闭,并且是在栈上的相对索引是0。而retf
的函数原型中,字节码GetUpvalue
关联的第2个参数虽然没有变,但其含义变了,变成了中介列表的索引。只不过这个例子中恰巧也是0而已。
| | /----------------\
+----------+ | |
base |newCounter| | *-------V-+---
+----------+ close | |Closed(i)|
0 | i +----------/ *-^-------+---
+----------+ |
1 | retf +---->+-FuncProto-----+--+
+----------+ |byte_codes: | |
| | | GetUpvalue(0, 0) |
| ... |
+------------------+
上图是newCounter()
函数返回前,关闭局部变量i
之后的示意图。上图中增加的Upvalue中介列表中的成员,变成了Closed(i)
,即把局部变量i
挪到这个中介列表中。这样GetUpvalue
仍然可以定位到第0个Upvalue中介,并访问关闭后的i
。
改进:共享Upvalue
上述方案可以支持目前简单的逃逸情景了,但是不支持多个闭包共享同一个局部变量的情景。比如下面示例代码:
local function foo()
local i, ip, ic = 0, 0, 0
local function producer()
i = i + 1
ip = ip + 1
end
local function consumer()
i = i - 1
ic = ic + 1
end
return produce, consume
end
上述的foo()
函数返回的两个内部函数都引用了局部变量i
,而且很明显这两个函数是要共享i
,要操作同一个i
,而不是各自独立的i
。那当foo()
函数结束关闭i
后,就需要两个函数来共享关闭后的i
了。由于这两个函数拥有不同的Upvalue列表,分别是i, ip
和i, ic
,所以两个函数不用共享同一个Upvalue列表。那就只能针对每个Upvalue单独共享了。
下图是单独共享每个Upvalue的方案:
| |
+----------+ +=======+
base | foo | |Open(0)|<===============+------------\
+----------+ +=======+ | |
0 | i |<- -/ +=======+ | |
+----------+ |Open(1)|<-------------|---\ |
1 | ip |<- - - +=======+ | | |
+----------+ +=======+ | | |
2 | ic |<- - - - |Open(2)|<-----------|---|--------|---\
+----------+ +=======+ *-+-+-+-+-- | |
3 | producer +---->+-FuncProto--------+ | i |ip | | |
+----------+ |byte_codes: | *-^-+-^-+-- | |
4 | consumer +--\ | GetUpvalue(0, 0)-+----/ | | |
+----------+ | | ... | | | |
| | | | GetUpvalue(0, 1)-+---------/ | |
| | ... | | |
| +------------------+ | |
| *-+-+-+-+--
\-------------->+-FuncProto-------+ | i |ic |
|byte_codes: | *-^-+-^-+--
| GetUpvalue(0, 0)-+-----/ |
| ... | |
| GetUpvalue(0, 1)-+----------/
| ... |
+------------------+
上图略显复杂,但大部分跟之前的方案是一样的。最左边仍然是栈。然后看producer()
函数指向的内容仍然是函数原型和对应的Upvalue列表。由于这个函数用到了两个Upvalue,所以列出了两条字节码。然后就是不一样的地方了:对于的Upvalue列表中,并不直接是Upvalue,而是Upvalue的地址。而真正的Upvalue是单独在堆上分配的,也就是图中的Open(0)
、Open(1)
和Open(2)
。这3个Upvalue通过索引可以访问栈上的局部变量。最后的consumer()
函数类似,区别是引用了不同的Upvalue。
当foo()
函数结束,关闭所有Upvalue引用的局部变量时,上图中的Open(0)
、Open(1)
和Open(2)
分别被替换为Closed(i)
、Closed(ip)
和Closed(ic)
。此时,producer()
和consumer()
函数对应的Upvalue列表中都有的i
指向的是同一个Closed(i)
。如此一来,在外层foo()
函数退出后,这两个函数仍然可以访问同一个i
了。只是替换3个Upvalue,改动相对较小,这里就省去关闭后的图了。
闭包的定义
在继续介绍更多的Upvalue使用场景前,我们先基于上述方案,引入闭包的概念。
依照上面的方案,返回的retf
并不只是一个函数原型了,还要包括对应的Upvalue列表。而函数原型加上Upvalue,就是闭包!在Value
中增加Lua闭包类型:
pub enum Upvalue { // 上图中的Upvalue中介
Open(usize),
Closed(Value),
}
pub struct LuaClosure {
proto: Rc<FuncProto>,
upvalues: Vec<Rc<RefCell<Upvalue>>>,
}
pub enum Value {
LuaFunction(Rc<FuncProto>), // Lua函数
LuaClosure(Rc<LuaClosure>), // Lua闭包
这样一来,多次调用newCounter()
函数返回的不同闭包,虽然共享同一个函数原型,但是各自拥有独立的Upvalue。这也是本节开头两个计数器c1、c2可以独立计数的原因。
下图展示两个计数器的示意图:
+-LuaClosure--+
| | | proto-+----------------------+-->+-FuncProto--------+
+------+ | upvalues-+--->+---+-- | |byte_codes: |
| c1 +---->+-------------+ | i | | | GetUpvalue(0, 0) |
+------+ +-+-+-- | | ... |
| c2 +-\ | | +------------------+
+------+ | V=========+ |
| | | |Closed(i)| |
| +=========+ |
\-->+-LuaClosure--+ |
| proto-+----------------------/
| upvalues-+--->+---+--
+-------------+ | i |
+-+-+--
|
V=========+
|Closed(i)|
+=========+
类似的,我们也改造下上面共享Upvalue例子中的示意图。清晰起见,删掉FuncProto
的具体内容;然后把函数原型和Upvalue列表合并为LuaClosure
。如下图。
| |
+----------+ +=======+
base | foo | |Open(0)|<========+----------\
+----------+ +=======+ | |
0 | i |<- -/ +=======+ | |
+----------+ |Open(1)|<------|---\ |
1 | ip |<- - - +=======+ | | |
+----------+ +=======+ | | |
2 | ic |<- - - - |Open(2)|<----|---|------|---\
+----------+ +=======+ | | | |
3 | producer +---->+-LuaClosure--+ | | | |
+----------+ | proto | | | | |
4 | consumer +--\ | upvalues -+>*-+-+-+-+-- | |
+----------+ | +-------------+ | i |ip | | |
| | | *---+---+-- | |
| | |
\------------>+-LuaClosure--+ | |
| proto | | |
| upvalues -+>*-+-+-+-+--
+-------------+ | i |ic |
*---+---+--
由图可见,相比于上一章定义的Lua函数LuaFunction
而言,闭包LuaClosure
虽然可以拥有独立的Upvalue列表,但是多了一次内存分配和指针跳转。这里就面临一个选择:是用闭包完全代替函数,还是两者并存?Lua官方实现中是前者,这也是“Lua中所有函数都是闭包”这句话的来源。代替的好处是少一个类型,代码稍微简单一点点;并存的好处是函数类型毕竟少分配一点内存少一次指针跳转。除了这两点个优缺点之外,还有一个更大的影响行为的区别。比如下面的示例代码:
local function foo()
return function () print "hello, world!" end
end
local f1 = foo()
local f2 = foo()
print(f1 == f2) -- true or false?
这里调用foo()
返回的匿名函数不包括Upvalue。那么问题来了,两次调用foo()
的两个返回值相等吗?
-
如果保留
LuaFunction
类型,那么返回值就是LuaFunction
类型,f1
和f2
就只涉及函数原型,就是相等的。可以用上一章的代码执行验证。 -
如果不保留
LuaFunction
类型,那么返回的函数就是LuaClosure
类型。虽然不包含Upvalue,但是也是两个不同的闭包,f1
和f2
就是不等的。
那么上述哪个行为是符合Lua语言要求的呢?答案是:都可以。Lua手册中对函数比较的描述如下:
Functions created at different times but with no detectable differences may be classified as equal or not (depending on internal caching details).
也就是无所谓,并没有对此做规定。那我们也就可以随便选择了。这个项目里,我们最开始是选择闭包代替函数的,后来又把函数类型加了回来。感觉区别不大。
闭包的语法分析
之前没有闭包,还是LuaFunction的时候,对于函数定义的处理很直观:
- 解析函数定义,并生成函数原型FuncProto;
- 把FuncProto用
Value::LuaFunction
包装下,放到常量表里; - 生成
LoadConst
等读取常量表的字节码。
对函数定义的处理,就跟其他类型的常量的处理方式类似。回忆一下,相关代码如下:
fn funcbody(&mut self, with_self: bool) -> ExpDesc {
// 省略准备工作
// chunk()函数返回的proto就是FuncProto类型
let proto = chunk(self.lex, has_varargs, params, Token::End);
ExpDesc::Function(Value::LuaFunction(Rc::new(proto)))
}
fn discharge(&mut self, dst: usize, desc: ExpDesc) {
let code = match desc {
// 省略其他类型
// 把函数原因加入到常量表中,并生成LoadConst字节码
ExpDesc::Function(f) => ByteCode::LoadConst(dst as u8, self.add_const(f) as u16),
现在为了支持闭包,需要做如下改进:
-
相关Value类型定义改成了
LuaClosure(Rc<LuaClosure>)
,所以解析出的函数原型FuncProto没法直接放到常量表里了。虽然也能间接放,但不直观。不如在函数原型FuncProto中再加个表专门保存内层函数的原型列表。 -
虚拟机执行函数定义时,除了函数原型外还要生成Upvalue。那么类似
LoadConst
之类的直接读取常量表的字节码也不满足需求了。需要新增一个专门的字节码,把函数原型和生成的Upvalue聚合为闭包。 -
另外,在生成Upvalue的时候,需要知道这个函数用到了上层函数的哪些局部变量。所以函数原型中还要新增Upvalue引用上层局部索引的列表。
综上,新增的创建闭包的字节码如下:
pub enum ByteCode {
Closure(u8, u16),
这个字节码关联的两个参数类似LoadConst
字节码,分别是栈上目标地址,和内部函数原型列表inner_funcs
的索引。
另外,需要在函数原型中新增两个成员如下:
pub struct FuncProto {
pub upindexes: Vec<usize>,
pub inner_funcs: Vec<Rc<FuncProto>>,
其中inner_funcs
是函数内部定义的内层函数的原型列表。upindexes
是当前函数引用上层函数的局部变量的索引,这个成员后续需要改造。需要说明的是,inner_funcs
是当前函数作为外层函数的角色时使用的,而upindexes
是当前函数作为内存函数的角色时使用的。
我们在后面介绍完Upvalue完整的特性后,再来介绍对Upvalue索引upindexes
的解析。
在介绍完闭包的定义和语法分析后,接着看Upvalue的其他场景。
改进:对Upvalue的引用
之前介绍的Upvalue都是对上层函数的局部变量的引用,现在来看对上层函数的Upvalue的引用。对本节最开头的计数闭包示例做下改造,把递增的代码i = i + 1
再放到一层函数中:
local function newCounter()
local i = 0
return function ()
print(i) -- upvalue
local function increase()
i = i + 1 -- where does `i` refer?
end
increase()
end
end
local c1 = newCounter()
c1()
这个示例中newCounter()
函数返回的匿名函数的第1行print语句中的i
是之前已经介绍的普通的Upvalue,指向上层函数的局部变量。而内部函数increase()
函数中的i
是什么?也是Upvalue。那这个Upvalue是对谁的引用呢?
可以看做是对最外层newCounter()
函数中局部变量i
的 跨层 引用吗?不可以,因为虚拟机执行时不能实现。当匿名函数返回时,内部的increase()
函数还没有创建;只有当在更外面调用匿名函数时,内部的increase()
函数才会被创建并执行;而此时最外层的newCounter()
已经结束了,其中的局部变量i
也已经不存在了,也就无法对其进行引用了。
既然不能是对最外层newCounter()
函数中局部变量i
的 跨层 引用,那就只能是对外层的匿名函数中的Upvalue i
的引用了。
为了支持对Upvalue的引用,首先,要修改刚才FuncProto
中Upvalue列表的定义,从只支持局部变量修改为也支持Upvalue:
pub enum UpIndex {
Local(usize), // index of local variables in upper functions
Upvalue(usize), // index of upvalues in upper functions
}
pub struct FuncProto {
pub upindexes: Vec<UpIndex>, // 从usize修改为UpIndex
pub inner_funcs: Vec<Rc<FuncProto>>,
然后,来看上面例子中,在执行返回的匿名函数计数器c1
时,在调用内部increase()
函数时的示意图:
| |
+----------+
| c1 +-------------------->+-LuaClosure--+
+----------+ | proto |
| increase | | upvalues +--->+---+--
+----------+ +-------------+ | i |
| increase +-->+-LuaClosure--+ +-+-+--
+----------+ | proto | |
| | | upvalues +--->+---+-- |
+-------------+ | i | |
+-+-+-- V=========+
\---------------->|Closed(i)|
+=========+
左边是栈。其中c1
是函数调用入口,对应的闭包中包含的Upvalue i
是print语句中引用的。
栈的下面第一个increase
是c1
中的局部变量。第二个increase
是函数调用入口,对应的闭包中包含的Upvalue i
是执行递增操作的语句中引用的。在函数原型中,这个Upvalue应该对应上层函数的第0个Upvalue,即UpIndex::Upvalue(0)
,所以在虚拟机执行生成这个闭包时,这个Upvalue就指向c1
的第0个Upvalue,即图中的Closed(i)
。这样一来,在这个函数中对i
的递增操作也会反应在c1
函数的print语句中了。
改进:跨多层函数引用
再来看另外一种场景:跨层引用。稍微修改一下上面的用例,把print
语句放到递增操作之后,得到下面的示例代码:
local function newCounter()
local i = 0
return function ()
local function increase()
i = i + 1 -- upvalue of upper-upper local
end
increase()
print(i) -- upvalue
end
end
这个例子跟上面那个例子的区别在于,在解析到increase()
函数的时候,要返回的匿名函数中还没有生成Upvalue i
,那么increase()
函数中的i
要指向谁?总结下之前的Upvalue类型:要么是指向上层函数的局部变量,要么是上层函数的Upvalue,并且分析了不能跨多层函数引用。所以,只有一个解决办法了:在中间层函数创造一个Upvalue。这个Upvalue在当前函数(暂时)并不使用,只是用来给内层函数引用。
刚才说的当前函数“暂时”不使用这个创造出来的Upvalue,是指的在语法分析解析到内部函数的时候,暂时还不使用。在后续的解析过程中,还是可能用上的。比如上面这个例子后,后面的print语句就用到了这个Upvalue。
这个例子里,两个函数的原型和执行时的示意图,都跟上面的例子一样。这里省略。
至此,终于介绍完全部的Upvalue特性,并给出最终的方案。这期间也对语法分析和虚拟机执行部分有所涉及,下面根据最终方案,再对语法分析和虚拟机执行做简单的整理。
Upvalue索引的语法分析
上面在介绍闭包的语法分析时,指出在函数原型FuncProto
中需要新增成员upindexes
来表示当前函数的Upvalue索引。
在上一节中,罗列了变量的解析流程:
- 在当前函数的局部变量中匹配,如果找到则是局部变量;
- 在上层函数的局部变量中匹配,如果找到则是Upvalue;
- 否则是全局变量。
根据本节前面对Upvalue完整特性的介绍,对上述的第2步扩展更详细的Upvalue索引的解析步骤,变量解析的最终流程如下:
- 在当前函数的局部变量中匹配,如果找到则是局部变量;
- 在当前函数的Upvalue列表中匹配,如果找到则是已有Upvalue;(复用Upvalue)
- 在外层函数的局部变量中匹配,如果找到则新增Upvalue;(普通Upvalue)
- 在外层函数的Upvalue中匹配,如果找到则新增Upvalue;(对上层函数中Upvalue的引用)
- 在更外层函数的局部变量中匹配,如果找到则在所有中间层函数中创建Upvalue,并新增Upvalue;(跨多层函数的引用)
- 在更外层函数的Upvalue中匹配,如果找到则在所有中间层函数中创建Upvalue,并新增Upvalue;(跨多层函数的Upvalue的引用)
- 循环上述第5,6步,如果到达最外层函数仍未匹配,则是全局变量。
这个流程中明显有很多重复的地方。最明显的是第3,4步是第5,6步的特殊情况,即没有中间层函数的情况,所以可以去掉3,4步。另外在代码实现时,第1,2步也可以作为特殊情况而省略。由于本节内容太多,这里就不贴具体代码了。
上一节的语法分析,为了支持Upvalue需要访问上层函数的局部变量列表,所以新增了上下文ParseContext
数据结构,包含各级函数的局部变量列表。本节介绍了Upvalue也可以引用上层函数的Upvalue,所以就也需要在ParseContext
中增加各级函数的Upvalue列表。
struct ParseContext<R: Read> {
all_locals: Vec<Vec<String>>,
all_upvalues: Vec<Vec<(String, UpIndex)>>, // 新增
lex: Lex<R>,
}
pub struct FuncProto {
pub upindexes: Vec<UpIndex>,
上面代码中,ParseContext
是语法分析上下文,是语法分析的内部数据结构,其Upvalue列表all_upvalues
的成员类型是(String, UpIndex)
,其中String是Upvalue变量名,用来上面第2,4,6步的匹配;UpIndex是Upvalue的索引。
而FuncProto
是语法分析阶段的输出,是交由虚拟机执行阶段使用的,此时并不需要Upvalue变量名了,只需要UpIndex索引即可。
虚拟机执行
本节前面部分在介绍Upvalue设计方案的时候,基本是按照虚拟机执行阶段来介绍的,这里再完整过一遍。
首先,是创建闭包,也就是定义函数。为此引入了新的字节码ByteCode::Closure
,其职责是生成Upvalue,并将其和函数原型一起打包为闭包,并加载到栈上。
这里需要说明的是,在语法分析阶段为了能访问上层函数的局部变量,需要引入ParseContext
上下文;但是,在虚拟机执行阶段,Upvalue虽然也要访问上层函数的栈空间,但是并不需要做语法分析那样类似的改造。这是因为在创建闭包的时候,Upvalue列表是由外层函数生成并传入闭包的,内层函数就可以通过Upvalue列表间接访问外层函数的栈空间。
另外,生成的Upvalue列表除了传入闭包外,外层函数自己也需要把列表维护起来的,目的有二:
-
上面共享Upvalue部分有介绍,如果一个函数中包含多个闭包,那么这些闭包的Upvalue是要共享局部变量的。所以,在创建Upvalue的时候,要先检查这个局部变量关联的Upvalue是否已经创建了。如果是,则共享;否则才创建新的。
这里有个小问题,这个是否已经创建的检查是在虚拟机执行阶段进行的。Upvalue列表一般不会很多,所以不至于使用hash表,而使用Vec的话这个匹配检查的时间复杂度就是O(n),当Upvalue很多的时候这里可能影响性能。那能不能把这个匹配检查放在语法分析阶段呢?下一节再详细介绍这个问题。
-
外层函数在退出的时候,需要关闭Upvalue。
需要说明的是,理论上讲,只需要关闭逃逸的Upvalue;而不需要关闭没有逃逸的Upvalue。但是,在语法阶段确定一个Upvalue是否逃逸是非常困难的,除了上述例子中比较明显的把内部函数作为返回值的逃逸情况,还有比如把内部函数赋值给一个外部的表这种情况。在虚拟机阶段判断是否逃逸也很麻烦。所以为了简单起见,我们这里参考Lua官方实现,在函数结束时关闭所有Upvalue,而不区分是否逃逸。
关闭Upvalue的时机是所有函数退出的地方,包括Return
、Return0
和TailCall
字节码。具体的关闭代码这里就省略了。
小节
本节介绍了Upvalue的逃逸,并新增了闭包类型。但主要介绍的都是如何设计和管理Upvalue,而并没有讲具体的操作,包括如何创建、读写、和关闭Upvalue。不过设计方案讲明白后,这些具体的操作相对也就很简单了。本节篇幅已经很长了,就省略这部分的介绍和代码。
Rust的DST
现在介绍Rust语言的一个特征,DST。
本节前面对闭包数据结构LuaClosure
的定义如下:
pub struct LuaClosure {
proto: Rc<FuncProto>,
upvalues: Vec<Rc<RefCell<Upvalue>>>,
}
这里忽略其中的函数原型proto
字段,而只关注Upvalue列表upvalues
字段。为了能存储任意个Upvalue,这里的upvalues定义为一个列表Vec。这样就需要再额外分配一段内存。整个闭包的内存布局如下:
+-LuaClosure--+
| proto |
| upvalues: | Upvalue列表
| ptr --+--->+------+------+-
| capacity | | | |
| length | +------+------+-
+-------------+
上图中,左边是闭包LuaClosure
,其中ptr
指向的右边额外的内存,是Upvalue列表Vec的实际存储空间。这样额外再分配一段内存的缺点有三个:
- 浪费内存,每一段内存都需要额外的管理空间和因为对齐造成的浪费;
- 在申请内存时,需要多执行一次分配,影响性能;
- 在访问Upvalue时,需要多一次指针跳转,也影响性能。
而对于这种不定长数组的需求,在C语言中经典的做法是:在数据结构中定义零长数组,然后在实际分配内存的时候按需指定实际长度。示例代码如下:
// 定义数据结构
struct lua_closure {
struct func_proto *proto;
int n_upavlue; // 实际个数
struct upvalue upvalues[0]; // 零长数组
}
// 申请内存
struct lua_closure *c = malloc(sizeof(struct lua_closure) // 基本空间
+ sizeof(struct upvalue) * n_upvalue); // 额外空间
// 初始化
c->n_upvalue = n_upvalue;
for (int i = 0; i < n_upvalue; i++) {
c->upvalues[i] = ...
}
相应的内存布局如下:
+-------------+
| proto |
| n_upvalue |
: : \
: : + Upvalue列表
: : /
+-------------+
这种做法可以避免上述的3个缺点。那在Rust中能这么做吗?比如如下定义:
pub struct LuaClosure {
proto: Rc<FuncProto>,
upvalues: [Rc<RefCell<Upvalue>>], // slice
}
这个定义中,upvalues的类型从列表Vec变成了slice []。好消息是Rust是支持DST类型(即这里的slice)作为数据结构的最后一个字段的,也就是说上述定义是合法的。坏消息是,这样的数据结构没法初始化。一个没法初始化的数据结构,自然也就没法使用了。引用The Rustonomicon的话就是:custom DSTs are a largely half-baked feature for now。
我们可以想想为什么没法初始化?比如Rc
有Rc::new_uninit_slice()
API可以创建slice,那能不能加一个类似的API来创建这种包含slice的数据结构?另外,还可以参考dyn_struct。
不过,即便可以初始化了,上述数据结构的定义可以使用了,但是还会有另外一个问题:由于upvalues字段是DST,那么整个LuaClosure
也跟着变成了DST,所以指针也就会变成胖指针,要包括slice的实际长度,Rc<LuaClosure>
就变成了2个word,进而导致enum Value
从2个word变成3个word。这也就不满足我们的要求了,就跟之前不能使用Rc<str>
来定义字符串类型一样。
既然不能用slice,那有没有其他解决办法?可以用固定长度的数组。比如修改定义如下:
enum VarUpvalues {
One(Rc<RefCell<Upvalue>>), // 1个Upvalue
Two([Rc<RefCell<Upvalue>>; 2]), // 2个Upvalue
Three([Rc<RefCell<Upvalue>>; 3]), // 3个Upvalue
Four([Rc<RefCell<Upvalue>>; 4]), // 4个Upvalue
More(Vec<Rc<RefCell<Upvalue>>>), // 更多个Upvalue
}
pub struct LuaClosure {
proto: Rc<FuncProto>,
upvalues: VarUpvalues,
}
这样的话对于不多于4个Upvalue的闭包,就可以避免额外的内存分配了。这应该满足了大多数情况。而对于更多个Upvalue的情况,再分配一块内存的浪费相对就没那么大了。这个方案的另外一个优点是不涉及unsafe。当然,这个方案的问题是会带来编码的复杂。由于创建LuaClosure
只是在创建闭包的时候一次性生成,不是一个高频操作,也就没必要搞这么复杂了。所以,最终绕一圈回来还是使用最开始的Vec方案。
block和goto的逃逸
上一节介绍了Upvalue的从函数中的逃逸。但是实际上局部变量的作用域是block,所以每当block结束时就可能出现Upvalue的逃逸。而函数也可以看成是一种block,所以上一节介绍的从函数中逃逸可以看成是从block中逃逸的一种特殊情况。
另外,还有一种逃逸的场景,即goto语句向后跳转,跳过局部变量的定义,此时局部变量也会失效。
上一节因为内容太多了,为了不节外生枝,所以把这两个逃逸的场景放到这一节中单独介绍。
从block中逃逸
首先看个从block中逃逸的示例代码:
do
local i = 0
c1 = function ()
i = i + 1 -- upvalue
print(i)
end
end -- block结束,局部变量i失效
这个例子中,do .. end
block中定义的匿名函数引用了其中定义的局部变量i
,作为Upvalue。当block结束后,局部变量i
就会失效,但由于还被匿名函数引用,所以需要逃逸。
虽然函数可以看成是block的一种特殊情况,但特殊情况毕竟是特殊情况,处理更通用的从block中的逃逸还是很不一样。上一节中当函数结束时,在Return/Return0/TailCall
等相关字节码中关闭所有Upvalue,因为每个函数的结尾都会有这几个字节码之一。但是block结束并没有类似固定的字节码,所以为此新增一个字节码Close
,这个字节码关闭当前block中被Upvalue引用的局部变量。
最简单的做法是在每个block结尾处都生成一个Close
字节码,但是由于从block中逃逸的情况非常少见,为了这种很少见的情况而对所有block都增加一个字节码,实在是得不偿失。所以,需要在语法分析阶段判断这个block中是否有逃逸,如果没有,则无需生成Close
字节码。
接下来就是如何判断一个block中是否有局部变量逃逸的现象。可能有多种实现方法。比如参考上节中多层函数嵌套的方式,也维护一个block嵌套的关系。不过有一种更轻量的做法,就是对每个局部变量加上一个标记位,如果被Upvalue引用,则设置这个标记位。然后在block结束的时候,判断这个block中定义的局部变量有没有被标记过,就能知道是否需要生成Close
字节码。
这里省略Close
字节码的具体定义和执行流程。
从goto中逃逸
我实在想不出一个合理的从goto中逃逸的示例。但是不合理示例的还是可以构造一个出来:
::again::
if c1 then -- 第1次执行到这里时if为假
c1() -- 下面给c1赋值后,c1就是包括了一个Upvalue的闭包
end
local i = 0
c1 = function ()
i = i + 1 -- upvalue
print(i)
end
goto again
上述代码中,第1次执行时if判断为假,跳过对c1
的调用;下面给c1赋值后,c1就是包括了一个Upvalue的闭包;然后goto跳转回开头后,此时就可以调用c1了;但是此时局部变量i
也已经失效,所以需要关闭。
可以把上述代码中,从开头的again
label定义到最后的goto
语句也看成是一个block,那么就可以采用刚才介绍的从block中逃逸的做法,来处理goto语句了。但是goto语句有个特殊的地方。我们之前在介绍goto语句的时候介绍过,对label和goto语句的匹配,有两种做法:
- 边解析边匹配。即解析到label的时候就匹配已经出现过的goto语句;解析到goto语句时就匹配已经出现过的label;
- block结束后(也就是其中定义的label失效时),一次性匹配现有的label和goto语句。
这两个做法的实现难度差不多,但是由于goto语句的另外一个特征,即向前跳转的goto语句需要忽略void语句。之前为了更方便的处理void语句,就采用了上述第2个方案。但是,现在为了支持逃逸,在解析到goto语句的时候(准确说是在生成的Jump
字节码之前),可能会生成一个Close
字节码。具体会不会生成,取决于goto向后跳转时,是否跳过了逃逸的局部变量的定义。也就是说,只有匹配label和goto语句才能知道是否需要Close
字节码。如果还按照第2个方案在block结束后再做匹配的话,在block结束时即便发现需要生成Close
也无法再插入到字节码序列中了。所以,就只能改成上述第1个边解析边匹配的方案了,在匹配的时候及时判断是否需要生成Close
字节码。
Rust闭包
前面几节介绍了在Lua中定义的闭包。除此之外,Lua语言的官方实现还支持C语言闭包。我们的解释器是由Rust实现的,自然也就要改成Rust闭包。本节就来介绍Rust闭包。
Lua官方实现中的C闭包
先来看下Lua官方实现中的C闭包。C语言本身不支持闭包,所以必须依赖Lua配合才能实现闭包。具体来说,就是把Upvalue存到Lua的栈上,然后再跟C函数原型绑定起来组成C闭包。Lua通过API向C函数提供访问栈上Upvalue的方式。
下面是C闭包版本的计数器示例代码:
// 计数器函数原型
static int counter(Lua_State *L) {
int i = lua_tointeger(L, lua_upvalueindex(1)); // 读取Upvalue计数
lua_pushinteger(L, ++i); // 加1,并压入栈顶
lua_copy(L, -1, lua_upvalueindex(1)); // 用栈顶新值更新Upvalue计数
return 1; // 返回栈顶的计数
}
// 工厂函数,创建闭包
int new_counter(Lua_State *L) {
lua_pushinteger(L, 0); // 压到栈上
// 创建C闭包,函数原型是counter,另外包括1个Upvalue,即上一行压入的0。
lua_pushcclosure(L, &counter, 1);
// 创建的C闭包压在栈顶,下面return 1代表返回栈顶这个C闭包
return 1;
}
先看第2个函数new_counter()
,也是创建闭包的工厂函数。先调用lua_pushinteger()
把Upvalue计数压入到栈顶;然后调用lua_pushcclosure()
创建闭包。复习一下,闭包由函数原型和Upvalue组成,这两部分分别由lua_pushcclosure()
函数的后面两个参数指定。第一个参数指定函数原型counter
,第二个参数1
代表栈顶的1个Value是Upvalue,即刚刚压入的0。下图是调用这个函数创建C闭包前后的栈示意图:
| | | |
+-----+ +---------+
| i +--\ +-C_closure------+<----+ closure |
+-----+ | | proto: counter | +---------+
| | | | upvalues: | | |
\--+--> i |
+----------------+
上图最左边是把计数i=0压入到栈顶。中间是创建的C闭包,包括了函数原型和Upvalue。最右边是创建完闭包后的栈布局,闭包压入栈上。
再看上述代码中第1个函数counter()
,也就是创建的闭包的函数原型。这个函数比较简单,其中最关键的是lua_upvalueindex()
API,生成代表Upvalue的索引,就可以用来读写被封装在闭包中的Upvalue了。
通过上述示例中代码对相关API的调用流程,基本可以猜到C闭包的具体实现。我们的Rust闭包也可以参考这种方式。但是,Rust本身就支持闭包!所以我们可以利用这个特性更简单的实现Lua中的Rust闭包。
Rust闭包的类型定义
用Rust语言的闭包实现Lua中的“Rust闭包”类型,就是新建一个Value类型,包含Rust语言的闭包就行。
《Rust程序设计语言》中已经详细介绍了Rust的闭包,这里就不再多言。我们只需要知道Rust闭包是一种trait。具体到Lua中的Rust闭包类型就是FnMut (&mut ExeState) -> i32
。然后就可以尝试定义Lua中Value的Rust闭包类型如下:
pub enum Value {
RustFunction(fn (&mut ExeState) -> i32), // 普通函数
RustClosure(FnMut (&mut ExeState) -> i32), // 闭包
然而这个定义是非法的,编译器会有如下报错:
error 782| trait objects must include the `dyn` keyword
这就涉及到Rust中trait的Static Dispatch和Dynamic Dispatch了。对此《Rust程序设计语言》也有详细的介绍,这里不再多言。
然后,我们根据编译器的提示,加上dyn
:
pub enum Value {
RustClosure(dyn FnMut (&mut ExeState) -> i32),
编译器仍然报错,但是换了一个错误:
error 277| the size for values of type `(dyn for<'a> FnMut(&'a mut ExeState) -> i32 + 'static)` cannot be known at compilation time
就是说trait object是个DST。这个之前在介绍字符串定义的时候介绍过,只不过当时遇到的是slice,现在是trait,这也是Rust中最主要的两个DST。对此《Rust程序设计语言》也有详细的介绍。解决方法就是在外面封装一层指针。既然Value要支持Clone,那么Box
就不能用,只能用Rc
。又由于是FnMut
而不是Fn
,在调用的时候会改变捕捉的环境,所以还需要再套一层RefCell
来提供内部可变性。于是得到如下定义:
pub enum Value {
RustClosure(Rc<RefCell<dyn FnMut (&mut ExeState) -> i32>>),
这次终于编译通过了!但是,想一想当初在介绍字符串各种定义的时候为什么没有使用Rc<str>
?因为对于DST类型,需要在外面的指针或引用的地方存储实际的长度,那么指针就会变成“胖指针”,需要占用2个word。这就会进一步导致整个Value的size变大。为了避免这种情况,只能再套一层Box
,让Box包含具体长度变成胖指针,从而让Rc
恢复1个word。定义如下:
pub enum Value {
RustClosure(Rc<RefCell<Box<dyn FnMut (&mut ExeState) -> i32>>>),
在定义了Rust闭包的类型后,也遇到了跟Lua闭包同样的问题:还要不要保留Rust函数的类型?是否保留区别都不大。我们这里选择了保留。
虚拟机执行
Rust闭包的虚拟机执行非常简单。因为Rust语言中闭包和函数的调用方式一样,所以Rust闭包的调用跟之前Rust函数的调用一样:
fn do_call_function(&mut self, narg_plus: u8) -> usize {
match self.stack[self.base - 1].clone() {
Value::RustFunction(f) => { // Rust普通函数
// 省略参数的准备
f(self) as usize
}
Value::RustClosure(c) => { // Rust闭包
// 省略同样的参数准备过程
c.borrow_mut()(self) as usize
}
测试
至此就完成了Rust闭包类型。借用了Rust语言自身的闭包后,这个实现就非常简单。并不需要像Lua官方实现那样用Lua栈来配合,也就不需要引入一些专门的API。
下面代码展示了用Rust闭包来完成本节开头的计数器例子:
fn test_new_counter(state: &mut ExeState) -> i32 {
let mut i = 0_i32;
let c = move |_: &mut ExeState| {
i += 1;
println!("counter: {i}");
0
};
state.push(Value::RustClosure(Rc::new(RefCell::new(Box::new(c)))));
1
}
相比于本节开头的C闭包,这个版本除了最后一句创建闭包的语句非常啰嗦以外,其他流程都更加清晰。后续在整理解释器API时也会优化最后这条语句。
Rust闭包的局限
上面的示例代码中可以看到,捕获的环境(或者说Upvalue)i
是需要move进闭包的。这也就导致多个闭包间共享不能共享Upvalue。不过Lua官方的C闭包也不支持共享,所以并没什么问题。
另外一个需要说明的地方是,Lua官方的C闭包中是用Lua的栈来存储Upvalue,也就导致Upvalue的类型就是Lua的Value类型。而我们使用Rust语言的闭包,那Upvalue就可以是“更多”的类型,而不限于Value类型了。不过这两者之间在功能上应该是等价的:
- Rust闭包支持的“更多”类型,在Lua中都可以用LightUserData,也就是指针来实现;虽然对于Rust来说这很不安全。
- Lua中支持的内部类型,比如表Table,在我们的解释器中,也可以通过
get()
这个API获取到(而Lua的官方实现中,表这个类型是内部的,没有对外)。
泛型for
本章前面几节介绍了闭包。闭包最典型的应用场景是迭代器(iterator),而迭代器最常见的地方是for语句。以至于《Lua程序设计》和《Rust程序设计语言》这两本书中都把闭包、迭代器和for语句放在一起介绍。Lua语言中的for语句有两种格式,数值型和泛型。之前已经介绍了数值型for语句,本节来介绍使用迭代器的泛型for语句。
在介绍完闭包后,迭代器本身的概念是很简单的。本章前面几节一直用来举例的计数器闭包就可以认为是一个迭代器,每次生成一个递增数字。下面再看一个稍微复杂的迭代器,遍历一个表中的数组部分。这也是Lua语言自带的ipairs()
函数的功能:
function ipairs(t)
local i = 0
return function ()
i = i + 1
local v = t[i]
if v then
return i, v
end
end
end
上述代码中,ipairs()
是工厂函数,创建并返回一个闭包作为迭代器。这个迭代器有2个Upvalue,一个是固定不变的表t
,另外一个是遍历的位置i
。我们可以称这两个Upvalue为迭代环境。在遍历过程中,迭代器返回数组的索引和值;而当遍历结束时不返回值,也可以认为是返回了nil。
可以直接调用这个迭代器,但更常见的是在泛型for语句中使用:
-- 直接调用迭代器
local iter = ipairs(t)
while true do
local i, v = iter()
if not i then break end
block -- do something
end
-- 在泛型for语句中使用
for i, v in ipairs(t) do
block -- do something
end
迭代器这样的使用方式固然很方便,但是前面几节也介绍了,创建一个闭包比创建一个普通函数要有额外的开销,即无论对Lua闭包还是Rust闭包,都需要额外的2次内存分配和2次指针跳转。所以Lua语言中的泛型for语句为此做了特殊的优化,即由泛型for语句本身代替闭包来保存迭代环境(也就是Upvalue)。既然不需要Upvalue,那迭代器也就不需要使用闭包,而只需要普通函数了。
具体说来,泛型for语句的语法如下:
stat ::= for namelist in explist do block end
namelist ::= Name {‘,’ Name}
其执行流程如下:
-
循环开始时,对
explist
求值,得到3个值:迭代函数、不可变状态、和控制变量。大部分情况下explist
是函数调用语句,那么求值就遵循函数返回值的求值规则,即如果不足3个就用nil补上,如果超过3个就丢弃多余的。当然也可以不使用函数调用,而是直接罗列3个值。 -
然后,每次执行循环前,都用后面两个值(不可变状态和控制变量)作为参数来调用迭代函数,并判断第一个返回值:如果为nil则终止循环;否则把返回值赋值给
namelist
,并额外把第一个返回值赋值给控制变量,以作为后续再次调用迭代函数的参数。
可以看到explist
返回的这3个值拼起来就是一个闭包的功能:迭代函数是函数原型,后面两个是Upvalue。只不过泛型for语句帮忙维护了这两个Upvalue。利用泛型for语句的这个特性,重新实现上述的遍历数组的迭代器如下:
local function iter(t, i) -- t和i都从Upvalue变成了参数
i = i + 1
local v = t[i]
if v then
return i, v
end
end
function ipairs(t)
return iter, t, 0
end
相比于上面的闭包版本,这里t
和i
都从Upvalue变成了参数,而iter
也就变成了一个普通的函数。
这么看来,并不需要闭包(比如在上一章介绍完函数后)就可以完成泛型for语句。但是这毕竟是基于闭包所做的优化,在掌握闭包的基础上才能理解为什么这么做。所以我们才在介绍完闭包后才来实现泛型for语句。
另外,这里的函数调用语句ipairs(t)
只是返回了3个变量,另外也可以在泛型for语句中直接罗列这3个变量:
for i, v in ipairs(t) do ... end
for i, v in iter, t, 0 do ... end -- 直接罗列3个变量
下面这种直接罗列的做法省略一次函数调用,但是不方便。所以常见的还是第一种做法。
介绍完Lua中泛型for语句的特性,下面开始实现。
实现
根据上面的介绍,泛型for语句自己保存并维护迭代环境。那保存在哪里?自然还是在栈上。就像数值型for语句在栈上会自动创建3个变量(1个计数变量和2个匿名变量)一样,泛型for语句也需要自动创建3个匿名变量,对应上述的迭代环境:迭代函数、不可变状态、控制变量。这3个变量是对explist
求值后得到,如下面左图显示的栈示意图:
| | | | | |
+-----------+ +-----------+ +-----------+
| iter func |entry | iter func | | iter func |
+-----------+ +-----------+ +-----------+
| state |\ | state | | state |
+-----------+ 2args +-----------+ +-----------+
| ctrl var |/ | ctrl var | | ctrl var |<--first return value
+-----------+ +-----------+ ->+-----------+
| | : : / | name- | i
+-----------+ / | list | v
| return- |- | |
| values |
| |
接下来就执行循环,包括3个步骤:调用迭代函数、判断是否继续循环、控制变量赋值。
首先,以不可变状态state
和控制变量ctrl var
为两个参数,调用迭代函数iter func
。看左图中栈示意图,刚好已经排列成函数调用的阵型,所以可以直接调用函数;
其次,调用完函数后(上面中图),判断第一个返回值是否为nil,如果是则退出循环;如果没有返回值也退出循环;否则继续执行循环。在执行循环体前,需要处理返回值(上面右图):
-
把第一个返回值赋值给控制变量(上图中的ctrl-var),作为下次调用迭代函数的参数;
-
把返回值赋值给变量列表,也就是上面BNF里的
namelist
。比如上面遍历数组的例子中,就是i, v
。如果返回值个数少于变量列表,则用nil补足。这个补齐操作跟普通的函数调用一致。而不一致的地方是,普通函数调用的返回值会挪到函数入口处,即上图中iter func
的位置;而这里是向下偏移了3个位置。
这里需要说明的一点是,控制变量ctrl-var
就是namelist
的第一个name。所以实际上,栈上并不需要特意给ctrl-var
保留位置;每次调用完迭代函数后,直接把所有返回值挪到图中ctrl-var
的地方即可,这样第一个返回值刚好也就在ctrl-var
的位置。下图是这两个方案的对比。左图是开始的方案,特意给ctrl-var
保留位置;右图是新方案,只需要2个匿名变量来保存迭代环境,而ctrl-var
跟第一个name重叠:
| | | |
+-----------+ +-----------+
| iter func | | iter func |
+-----------+ +-----------+
| state | | state |
+-----------+ +-----------+
| ctrl var | i| name- | <--ctrl var
+-----------+ v| list |
i| name- | | |
v| list |
| |
右面的方案更简单,少一次变量赋值。而且正常情况下,这两个方案的功能一样。但在一个情况下功能会有差别,即在循环体内修改控制变量的时候。比如下面的示例代码:
for i, v in ipairs(t) do
i = i + 1 -- 修改控制变量`i`
print(i)
end
按照上面左图的方案,这里修改的i
是暴露给程序员的变量,而控制变量ctrl var
是隐藏的匿名变量,这两个变量是独立的。所以对i
的修改不影响控制变量ctrl var
。于是这个循环依旧可以遍历全部数组。
而按照右图的方案,i
和ctrl var
是一个值,修改i
就是修改了ctrl var
,也就影响了下一次的迭代函数调用,最终导致无法正常遍历整个数组。
哪个行为更合理呢?Lua手册的说明是:You should not change the value of the control variable during the loop。也就是说对这种行为并没有明确定义,所以那个方案都可以。不过Lua的官方实现是按照左图中的行为,为了保持一致我们这里也选择左图的方案。
字节码
上面介绍了泛型for语句在循环中的操作。这些操作需要一个新的字节码ForCallLoop
来完成。
在定义这个字节码之前,先来看下这个字节码要放置在哪里?是循环开始,还是循环结束?如果按照Lua代码,那应该是放在循环开始,然后在循环结束的地方生成一个Jump
字节码跳转回来继续循环,就像下面这样:
ForCallLoop # 如果调用迭代函数返回nil,则跳转到最后
... block ...
Jump (back-to-ForCallLoop)
但是这样的话每次循环都要执行2条字节码,开头的ForCallLoop
和结尾的Jump
。为了减少一次字节码,可以把ForCallLoop
放在循环结束的位置,这样只有第一次循环时要执行2条字节码,后续每次循环就只需要执行1条字节码了:
Jump (forward-to-ForCallLoop)
... block ...
ForCallLoop # 如果调用迭代函数返回不是nil,则跳转到上面block处
确定字节码位置之后,再来看定义。这个字节码需要关联3个参数:
- 迭代函数
iter func
的栈索引; - 变量个数,用于返回值的赋值。如果返回值个数小于变量个数,则需要填上nil;
- 跳转距离。
前面2个参数都可以用1个字节表示,那最后的跳转距离就也只剩1个字节的空间了,只能表示255的距离,不太够。为此只能再加上一个Jump
字节码一起来完成这个功能。但是大部分情况循环体并不大,不超过255的距离,为了少数的大循环体而再加一个字节码有点浪费。这种情况下最好的办法就是:
- 对于小循环体可以把跳转距离编入到
ForCallLoop
字节码中,只用这1个字节码; - 对于大循环体,把
ForCallLoop
字节码中的第3个参数跳转距离就设置为0,并新增一个Jump
字节码做配合。
这样在虚拟机执行的时候:
- 在继续循环需要向后跳转的情况:对于小循环体就直接根据第3个参数跳转;对于大循环体,第3个参数是0,实际跳转到下一条Jump字节码,然后再执行循环体的跳转。
- 在要结束循环需要继续向前执行的情况:对于小循环体无需特殊处理;对于大循环体,需要跳过下一条Jump字节码。
综上可得,字节码ForCallLoop
定义如下:
pub enum ByteCode {
ForCallLoop(u8, u8, u8),
具体的语法分析和虚拟机执行代码,这里就省略掉了。
至此,完成泛型for语句。我们也完成了Lua所有的语法!(此处应有掌声)
环境 _ENV
回到最开始第一章里的"hello, world!"的例子。当时展示的luac -l
的输出中,关于全局变量print
的读取的字节码如下:
2 [1] GETTABUP 0 0 0 ; _ENV "print"
看这字节码复杂的名字和后面奇怪的_ENV
注释,就觉得不简单。当时并没有介绍这个字节码,而是重新定义了GetGlobal
这个更直观的字节码来读取全局变量。本节,就来补上_ENV
的介绍。
目前对全局变量的处理方式
我们目前对全局变量的处理是很直观的:
-
语法分析阶段,把不是局部变量和Upvalue的变量认为是全局变量,并生成对应的字节码,包括
GetGlobal
、SetGlobal
和SetGlobalConst
; -
虚拟机执行阶段,在执行状态
ExeState
数据结构中定义global: HashMap<String, Value>
来表示全局变量表。后续对全局变量的读写都是操作这个表。
这种做法很直观,也没什么缺点。但是,有其他做法可以带来更强大的功能,就是Lua 5.2版本中引入的环境_ENV
。《Lua程序设计》中对_ENV
有很详细的描述,包括为什么要用_ENV
来代替全局变量以及应用场景。我们这里就不赘述了,而是直接介绍其设计和实现。
_ENV的原理
_ENV
的实现原理:
-
在语法分析阶段,把所有的全局变量都转换为对_ENV的索引,比如
g1 = g2
就转换为_ENV.g1 = _ENV.g2
; -
那
_ENV
自己又是什么呢?由于所有Lua代码段可以认为是一个函数,所以_ENV
就可以认为是这个代码段外层的局部变量,也就是Upvalue。比如对于上述代码段g1 = g2
,更完整的转换结果如下:
local _ENV = XXX -- 预定义的全局变量表
return function (...)
_ENV.g1 = _ENV.g2
end
所有“全局变量”都变成了_ENV
的索引,而_ENV
本身也是一个Upvalue,于是,就不存在全局变量了!另外,关键的地方还在于_ENV
本身除了是提前预置的之外,并没有其他特别之处,就是一个普通的变量。这就意味着可以像普通变量一样操作他,这就带来了很大的灵活性,比如可以很方便地实现一个沙箱。具体的使用场景这里不做展开,感兴趣可以参考《Lua程序设计》。
_ENV的实现
按照上面的介绍,用_ENV
改造全局变量。
首先,在语法分析阶段,把全局变量改造为对_ENV
的索引。相关代码如下:
fn simple_name(&mut self, name: String) -> ExpDesc {
// 省略对局部变量和Upvalue的匹配,如果匹配上则直接返回。
// 如果匹配不上,
// - 之前就认为是全局变量,返回 ExpDesc::Global(name)
// - 现在改造为 _ENV.name,代码如下:
let env = self.simple_name("_ENV".into()); // 递归调用,查找_ENV
let ienv = self.discharge_any(env);
ExpDesc::IndexField(ienv, self.add_const(name))
}
上述代码中,先是对变量name
尝试从局部变量和Upvalue中匹配,这部分在之前Upvalue中有详细介绍,这里省略。这里只看如果都匹配失败的情况。这种情况下,之前就认为name
是全局变量,返回ExpDesc::Global(name)
。现在要改造为_ENV.name
,这就要首先定位_ENV
。由于_ENV
也是一个普通的变量,所以用_ENV
做参数递归调用simple_name()
函数。为了确保这个调用不会无限递归下去,就需要在语法分析的准备阶段,就预先设置_ENV
。所以这次递归调用中,_ENV
肯定会匹配为局部变量或者Upvalue,就不会再次递归调用。
那要如何预置_ENV
呢?在上面的介绍中,_ENV
是作为整个代码块的Upvalue。但我们这里为了实现方便,在load()
函数中把_ENV
作为参数,也可以实现同样的效果:
pub fn load(input: impl Read) -> FuncProto {
let mut ctx = ParseContext { /* 省略 */ };
// _ENV 作为第一个参数,也是唯一一个参数
chunk(&mut ctx, false, vec!["_ENV".into()], Token::Eos)
}
这样一来,在解析代码块最外层的代码时,调用simple_name()
函数时,对于全局变量都会匹配到一个_ENV
的局部变量;而对于函数内的代码,则会匹配到一个_ENV
的Upvalue。
这里只是承诺说肯定有一个_ENV
变量。而这个承诺的兑现,就需要在虚拟机执行阶段了。在创建一个执行状态ExeState
时,紧跟在函数入口之后要向栈上压入_ENV
,作为第一个参数。其实就是把之前对ExeState
中global
成员的初始化,转移到了栈上。代码如下:
impl ExeState {
pub fn new() -> Self {
// 全局变量表
let mut env = Table::new(0, 0);
env.map.insert("print".into(), Value::RustFunction(lib_print));
env.map.insert("type".into(), Value::RustFunction(lib_type));
env.map.insert("ipairs".into(), Value::RustFunction(ipairs));
env.map.insert("new_counter".into(), Value::RustFunction(test_new_counter));
ExeState {
// 栈上压入2个值:虚拟的函数入口,和全局变量表 _ENV
stack: vec![Value::Nil, Value::Table(Rc::new(RefCell::new(env)))],
base: 1, // for entry function
}
}
这样,就基本完成了_ENV
的改造。这次改造非常简单,而带来的功能却很强大,所以说_ENV
是个很漂亮的设计。
另外,由于没有了全局变量的概念,之前跟全局变量相关的代码,比如ExpDesc::Global
和全局变量相关的3个字节码的生成和执行,就都可以删掉了。注意,为了实现_ENV
,并没有引入新的ExpDesc或字节码。不过只是暂时没有。
优化
上面的改造虽然功能完整,但是有个性能上的问题。由于_ENV
大部分情况下都是Upvalue,那么对于全局变量,在上述simple_name()
函数中会生成两个字节码:
GetUpvalue ($tmp_table, _ENV) # 先把 _ENV 加载到栈上
GetField ($dst, $tmp_table, $key) # 然后才能索引
而原来不用_ENV
的方案中,只需要一条字节码GetGlobal
即可。这新方案明显是降低了性能。为了弥补这里的性能损失,只需要提供能够直接对Upvalue表进行索引的字节码。为此,新增3个字节码:
pub enum ByteCode {
// 删除的3个旧的直接操作全局变量表的字节码
// GetGlobal(u8, u8),
// SetGlobal(u8, u8),
// SetGlobalConst(u8, u8),
// 新增3个对应的操作Upvalue表的字节码
GetUpField(u8, u8, u8),
SetUpField(u8, u8, u8),
SetUpFieldConst(u8, u8, u8),
相应的也要增加Upvalue表索引的表达:
enum ExpDesc {
// 删除的全局变量
// Global(usize),
// 新增的对Upvalue表的索引
IndexUpField(usize, usize),
这里对Upvalue表的索引,只支持字符串常量,这也是全局变量的场景。这个IndexUpField
虽然是针对全局变量优化而添加的,但是对于普通的Upvalue表索引也是可以应用的。所以在解析表索引的函数中,也可以增加IndexUpField
优化。这里省略具体代码。
在定义了IndexUpField
后,就可以对原来的变量解析函数进行改造:
fn simple_name(&mut self, name: String) -> ExpDesc {
// 省略对局部变量和Upvalue的匹配,如果匹配上则直接返回。
// 如果匹配不上,
// - 之前就认为是全局变量,返回 ExpDesc::Global(name)
// - 现在改造为 _ENV.name,代码如下:
let iname = self.add_const(name);
match self.simple_name("_ENV".into()) {
ExpDesc::Local(i) => ExpDesc::IndexField(i, iname),
ExpDesc::Upvalue(i) => ExpDesc::IndexUpField(i, iname), // 新增的IndexUpField
_ => panic!("no here"), // because "_ENV" must exist!
}
}
跟之前一样,一个变量在局部变量和Upvalue都匹配失败后,仍然用_ENV
做参数递归调用simple_name()
函数。但这里我们知道_ENV
返回的结果肯定是局部变量或者Upvalue,这两种情况下分别生成ExpDesc::IndexField
和ExpDesc::IndexUpField
。然后在对ExpDesc::IndexUpField
的读写处理时生成上面新增的3个字节码即可。
这样一来,就相当于是用ExpDesc::IndexUpField
代替了ExpDesc::Global
。之前删掉了对ExpDesc::Global
的处理,现在都由从ExpDesc::IndexUpField
身上加了回来。
未完待续
我们已经实现了Lua解释器最核心的功能。不过,离我们最初的目标——一个完整的、高性能的、生产级别的解释器——还差得很远。我会继续完善这个解释器,但是由于工作繁忙业余时间不足,会暂停这个系列的文章。写文章比写代码累多了。结合我上学时读于渊的《自己动手写操作系统》的经历,当时也只是跟着书的前半部分实践,在掌握了基本的开发方法,对写操作系统入了门后,后面就是完全自己写了。我认为这个系列文章到目前已经完成的部分也应该可以提供对实现一个Lua解释器的入门知识,有心的读者可以独立地实现剩余部分。
下面是一些未完成功能的部分列表:
-
元表,是Lua语言一个很重要的特性,提供灵活且强大的特性。不过其实现原理很简单,只需要在虚拟机执行相关字节码时做一层额外判断即可,甚至不需要修改语法分析的部分。这里有一个实现上的细节:我们解释器的垃圾回收是用的RC,这就可能造成循环引用进而导致内存泄漏。一个表把自己设置为自己的元表就是一个常见的循环引用。为了避免这个常见场景的循环引用,需要对这种情况做特殊处理。
-
UserData,是Lua的基本类型之一。不过我们目前还没有遇到使用UserData的需求。可以等后面在实现标准库时,遇到这个需求的时候再来实现这个类型。Lua官方实现中,新建UserData是在Lua中申请内存,然后交由C函数来初始化。而Rust中是不允许未初始化的内存的,所以需要考虑UserData的创建方式。
-
LightUserData,也是Lua的基本类型之一。不过就是一个裸指针,并不需要对此做什么特殊处理。
-
错误处理。我们目前对所有错误的处理方式都是panic,这自然是不可行的。至少需要区分预期的错误和程序bug。而前者可能还需要细分词法分析、语法分析、虚拟机执行、Rust API等类型。错误处理也是Rust语言的一个特色。这也是个很好的体验Rust错误处理的机会。
-
性能测试。高性能是我们最初的目标之一,在实现时也做了些优化,比如字符串类型的设计,但最终结果如何目前并没有底,还是需要测试才能知道。网上有些Lua性能测试的示例代码,可以跟Lua官方实现做对比测试。这也可以顺便验证正确性。
-
优化表构造。对于全部是常量元素的表构造,可以无需加载到栈上,甚至可以直接在语法分析阶段创建好表。
-
Rust API。Lua语言更多的使用场景是胶水语言,所以对外的API是非常重要的。我们这个解释器主要是给Rust语言编写的程序使用的,所以对外提供的应该是一套符合Rust调用方式的API。这就跟Lua官方实现提供的C API不一致。我们目前已经实现了些基本的API,比如读取栈上的值等,就使用了泛型,简化了API和调用方式,也就跟C API不一致。这里有份对Rust实现的脚本语言的调用方式的对比调研,很有参考价值。
-
支持整个代码段的传参和返回值。
-
标准库,是解释器核心以外的特性,涉及更多的方面。标准库中除了下面罗列的几个包外,还有一些基础函数,比如
type()
和ipairs()
等是我们已经实现的,剩下的也大多不难,唯一麻烦的是pairs()
函数。Lua官方实现中的pairs()
函数的高效实现,依赖于表的实现方式。而我们是用Rust的HashMap
来实现表的字典部分,可能没有简单的实现方式了。 -
math库,大部分函数在Rust标准库中都有对应的实现,唯一需要手动实现的是生成随机数的函数。由于C语言标准中并没有提供这个函数,所以Lua的官方实现是自己实现的这个函数。我们虽然也可以使用
random
crate,但更好是参考Lua官方实现,并自己实现这个随机数的生成函数。另外,生成随机数需要维护一个全局的状态,在Lua官方实现中,这个状态是一个UserData类型并被添加到Lua的Register中。而我们可以利用Rust闭包的特性,把这个状态放到闭包中,更方便也更高效。 -
string库,麻烦的是正则匹配。Lua语言为了轻便,自己定义并实现了一套正则匹配规则。所以我们也只能遵循其定义,并用Rust重新实现一遍。这里应该会很复杂,不过完成后也会对正则匹配有更深的了解。
-
io库,麻烦的是对文件的表示。在C语言标准中提供了
FILE
类型,可以代表所有文件类型,包括标准输入输出、普通文件等,也可以表示只读、只写、读写等多种模式。但在Rust语言中似乎这些都是独立的。如果要提供跟io库一致的API,需要做封装。 -
coroutine库,需要对Lua的协程有彻底的了解,也会对现有的函数调用流程做出很大的调整。
-
debug库,我没有使用过这个库,了解不多,但感觉如果要实现这个库,感觉要么需要大量的unsafe代码,要么对现有流程做出很大的改动。所以最终可能选择不实现这个库。
除了上述的未完成功能列表外,还有对目前代码的一些小改进,比如完善注释、应用Rust新版本中支持的let..else
语法、一些代码小优化等。为此在代码目录下新增了to_be_continued。这也可以看做是这系列文章对应代码的最终版本。
参考文献
-
Lua 5.4 Reference Manual,也是这个项目的需求文档。
-
《Lua程序设计(第4版)》,Lua官方教程。虽然是基于Lua 5.3版本,但是由于5.4版本的变化并不多,所以影响不大。
-
《Lua设计与实现》,感觉像是一份Lua官方实现的源码阅读笔记,直接讲代码实现细节,刚上手看时很吃力。
-
《自己动手实现Lua》,跟本系列文章很像,也是从零实现一个Lua解释器。但是这本书是以Lua官方实现里的字节码定义为出发点,先实现虚拟机去执行字节码,然后再实现编译器去生成字节码。而我们这系列文章是以Lua语言手册为出发点,设计并实现编译过程、虚拟机、字节码定义等。
-
Why is there no continue statement?,对Lua中为什么没有continue语句的解释。但并不完整。
-
《Rust程序设计语言》,Rust官方教程。
-
Rust官方文档,主要是参考其中的标准库部分。
-
Designing a GC in Rust,介绍用Rust实现GC的设计思路。
-
gc-crate,基于上述设计思路的一个实现。
-
A Tour of Safe Tracing GC Designs in Rust,介绍一个用Rust实现的GC设计。我只记得其中一点:用Rust实现GC是很难的。
-
Implementing a safe garbage collector in Rust,另外一个用Rust实现GC的项目。
-
When Zig is safer and faster than Rust,以Roc语言使用Zig而非Rust来实现GC部分为出发点,来说明用unsafe Rust来实现某些功能是很困难的。
-
Luster,用Rust实现的Lua解释器,也是用的GC而非RC,但项目没完成。
-
The Story of Tail Call Optimizations in Rust,对Rust语言支持尾调用的讨论。
-
Lua bindings: lua, hlua or rlua?,Reddit上对现有的3个Lua crate:lua、hlua和rlua的简单对比。
-
A Survey of Rust Embeddable Scripting Languages,对几个可以在Rust中使用的脚本语言(包括Lua)在使用方式上的对比。
-
Floating Point Arcade,把整型随机数转换为浮点数的介绍。