在我们开始coding之前,让我们理清一下本部分的目标。我们将写下我们自己的词法分析器,它将吃掉源代码作为输入并且输出它认识的Token。
它不需要缓冲区或保存Tokens,因为只有一个被称为NextToken()
的方法,它可以输出下一个Token。
这意味着我们将用我们的源代码初始化词法分析器,然后重复调用NextToken()来遍历源代码,一个标记一个标记,一个字符,一个字符。我们还将通过使用字符串作为我们源代码的类型来简化这里的工作。注意:在生产环境中,将文件名和行号附加到Token上是有意义的,它可以更好地追钟语法分析和解析错误。所以最好用io.Reader
初始化语法分析器和文件名。但是因为这会增加更多的复杂性,我们不会在这里处理,我们将从小开始,只是用字符串并忽略文件名和行号。
考虑到这一点,我们现在意识到我们的词法分析器需要做的很清楚。所以让我们创建一个新package并添加第一个测试,我们可以连续运行该测试以获取有关词法分析器工作状态的反馈。我们从这里开始,以扩展测试用例,为词法分析器添加更多功能:
// lexer/lexer_test.go
package lexer
import (
"testing"
"monkey/token"
)
func TestNextToken(t *testing.T){
input := `=+(){},;`
tests := []struct{
expectedType token.TokenType
expectedLiteral string
}{
{token.ASSIGN,"="},
{token.PLUS,"+"},
{token.LPAREN,"("},
{token.RPAREN,")"},
{token.LBRACE,"{"},
{token.RBRACE,"}"},
{token.COMMA,","},
{token.SEMICOLON,";"},
{token.EOF,""},
}
l := New(input)
for i, tt := range tests {
tok := l.NextToken()
if tok.Type != tt.expectedType {
t.Fatalf("tests[%d] - tokentype wrong. expected=%q, got=%q",
i, tt.expectedType, tok.Type)
}
if tok.Literal != tt.expectedLiteral {
t.Fatalf("tests[%d] - literal wrong. expected=%q, got=%q",
i, tt.expectedLiteral, tok.Literal)
}
}
}
当然,这个测试失败了——我们还没有写任何代码:
$ go test ./lexer
# monkey/lexer
lexer/lexer_test.go:27: undefined: New
FAIL monkey/lexer [build f
所以让我们通过定义返回*Lexer的New()函数。
// lexer/lexer.go
package lexer
import "monkey/token"
type Lexer struct {
input string
position int // current position in input (points to current char)
readPosition int // current reading position in input (after current char)
ch byte // current char under examination
}
func New(input string) *Lexer {
l := &Lexer{input: input}
return l
}
许多在Lexer中的字段是不言自明的。有一些可能会引起困扰的是position
和readPosition
.这两个将用于它们作为索引来访问输入的字符。例如l.input[l.readPosition]
。这两个指针指向我们的输入字符串的原因是我们需要能够进一步“窥视”输入并查看当前字符以查看接下来会发送什么。readPosition
总是指向输入中的“下一个”字。positions
指向输入中对应于ch的字符。
第一个名为readChar()
的辅助方法应该使这些字段更容易理解:
//lexer/lexer.go
func (l *Lexer) readChar() {
if l.readPosition >= len(l.input) {
l.ch = 0
} else {
l.ch = l.input[l.readPosition]
}
l.position = l.readPosition
l.readPosition += 1
}
readChar的目的是为我们提供下一个字符并提高我们在输入字符串中的位置。它做的第一件事是检查我们是否到达输入的末尾。如果是,它就设置l.ch为0,这是"NUL"字符的ASCII码,对我们来说表示“我们还没有读取任何东西”或“文件结束”。但是如果我们还没有到达输入的结尾,它会通过访问l.input[l.readPosition]来设置l.ch到下一个字符。
在l.position之后被更新为l.readPosition并且l.readPosition+1。这样,l.readPosition总是指向我们要从next开始读取的下一个位置,而l.position总是指向我们上次读取的位置。这很快就会派上用场。
在谈论readChar时指出,词法分析器仅支持ASCII字符而不是完成的Unicode范围。为什么?因为这让我们保持简单并专注于我们的解释器的基本部分。为了完全支持Unicode和UTF-8,我们需要将l.ch从一个字节更改为rune并更改我们读取下一个字符的方式,因为它们现在可能是多个字节宽。使用l.input[l.readPosition]将不再起作用。然后我们还需要更改一些我们稍后会看到的其他方法和函数。因此在Monkey中完全支持Unicode(和表情符号)作为练习留给读者。
让我们在New函数中使用readChar以便我们的*Lexer在任何人调用NextToken()之前处于完全工作的状态,并且l.ch,l.position和l.readPosition也以及初始化。
//lexer/lexer.go
func New(input string) *Lexer {
l := &Lexer{input:input}
l.readChar()
return l
}
我们的测试现在告诉我们,调用New(输入)不会遇到问题,但仍然缺少NextToken()方法。让我们通过添加第一个版本来解决:
//lexer/lexer.go
import "monkey/token"
func (l *Lexer) NextToken() token.Token {
var tok token.Token
switch l.ch {
case'=':
tok = newToken(token.ASSIGN, l.ch)
case ';':
tok = newToken(token.SEMICOLON, l.ch)
case '(':
tok = newToken(token.LPAREN, l.ch)
case ')':
tok = newToken(token.RPAREN, l.ch)
case ',':
tok = newToken(token.COMMA, l.ch)
case '+':
tok = newToken(token.PLUS, l.ch)
case '{':
tok = newToken(token.LBRACE, l.ch)
case '}':
tok = newToken(token.RBRACE, l.ch)
case 0:
tok.Literal = ""
tok.Type = token.EOF
}
l.readChar()
return tok
}
func newToken(tokenType token.TokenType, ch byte) token.Token {
return token.Token{Type: tokenType, Literal: string(ch)}
}
这就是newToken()方法的基本结构。我们查看当前正在检查的字符(l.ch)并根据它是哪个字符返回一个标记。在返回Token之前,我们将指针推进到输入中,因此当我们再一次调用NextToken()时,l.ch字段已经更新。一个名为newToken的小函数帮助我们初始化这些Token。
运行测试我们可以看到它通过了:
$ go test ./lexer
ok monkey/lexer 0.007s
很棒!现在让我们延长测试案例,以便开始类似于Monkey的源代码。
// 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);
`
tests := []struct {
expectedType token.TokenType
expectedLiteral string
}{
{token.LET, "let"},
{token.IDENT, "five"},
{token.ASSIGN, "="},
{token.INT, "5"},
{token.SEMICOLON, ";"},
{token.LET, "let"},
{token.IDENT, "ten"},
{token.ASSIGN, "="},
{token.INT, "10"},
{token.SEMICOLON, ";"},
{token.LET, "let"},
{token.IDENT, "add"},
{token.ASSIGN, "="},
{token.FUNCTION, "fn"},
{token.LPAREN, "("},
{token.IDENT, "x"},
{token.COMMA, ","},
{token.IDENT, "y"},
{token.RPAREN, ")"},
{token.LBRACE, "{"},
{token.IDENT, "x"},
{token.PLUS, "+"},
{token.IDENT, "y"},
{token.SEMICOLON, ";"},
{token.RBRACE, "}"},
{token.SEMICOLON, ";"},
{token.LET, "let"},
{token.IDENT, "result"},
{token.ASSIGN, "="},
{token.IDENT, "add"},
{token.LPAREN, "("},
{token.IDENT, "five"},
{token.COMMA, ","},
{token.IDENT, "ten"},
{token.RPAREN, ")"},
{token.SEMICOLON, ";"},
{token.EOF, ""},
}
//[...]
}
值得注意的是,此测试用例中的输入已更改。它看起来像是Monkey语言的一个子集。它包含我们已经成功转换为标记的所有符号,以及现在导致我们的测试失败的新事物:标识符,关键字和数字。让我们从标识符和关键字开始。我们的词法分析器需要做的是识别当前字符是否是字母,如果是,它需要读取标识符/关键字的其余部分,直到遇到非字母字符。读完那个标识符/关键字后,我们需要找出它是标识符还是关键字,这样我们就可以使用正确的token.TokenType。第一步是拓展我们的switch语句:
// lexer/lexer.go
import "monkey/token"
func (l *Lexer) NextToken() token.Token {
var tok token.Token
switch l.ch{
//[...]
default:
if isLetter(l.ch){
tok.Literal = l.readIdentifier()
return tok
} else {
tok = newToken(token.ILLEGAL,l.ch)
}
}
//[...]
}
func (l *Lexer) readIdentifier() string {
position := l.position
for isLetter(l.ch) {
l.readChar()
}
return l.input[position:l.position]
}
func isLetter(ch byte) bool {
return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_'
}
我们已经在switch语句中添加了default
默认分支,因此只要 l.ch 不是可识别的字符之一,我们就可以检查标识符。我们还添加了token.ILLEGAL Token的生成。如果我们到了那里,我们真的不知道如何正确处理当前的字符和将其声明为token.ILLEGAL。
isLetter函数只是检查给定的参数是否为字母。这听起来很简单,但值得注意的是,改变这个函数对我们的解释器能够解析的语言的影响比人们对这样一个小函数的期望更大。正如你所见,在我们实例中,它包含检查ch=='_',这意味着我们将把_视为一个字母并允许它出现在标识符和关键字中。这意味着我们可以使用像foo_bar这样的变量名。其他编程语言甚至允许!、and、?、in标识符。如果你也想允许这些标识符,偷偷加进去:)
readIdentifier函数就像它的名字那样:它读入一个标识符并推进我们的词法分析器的位置,直到遇到一个非字母字符。
在switch1的default
分支中我们使用readIdentifier来设置当前token的文字字段。但它的类型呢?现在我们以及读取了标识符像let,fn或foobar这样的标识符,我们需要能够区分用户定义的标识符和语言关键字。我们需要一个函数来为我们拥有的token文字返回正确的token类型。有什么比token包添加这样的功能更好的地方吗?
// token/token.go
var Keywords = map[string]TokenType{
"fn": FUNCTION,
"let": LET,
}
func LookupIdent(ident string) TokenType {
if tok,ok := keywords[ident];ok{
return tok
}
return IDENT
}
LookupIdent检查关键字表以查看给定的关键字是否实际上是关键字。如果是,则返回关键字的TokenType常量。如果不是,我们就返回token,IDENT,它是所有用户定义标识符的TokenType。
有了这个,我们就可以完成标识符和关键字的语法分析:
// lexer/lexer.go
func (l *Lexer) NextToken() token.Token {
var tok token.Token
switch l.ch{
//[...]
default:
if isLetter(l.ch) {
tok.Literal = l.readIdentifier()
return tok
} else {
tok = newToken(token.ILLEGAL,l.ch)
}
}
//[...]
}
这里的提前退出,我们的return tok
语句是必要的,因为当调用readIdentifier时,我们会重复调用readChar()并将我们的readPosition和position字段推进到当前字符的后一个字符去。所以我们不需要在switch语句之后再一次调用readChar()。
现在允许我们的测试,我们可以看到let被正确识别,但测试仍然失败:
$ go test ./lexer
--- FAIL: TestNextToken (0.00s)
lexer_test.go:70: tests[1] - tokentype wrong. expected="IDENT", got="ILLEGAL"
FAIL
FAIL monkey/lexer 0.008s
问题是我们想要的下一个token:在其文字字段中带有"five"的IDENT标记。相反,我们得到了一个ILLEGAL token。这是为什么?因为"let"和"five"之间的空格字符。但是在Monkey中,空格只是作为标记的分隔符并没有意义,所以我们需要完全跳过它。
// lexer/lexer.go
func (l *Lexer) NextToken() token.Token {
var tok token.Token
l.skipWhitespace()
switch l.ch {
//[...]
}
func (l *Lexer) skipWhitespace() {
for l.ch == ' ' || l.ch == '\t' || l.ch == '\n' || l.ch == '\r' {
l.readChar()
}
}
在很多解析器中都可以找到这个小辅助函数。有时它被称为eatWhitespace
,有时被称为consumeWhitespace
并且有时候则是完全不同的东西。这些函数实际上跳过哪些字符取决于被词法分析的语言。例如,某些语言实现确实会为换行符创造token,如果它们不在token流中的正确位置,则会引发解析错误。我们跳过换行符以使稍后的解析步骤更容易一些。
使用skipWhitespace()在对应的位置,词法分析器将遍历let five = 5;
,部分测试输入中的5。没错,它还不知道如何将数字转换为token。是时候补充一下了。
当我们之前为标识符所作的那样,我们现在需要向switch语句的默认分支添加更多功能。
//lexer/lexer.go
func (l *Lexer) NextToken() token.Token {
var tok token.Token
l.skipWhitespace()
switch l.ch {
// [...]
default:
if isLetter(l.ch) {
tok.Literal = l.readIdentifier()
tok.Type = token.LookupIdent(tok.Literal)
return tok
} else if isDigit(l.ch) {
tok.Type = token.INT
tok.Literal = l.readNumber()
return tok
} else {
tok = newToken(token.ILLEGAL, l.ch)
}
}
// [...]
}
func (l *Lexer) readNumber() string {
position := l.position
for isDigit(l.ch) {
l.readChar()
}
return l.input[position:l.position]
}
func isDigit(ch byte) bool {
return '0' <= ch && ch <= '9'
}
正如你们所看到的,添加的代码与读取标识符和关键字相关的部分非常相似,readNumber方法与readIdentifier完全相同,只是它使用了isDigit而不是isLetter。我们可能可以通过将字符识别函数作为参数传递来概括这一点,但为了简单起见,和易于理解,我们不会这样做。
isDigit函数和isLetter简单,它只是返回传入的字节是否是介于0和9之间的拉丁数字。
添加此内容后,我们的测试通过:
$ go test ./lexer
ok monkey/lexer 0.008s
我不知道是否你注意到了,但是我们简化了许多在readNumber的东西。我们仅仅读取整型,那浮点型呢?或者十六进制的数字?八进制表示?我们忽略了他们,只Monkey不支持这些。当然,这也是本书的的教育目标和有限的范围。是时候弹出香槟并且庆祝:我们成功地将测试用例中使用的Monkey语言的一小部分变成了tokens。
有了这次胜利,我们很容易拓展词法分析器,这样它就可以标记更多的Monkey源代码。
< 1.2定义我们的Tokens | > 1.4拓展我们的Token集和词法分析器 |
---|