用Go语言自制解释器
上QQ阅读APP看书,第一时间看更新

1.4 扩展词法单元和词法分析器

为了避免以后编写语法分析器时需要在多个语言包之间跳转,需要扩展词法分析器,以便识别更多的 Monkey 代码并输出更多的词法单元。因此本节将添加对==!!=-/*<>和关键字truefalseifelsereturn的支持。

需要添加、构建和输出的新词法单元可以分为以下三种:单字符词法单元(例如-)、双字符词法单元(例如==)和关键字词法单元(例如return)。前面已经介绍了如何处理单字符和关键字的词法单元,所以现在先添加这两种,之后再为词法分析器添加双字符词法单元。

添加对-/*<>的支持很简单。当然,与之前一样,第一件事是在lexer/lexer_test.go中修改测试用例的输入来添加这些字符。另外还要修改tests表,在本章随附的代码中可以找到扩展后的tests表。为了节省篇幅且不让读者感到枯燥,本章后续部分不会再列出tests表。

// lexer/lexer_test.go

func TestNextToken(t *testing.T) {
    input :=`let five = 5;
let ten = 10;

let add = fn(x, y) {
    x + y;
};

let result = add(five, ten);
!-/*5;
5 < 10 > 5;
`

// [...]
}

注意,尽管这个输入看起来像是一段真实的 Monkey 源代码,但是实际上有些代码行并没有意义,比如!-/*5这样的乱码。不过没关系,词法分析器的任务不是检查代码是否有意义、能否运行,或者有没有错误,这些都是后续阶段的任务。词法分析器应该仅用来将输入转换为词法单元。因此,为词法分析器编写的这个测试用例涵盖了所有词法单元,并且还尝试引发词法单元位置的差一错误、文件末尾的边缘情形、换行符处理、多位数字解析等问题。这就是为什么这段用作测试的代码看起来像乱码。

运行该测试会得到许多undefined:错误,因为测试包含对未定义TokenType的引用。为了解决这些问题,需要在token/token.go中添加以下常量:

// token/token.go

const (
// [...]

    // 运算符
    ASSIGN   = "="
    PLUS     = "+"
    MINUS    = "-"
    BANG     = "!"
    ASTERISK = "*"
    SLASH    = "/"

    LT = "<"
    GT = ">"

// [...]
)

添加了新的常量后,测试仍然会失败,因为还没有返回带有预期TokenType的词法单元。

$ go test ./lexer
--- FAIL: TestNextToken (0.00s)
  lexer_test.go:84: tests[36] - tokentype wrong. expected="!", got="ILLEGAL"
FAIL
FAIL    monkey/lexer 0.007s

为了让测试通过,还需要修改LexerNextToken()里面的switch语句:

// lexer/lexer.go

func (l *Lexer) NextToken() token.Token {
// [...]
    switch l.ch {
    case '=':
        tok = newToken(token.ASSIGN, l.ch)
    case '+':
        tok = newToken(token.PLUS, l.ch)
    case '-':
        tok = newToken(token.MINUS, l.ch)
    case '!':
        tok = newToken(token.BANG, l.ch)
    case '/':
        tok = newToken(token.SLASH, l.ch)
    case '*':
        tok = newToken(token.ASTERISK, l.ch)
    case '<':
        tok = newToken(token.LT, l.ch)
    case '>':
        tok = newToken(token.GT, l.ch)
    case ';':
        tok = newToken(token.SEMICOLON, l.ch)
    case ',':
        tok = newToken(token.COMMA, l.ch)
// [...]
}

这里添加了新的词法单元,并且对switch语句的各个分支进行了重新排序,以便与token/token.go中的常量结构相对应。有了这个小小的修改,测试就能通过了:

$ go test ./lexer
ok      monkey/lexer 0.007s

成功添加新的单字符词法单元后,下一步来添加新的关键字truefalseifelsereturn

同样,第一步是扩展测试中的输入,添加这些新关键字。下面是TestNextTokeninput现在的内容:

// lexer/lexer_test.go

func TestNextToken(t *testing.T) {
    input :=`let five = 5;
let ten = 10;

let add = fn(x, y) {
  x + y;
};

let result = add(five, ten);
!-/*5;
5 < 10 > 5;

if (5 < 10) {
    return true;
} else {
    return false;
}`
// [...]
}

由于测试的期望结果中还没有添加对新关键字的引用,因此测试无法编译。为了再次解决这个问题,需要添加新的常量。而对于当前情况,需要将关键字添加到LookupIdent()的关键字表中。

// token/token.go

const (
// [...]

    // 关键字
    FUNCTION = "FUNCTION"
    LET      = "LET"
    TRUE     = "TRUE"
    FALSE    = "FALSE"
    IF       = "IF"
    ELSE     = "ELSE"
    RETURN   = "RETURN"
)

var keywords = map[string]TokenType{
    "fn":     FUNCTION,
    "let":    LET,
    "true":   TRUE,
    "false":  FALSE,
    "if":     IF,
    "else":   ELSE,
    "return": RETURN,
}

结果是,不仅通过修复对未定义变量的引用解决了编译错误,测试也通过了:

$ go test ./lexer
ok      monkey/lexer 0.007s

词法分析器现在可以识别新的关键字了,所做的修改不大,很容易就能想到并实现。现在可以自夸一下,我们做得很好!

但是在进入第2章接触语法分析器之前,还需要进一步扩展词法分析器,以便识别由两个字符组成的词法单元。所要支持的词法单元在源代码中看起来像==!=这样。

乍一看,读者可能会想:为什么不向switch语句中添加新的case来达到这个目的呢?由于switch语句使用的表达式是单个字符l.ch,与它相比较的case也需要是单个字符,因此编译器不允许使用case "=="这样的形式,即字节类型的l.ch==之类的字符串不能互相比较。因此不能直接添加类似的新case语句。

实际可以做的是,复用并扩展现有的=分支和!分支。因此,所要做的是根据前一步输入中的下一个字符,决定是返回=,还是==的词法单元。再次扩展lexer/lexer_test.go中的input,现在的代码如下:

// lexer/lexer_test.go

func TestNextToken(t *testing.T) {
    input :=`let five = 5;
let ten = 10;

let add = fn(x, y) {
  x + y;
};

let result = add(five, ten);
!-/*5;
5 < 10 > 5;

if (5 < 10) {
    return true;
} else {
    return false;
}

10 == 10;
10 != 9;
`

// [...]
}

在开始修改NextToken()中的switch语句之前,需要在 *Lexer上定义名为peekChar()的新辅助方法:

// lexer/lexer.go

func (l *Lexer) peekChar() byte {
    if l.readPosition >= len(l.input) {
        return 0
    } else {
        return l.input[l.readPosition]
    }
}

peekChar()readChar()非常类似,但这个函数不会前移l.positionl.readPosition。它的目的只是窥视一下输入中的下一个字符,不会移动位于输入中的指针位置,这样就能知道下一步在调用readChar()时会返回什么。大多数词法分析器和语法分析器具有这样的“窥视”函数,且大部分情况是用来向前看一个字符的。

在对不同的编程语言进行语法分析时,通常的难点就是必须在源代码中向前或向后多看几个字符才能确定代码的含义。

添加peekChar()后,测试代码还无法编译。这是由于测试中引用了未定义的词法单元常量。需要再次解决这个问题,这很容易:

// token/token.go

const (
// [...]

    EQ     = "=="
    NOT_EQ = "!="

// [...]
)

修复了词法分析器测试中对token.EQtoken.NOT_EQ的引用后,运行该测试会得到一条失败消息:

$ go test ./lexer
--- FAIL: TestNextToken (0.00s)
  lexer_test.go:118: tests[66] - tokentype wrong. expected="==", got="="
FAIL
FAIL    monkey/lexer 0.007s

现在,当词法分析器在输入中遇到==时,会创建两个token.ASSIGN词法单元,而不是一个token.EQ词法单元。解决方案是使用新的peekChar()方法。在switch语句的=分支和!分支中,向前多看一个字符。如果下一个词法单元是=,那么就分别创建token.EQ词法单元或token.NOT_EQ词法单元:

// lexer/lexer.go

func (l *Lexer) NextToken() token.Token {
// [...]
    switch l.ch {
    case '=':
        if l.peekChar() == '=' {
            ch := l.ch
            l.readChar()
            literal := string(ch) + string(l.ch)
            tok = token.Token{Type: token.EQ, Literal: literal}
        } else {
            tok = newToken(token.ASSIGN, l.ch)
        }
// [...]
    case '!':
        if l.peekChar() == '=' {
            ch := l.ch
            l.readChar()
            literal := string(ch) + string(l.ch)
            tok = token.Token{Type: token.NOT_EQ, Literal: literal}
        } else {
            tok = newToken(token.BANG, l.ch)
        }
// [...]
}

注意,再次调用l.readChar()之前,需要将l.ch保存在局部变量中。这样就不会丢失当前字符,可以安全地前移词法分析器,以使NextToken()l.positionl.readPosition保持正确的状态。这两个双字符的处理方式非常相似。如果要在 Monkey 语言中支持更多的双字符词法单元,则应该使用名为makeTwoCharToken的方法把处理步骤抽象出来。该方法会在找到某些词法单元时继续前看一个字符。对于 Monkey 来说,目前仅有==!=这两个双字符词法单元,所以先保持原样。现在再次运行测试以确保其有效:

$ go test ./lexer
ok      monkey/lexer 0.006s

测试正常通过。我们成功地扩展了词法分析器!现在词法分析器可以生成扩展的词法单元,接下来就能够编写语法分析器了。但在此之前,再做一些额外的工作来为后续章节打好基础。