警告⚠️:这将是一个又臭又长的系列教程,教程结束的时候,你将拥有一个除了性能差劲、扩展性差、标准库不完善之外,其他方面都和官方相差无几的 Lua 语言解释器。说白了,这个系列的教程实现的是一个玩具语言,仅供学习,无实用性。请谨慎 Follow,请谨慎 Follow,请谨慎 Follow。
这是本系列教程的第三篇,如果你没有看过之前的文章,请从头观看。
前言
本次我们将为 SLua 实现完整的词法解析器。词法解析的任务比较简单,所以编写起来难度也不大。在上一篇教程中,我们引入了 Token 类型,词法解析器做的事情就是把用户输入的源代码转化成一个 Token 序列。在本文中我们将编写一个新的类型:Scanner (扫描器),完成词法分析的任务。
题外话:我讨厌废话,所以我会尽量将语言组织的简练易懂。如果您觉得文章有词不达意的地方,欢迎在评论区或发私信提出来,我会及时改进,为后续的读者谢谢您。
使用方式
在 Scanner 完成之后,我们可以这样来使用它:
// slua.go
package main
import (
"fmt"
"strings"
"github.com/ksco/slua/scanner"
)
func main() {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
}
}()
r := strings.NewReader("local 你好 = 'Hello, 世界'")
s := scanner.New(r)
t := s.Scan()
for t.Category != scanner.TokenEOF {
fmt.Printf("Type:%v Value:%v\n", t.Category, t)
t = s.Scan()
}
}
如果你不熟悉 Go 语言,可能会对下面这段代码感到困惑。
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
}
}()
解释:Go 语言使用 panic 来抛出错误,使程序进入“恐慌”模式,可以使用 defer 和 recover 来处理错误,恢复运行。预知详情,请看这篇文章:英文版(需要翻墙)、中文版
可以看到,scanner 模块提供了 New 函数,用来实例化一个 Scanner 类型,它接受一个满足 io.RuneReader 接口的参数。在上面的代码中,为了测试方便,我们传入了一个 strings.Reader 作为参数。
另外,Scanner 类还导出了一个方法 Scan,每次调用这个函数,都会返回下一个 Token,直到文件结束为止(返回值的 Category 为 TokenEOF 即为结束)。
之所以使用 io.RuneReader,而不是更为常见的 io.ByteReader,是因为我们想让 SLua 支持 UNICODE,可以使用中文作为标志符(Identifier),很 Cool 吧?
上面的程序的输出如下:
$ go run slua.go
Type:local Value:local
Type:<id> Value:你好
Type:= Value:=
Type:<string> Value:Hello, 世界
具体实现
New 方法
好了,最终成果展示完毕,现在我们就来实现它!首先第一步,我们需要定义 Scanner 类型。
type Scanner struct {
reader io.RuneReader
module string
current rune
line int
column int
buffer []rune
}
下面逐一解释每个参数的作用。
- reader:自然想到,我们需要存储传进来的 io.RuneReader 参数。
- module:如果程序运行出错的话,我们需要知道到底是在哪个阶段出的错,是词法分析、语法分析还是语义分析?module 字段用于存储当前所在的模块。
- current:存储当前所在位置的 rune 字符。
- line、column:因为 Token 需要存储所在的行和列,所以 Scanner 就需要维护这两个变量。
- buffer 作为缓冲区,用作解析字符串和数字。
这样,我们就可以定义 scanner.New 函数了:
const eof rune = 0
func New(reader io.RuneReader) *Scanner {
s := new(Scanner)
s.module = "scanner"
s.reader = reader
s.line = 1
s.current = eof
}
等等,你是不是漏掉为 column 字段做初始化了?
Go 语言会为未初始化的变量设定一个默认的初始值:数字类型的默认值为 0、字符串默认为 ""、指针默认为 nil 等等,所以 column 值为 0,这刚好就是我们希望的。
Scan 方法初探
下面我们就开始定义前面提到过的 Scan 方法。但在此之前,我们还需要定义一些私有的帮助方法。
Go 语言提供了简单的访问权限的控制,但它并没有 public 和 private 等关键字,而且它的权限控制是针对模块的,而不是类。规则非常简单:如果类型、常量、函数等的首字母是大写的,那它就是模块外可见的。如果首字母小写,那它就只能在模块内访问。
func (s *Scanner) next() rune {
ch, _, err := s.reader.ReadRune()
if err == nil {
s.column++
}
return ch
}
next 函数非常简单,它使用 ReadRune 函数来读取一个字符,如果读取成功就将 column +1,然后将读取到的字符返回。如果读取失败,ch 为 0,这刚好就是我们定义的 eof 常量。
有了 next 函数,我们可以开始定义 Scan 函数。
func (s *Scanner) Scan() *Token {
if s.current == eof {
s.current = s.next()
}
for s.current != eof {
switch s.current {
case ' ', '\t', '\v', '\f':
s.current = s.next()
default:
s.current = s.next()
}
}
return NewToken()
}
首先我们判断 s.current 是否为 eof,如果是就调用 next 函数为其赋值。剩下的部分应该都很容易懂。第一个 case 是为了去除源代码中的空白符。剩下的字符我们暂时用 default 分支简单处理。
处理换行符
换行符的处理较为简单:
case '\r', '\n':
s.newLine()
如果当前的字符为 \r、\n,就调用 newLine 方法。newLine 定义如下:
func (s *Scanner) newLine() {
ch := s.next()
if (ch == '\r' || ch == '\n') && ch != s.current {
s.current = s.next()
} else {
s.current = ch
}
s.line++
s.column = 0
}
注意,如果下一个字符也是 \r 或 \n,且不与当前字符相同,则两个换行符就合并做为一个来处理。
这样做是为了系统兼容:
\r = CR (Carriage Return) 在 OSX 之前用于 Mac OS 的换行符。
\n = LF (Line Feed) 用于 OSX/Unix 系统的换行符。
\r\n = CR + LF 用于 Windows 系统的换行符。
参考链接:StackOverflow
解析单字节操作符
在讲解如何解析操作符之前,需要介绍另一个私有方法:
func (s *Scanner) normalToken(category string) *Token {
return &Token{
Line: s.line,
Column: s.column,
Category: category,
}
}
这个函数根据传入的类型以及 Scanner 内部存储的行列信息,构造出一个 Token 实例,并返回其指针。有了它,我们就可以解析单个字符长度的操作符了。
case '+', '*', '/', '#', '(', ')', ';', ',':
t := s.current
s.current = s.next()
return s.normalToken(string(t))
对于上面定义的 normalToken 方法,我们注意到,它只能定义没有附加信息类型的 Token,所以对于 TokenNumber、TokenID 和 TokenString,我们还需做点额外的工作:
func (s *Scanner) stringToken(value, category string) *Token {
t := s.normalToken(category)
t.Value = value
return t
}
func (s *Scanner) numberToken(value float64) *Token {
t := s.normalToken(TokenNumber)
t.Value = value
return t
}
stringToken 之所以还需要传入类型,是因为它有 TokenString 和 TokenID 两种选择。
解析注释
注释的解析也不麻烦:
case '-':
n := s.next()
if n == '-' {
s.comment()
} else {
s.current = n
return s.normalToken(TokenSub)
}
Lua 使用 -- 这是一个单行注释 来表示单行注释,跟 C/C++ 的单行注释规则相同,只不过不是 // 而是 --。
所以,如果我们遇到一个 - 符号,就需要看一下它下一个符号,如果下一个符号还是 -,则说明这是一个单行注释。否则,就作为 TokenSub 类型处理。
如果是注释,我们就调用 comment 方法来处理,它的定义如下:
func (s *Scanner) comment() {
s.current = s.next()
for s.current != '\r' && s.current != '\n' && s.current != eof {
s.current = s.next()
}
}
逻辑很简单,我们一直往下读取,直到遇到换行符或读取到文件尾(EOF)为止。
错误处理
接下来,我们还有字符串、标记符(包括关键字)和数字需要处理。在此之前,我们需要先介绍一下 SLua 的错误处理方式。
Go 语言定义了一个 error 接口:
type error interface {
Error() string
}
只要满足这个接口的类型就可以直接做为参数传入 panic 函数。所以我们定义了满足这个接口的 Error 类型:
package scanner
import "fmt"
type Error struct {
module string
line int
column int
str string
}
func (e *Error) Error() string {
return fmt.Sprintf("%v:%v:%v %v", e.module, e.line, e.column, e.str)
}
具体代码就不详细说了,一看就懂。
解析数字
数字的解析算是比较麻烦的部分了。
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
return s.number(false)
case '.':
n := s.next()
if n == '.' {
s.current = s.next()
return s.normalToken(TokenConcat)
} else {
s.buffer = s.buffer[:0]
s.buffer = append(s.buffer, s.current)
s.current = n
return s.number(true)
}
如代码所示,如果我们遇到数字符号,就调用 number 方法。另外,Lua 和 C 语言相似,允许小于 1 的浮点数省略前面的 0,直接以 . 号开始,例如 .141592653。但如果我们连续遇到两个点号,则说明是 TokenConcat。number 的定义如下:
func (s *Scanner) number(point bool) *Token {
if !point {
s.buffer = s.buffer[:0]
for unicode.IsDigit(s.current) {
s.buffer = append(s.buffer, s.current)
s.current = s.next()
}
if s.current == '.' {
s.buffer = append(s.buffer, s.current)
s.current = s.next()
}
}
for unicode.IsDigit(s.current) {
s.buffer = append(s.buffer, s.current)
s.current = s.next()
}
str := string(s.buffer)
number, err := strconv.ParseFloat(str, 64)
if err != nil {
panic(&Error{
module: s.module,
line: s.line,
column: s.column,
str: "parse number " + str + " error: invalid syntax",
})
}
return s.numberToken(number)
}
number 函数接受一个参数,用来表示是否已经遇到过 . 号了。另外我们使用了 unicode.IsDigit 来判断是否为数字字符,使用了 strconv.ParseFloat 将字符串转换为数字。请注意错误处理方式,和文首的示例代码配合着看(panic 和 recover)。
解析 ~= 操作符
Lua 使用 ~= 来表示不等于,逻辑等同于 C/C++ 中的 != 操作符。解析代码如下:
case '~':
n := s.next()
if n != '=' {
panic(&Error{
module: s.module,
line: s.line,
column: s.column,
str: "expect '=' after '~'",
})
}
s.current = s.next()
return s.normalToken(TokenNotEqual)
解析 =、==、<、<=、>、>= 操作符
case '=':
return s.xequal(TokenEqual)
case '>':
return s.xequal(TokenGreaterEqual)
case '<':
return s.xequal(TokenLessEqual)
xequal 方法定义如下:
func (s *Scanner) xequal(category string) *Token {
t := s.current
ch := s.next()
if ch == '=' {
s.current = s.next()
return s.normalToken(category)
}
s.current = ch
return s.normalToken(string(t))
}
xequal 的逻辑是:如果跟在当前符号后面的是一个 = 符号,则返回传入的类型的 Token,否则就返回当前符号所代表类型的 Token。
解析单行字符串
case '\'', '"':
return s.singlelineString()
与 Python 类似,Lua 的单行字符串可以包裹在 ' 或 " 中。singlelineString 方法定义入下:
func (s *Scanner) singlelineString() *Token {
quote := s.current
s.current = s.next()
s.buffer = s.buffer[:0]
for s.current != quote {
if s.current == eof {
panic(&Error{
module: s.module,
line: s.line,
column: s.column,
str: "incomplete string at <eof>",
})
}
if s.current == '\r' || s.current == '\n' {
panic(&Error{
module: s.module,
line: s.line,
column: s.column,
str: "incomplete string at <eol>",
})
}
s.stringChar()
}
s.current = s.next()
return s.stringToken(string(s.buffer), TokenString)
}
func (s *Scanner) stringChar() {
if s.current == '\\' {
s.current = s.next()
if s.current == 'a' {
s.buffer = append(s.buffer, '\a')
} else if s.current == 'b' {
s.buffer = append(s.buffer, '\b')
} else if s.current == 'f' {
s.buffer = append(s.buffer, '\f')
} else if s.current == 'n' {
s.buffer = append(s.buffer, '\n')
} else if s.current == 'r' {
s.buffer = append(s.buffer, '\r')
} else if s.current == 't' {
s.buffer = append(s.buffer, '\t')
} else if s.current == 'v' {
s.buffer = append(s.buffer, '\v')
} else if s.current == '\\' {
s.buffer = append(s.buffer, '\\')
} else if s.current == '"' {
s.buffer = append(s.buffer, '"')
} else if s.current == '\'' {
s.buffer = append(s.buffer, '\'')
} else {
panic(&Error{
module: s.module,
line: s.line,
column: s.column,
str: "unexpect character after '\\'",
})
}
} else {
s.buffer = append(s.buffer, s.current)
}
s.current = s.next()
}
其中,stringChar 函数用来处理字符串中出现的转义字符。如果在字符串结束之前遇到了换行符或读取到文件尾,则输出处错误信息。
到现在为止,除了标记符(包括关键字),所有的 case 都解析完毕了,所以我们可以将 TokenID 类型的解析放在 default 中:
default:
return s.id()
在 Lua 中,标记符需要以 _ 或其他非符号 UNICODE 字符开头,剩下的部分除此之外还可以包含数字。
例如
hello、_temp、k_1077、信息列表123、さよなら都是合法的标记符。
为此我们需要有个函数来帮助我们确定某字符是否为合法字符:
func isLetter(ch rune) bool {
return ascii.IsLetter(byte(ch)) || ch == '_' ||
ch >= 0x80 && unicode.IsLetter(ch)
}
其中 ascii.IsLetter 是我们自定义的函数,定义如下:
package ascii
func IsLetter(ch byte) bool {
return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z'
}
你可能会奇怪,问什么我们要多此一举呢,isLetter 完全可以这样来写:
func isLetter(ch rune) bool {
return ch == '_' || unicode.IsLetter(ch)
}
答案是出于性能考虑。在 unicode.IsLetter 的内部实现中,需要查找一个巨大的列表来确定结果。而在代码中,绝大多数的标记符和所有的关键字都是由 ASCII 字符组成,我们利用 || 操作符的“截断”特性来尽量避免调用 unicode.IsLetter 函数,以提高性能。
具体的讨论请见这个帖子:地址(需翻墙)
id 函数的定义如下:
func (s *Scanner) id() *Token {
if !isLetter(s.current) {
panic(&Error{
module: s.module,
line: s.line,
column: s.column,
str: "unexpect character",
})
}
s.buffer = s.buffer[:0]
s.buffer = append(s.buffer, s.current)
s.current = s.next()
for isLetter(s.current) || unicode.IsDigit(s.current) {
s.buffer = append(s.buffer, s.current)
s.current = s.next()
}
str := string(s.buffer)
if isKeyword(str) {
return s.stringToken(str, str)
}
return s.stringToken(str, TokenID)
}
至此,Scanner 类型的完整定义就完成了。你可以在此查看完整的源代码:scanner/scanner.go、scanner/error.go。
虽然 Scanner 的定义完成了,但我们并没有进行单元测试,所以我们不清楚程序中是否存在未知的 Bug,这显然对后续的开发是不利的。幸运的是,Go 语言提供了强大的 testing 包用于单元测试,你可以学习一下这个包的使用方法,并用其对 scanner 模块进行完整的单元测试。因为词法分析的单元测试比较简单,就不详细说了。
获取源代码
代码已托管到 Github 上:SLua,每一个阶段的代码我都会创建一个 release,你可以直接下载作为参照。虽然提供了源代码,但并不建议直接复制粘贴,因为这样学到的知识会很容易忘记。
刚开始玩 Github 和简书,所以没有任何粉丝和关注量(哭),如果你觉得这篇教程有帮助,请不要吝啬给文章点个喜欢,给 Github 上的项目点个 Star。如果能 Follow 一下简书和 Github 的账号就更好啦,我也会更加有动力将这个系列写下去。:)