从零开始实现 Lua 解释器之词法分析

982 阅读11分钟
原文链接: www.jianshu.com

警告⚠️:这将是一个又臭又长的系列教程,教程结束的时候,你将拥有一个除了性能差劲、扩展性差、标准库不完善之外,其他方面都和官方相差无几的 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_tempk_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.goscanner/error.go

虽然 Scanner 的定义完成了,但我们并没有进行单元测试,所以我们不清楚程序中是否存在未知的 Bug,这显然对后续的开发是不利的。幸运的是,Go 语言提供了强大的 testing 包用于单元测试,你可以学习一下这个包的使用方法,并用其对 scanner 模块进行完整的单元测试。因为词法分析的单元测试比较简单,就不详细说了。

获取源代码


代码已托管到 Github 上:SLua,每一个阶段的代码我都会创建一个 release,你可以直接下载作为参照。虽然提供了源代码,但并不建议直接复制粘贴,因为这样学到的知识会很容易忘记。

刚开始玩 Github 和简书,所以没有任何粉丝和关注量(哭),如果你觉得这篇教程有帮助,请不要吝啬给文章点个喜欢,给 Github 上的项目点个 Star。如果能 Follow 一下简书和 Github 的账号就更好啦,我也会更加有动力将这个系列写下去。:)