- 用Go語言自制解釋器
- (德)索斯藤·鮑爾
- 3533字
- 2022-06-17 10:50:31
1.3 詞法分析器
在開始編寫代碼之前,先了解本節的目標。在本節中,我們將編寫詞法分析器。詞法分析器將源代碼作為輸入,并輸出對應的詞法單元。詞法分析器會遍歷輸入的字符,然后逐個輸出識別出的詞法單元。這個過程既無須用到緩沖區,也無須保存詞法單元,只會用到一個名為NextToken()
的方法來輸出下一個詞法單元。
也就是說,詞法分析器在接收源代碼之后,會在其中重復調用NextToken()
,逐個字符遍歷源代碼來生成詞法單元。這里的源代碼還是使用字符串,因此可以省去不少處理工作。再次提醒,在生產環境中,應該將文件名和行號附加到詞法單元中,以便更好地跟蹤可能出現的詞法分析錯誤和語法分析錯誤。在這種情況下,最好使用io.Reader
加上文件名來初始化詞法分析器。但因為這樣做會增加復雜性,所以這里從簡單處著手,僅使用字符串作為輸入,忽略文件名和行號。
經過這些分析,現在詞法分析器的任務就很清楚了。接下來創建一個新語言包并添加第一個測試。測試可以重復運行,以獲取詞法分析器當前的工作狀態信息。這里依然從簡單處著手,后面隨著詞法分析器功能的完善,測試用例也會隨之擴展:
// 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 failed]
首先,定義New()
函數,用來返回*Lexer
。
// lexer/lexer.go
package lexer
type Lexer struct {
input string
position int // 所輸入字符串中的當前位置(指向當前字符)
readPosition int // 所輸入字符串中的當前讀取位置(指向當前字符之后的一個字符)
ch byte // 當前正在查看的字符
}
func New(input string) *Lexer {
l := &Lexer{input: input}
return l
}
Lexer
中的大多數字段很容易理解,但position
和readPosition
的含義可能讓人困惑。兩者都可以用作索引來訪問input
中的字符,例如l.input[l.readPosition]
。這里之所以用兩個“指針”來指向所輸入的字符串,是因為詞法分析器除了查看當前字符,還需要進一步“查看”字符串,即查看字符串中的下一個字符。readPosition
始終指向所輸入字符串中的“下一個”字符,position
則指向所輸入字符串中與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
的目的是讀取input
中的下一個字符,并前移其在input
中的位置。這個過程的第一件事就是檢查是否已經到達input
的末尾。如果是,則將l.ch
設置為0
,這是NUL
字符的ASCII編碼,用來表示“尚未讀取任何內容”或“文件結尾”。如果還沒有到達input
的末尾,則將l.ch
設置為下一個字符,即l.input[l.readPosition]
指向的字符。
之后,將l.position
更新為剛用過的l.readPosition
,然后將l.readPosition
加1
。這樣一來,l.readPosition
就始終指向下一個將讀取的字符位置,而l.position
始終指向剛剛讀取的位置。這個特性很快就會派上用場。
在談到readChar
時,值得指出的是,該詞法分析器僅支持ASCII字符,不能支持所有的Unicode字符。這么做也是為了讓事情保持簡單,讓我們能夠專注于解釋器的基礎部分。如果要完全支持Unicode和UTF-8,就要將l.ch
的類型從byte
改為rune
,同時還要修改讀取下一個字符的方式。因為字符此時可能為多字節,所以l.input[l.readPosition]
將無法工作。除此之外,還需要修改其他一些后面會介紹的方法和函數。這里將在Monkey中全面支持Unicode和表情符號作為練習留給讀者來實現。
在New()
函數中使用readChar
,初始化l.ch
、l.position
和l.readPosition
,以便在調用NextToken()
之前讓*Lexer
完全就緒:
// lexer/lexer.go
func New(input string) *Lexer {
l := &Lexer{input: input}
l.readChar()
return l
}
此時運行測試會發現,調用New(input)
后一切正常,但現在還缺少NextToken()
方法。下面就通過添加第一版的NextToken()
來解決這個問題:
// lexer/lexer.go
package lexer
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)}
}
這就是NextToken()
方法的基本結構。它首先檢查了當前正在查看的字符l.ch
,根據具體的字符來返回對應的詞法單元。在返回詞法單元之前,位于所輸入字符串中的指針會前移,所以之后再次調用NextToken()
時,l.ch
字段就已經更新過了。最后,名為newToken
的小型函數可以幫助初始化這些詞法單元。
運行測試,可以看到測試通過:
$ 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, ""},
}
// [...]
}
注意,現在測試用例中的input
發生了變化,看起來像是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.ILLEGAL
的詞法單元。
isLetter
輔助函數用來判斷給定的參數是否為字母。值得注意的是,這個函數雖然看起來簡短,但意義重大,其決定了解釋器所能處理的語言形式。比如示例中包含ch =='_'
,這意味著下劃線_
會被視為字母,允許在標識符和關鍵字中使用。因此可以使用諸如foo_bar
之類的變量名。其他編程語言甚至允許在標識符中使用問號和感嘆號。如果讀者也想這么做,那么可以修改這個isLetter
函數。
readIdentifier()
函數顧名思義,就是讀入一個標識符并前移詞法分析器的掃描位置,直到遇見非字母字符。
在switch
語句的default
分支中,使用readIdentifier()
設置了當前詞法單元的Literal
字段,但Type
還沒有處理。現在let
、fn
或foobar
之類的標識符已經被讀取,還需要將語言的關鍵字和用戶定義標識符區分開來,因此需要一個函數來為現有的詞法單元字面量返回正確的TokenType
。添加這個函數最合適的地方是在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()
tok.Type = token.LookupIdent(tok.Literal)
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
問題出在下一個詞法單元上:原本預期是IDENT
詞法單元,其Literal
字段中是five
,而這里得到的是ILLEGAL
詞法單元。出現這種情況是因為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
,還有時是完全不同的名稱。這個函數實際跳過的字符根據具體分析的語言會有所不同。例如在某些語言的實現中,會為換行符創建詞法單元,如果它們不在詞法單元流中的正確位置,就會拋出解析錯誤。不過這里沒有處理換行符,是為了簡化后面的語法分析步驟。
添加了skipWhitespace()
后,詞法分析器會停在測試代碼中let five = 5;
的5
這里。是的,詞法分析器還不能將數字轉換為詞法單元。現在來添加這個功能。
就像之前處理標識符那樣,現在需要在switch
語句的default
分支中添加更多功能:
// 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
。當然,也可以創建一個characteridentifying
函數同時處理這兩種情況,但為了簡潔和易于理解,這里還是分開處理。
isDigit
函數與isLetter
一樣簡單,只是判斷傳入的內容是否為Latin字符集中0
和9
之間的數字。
添加完成后,測試就能通過了:
$ go test ./lexer
ok monkey/lexer 0.008s
你是否注意到,readNumber
中簡化了很多處理?它只能讀取整數,忽略了浮點數、十六進制數、八進制數,這也意味著 Monkey 語言不支持這些特性。當然,這樣做的原因還是出于教學目的,所以限定了介紹內容的范圍。
現在可以慶祝了,我們成功地將測試用例中的一段 Monkey 語言代碼轉換成了詞法單元!
有了這次勝利,就能很容易地擴展詞法分析器,來解析更多的 Monkey 源代碼。
- 編程卓越之道(卷3):軟件工程化
- Python程序設計(第3版)
- Python從菜鳥到高手(第2版)
- Vue.js 3.x從入門到精通(視頻教學版)
- Rake Task Management Essentials
- Selenium Design Patterns and Best Practices
- Hands-On JavaScript High Performance
- Android底層接口與驅動開發技術詳解
- 深入理解Elasticsearch(原書第3版)
- AppInventor實踐教程:Android智能應用開發前傳
- PHP+Ajax+jQuery網站開發項目式教程
- 響應式Web設計:HTML5和CSS3實戰(第2版)
- Principles of Strategic Data Science
- 奔跑吧 Linux內核
- 金融商業數據分析:基于Python和SAS