Zig 詞法分析器
原文由 Mitchell Hashimoto 于 發布,訂閱此部落格
本文是Zig 編譯器內部機制系列的一部分。
詞法分析是典型編譯器處理流程的第一步。詞法分析是將位元組流(程式語言的語法)轉換為詞元流的過程。
以 Zig 為例,語法 comptime {} 會被詞法分析為 [.keyword_comptime, .l_brace, .r_brace]。詞法分析器通常不會處理語意,也不會判斷一組詞元是否有意義。例如,comptime [] 並不是有意義的 Zig 語法,但詞法分析器仍會很樂意地將它轉換為 [.keyword_comptime, .l_bracket, .r_bracket]。要賦予語意並在無效的情況下報錯,則是剖析器(詞法分析之後的下一個步驟)的工作。
撰寫詞法分析器的方法有很多。本文將聚焦於 Zig 的詞法分析器如何運作,並不打算將其與其他撰寫方式進行比較。
Zig 詞法分析器
Zig 語言的詞法分析器是 Zig 標準函式庫的一部分,位於 lib/std/zig/tokenizer.zig,並以 std.zig.Tokenizer 的形式對外提供。該詞法分析器以位元組切片作為輸入,一次產生一個詞元,直到 EOF 為止。詞法分析器不會進行任何記憶體配置。
基本用法如下所示,之後我們再深入了解其運作原理:
const std = @import("std");
const expect = std.testing.expect;
const tok = std.zig.Tokenizer.init("comptime {}");
try expect(tok.next() == .keyword_comptime);
try expect(tok.next() == .l_brace);
try expect(tok.next() == .r_brace);
try expect(tok.next() == .eof);詞法分析器有幾個值得一提的重要特性:
- 操作對象是切片,而非串流 - 詞法分析器接受的輸入是
[:0] const u8,而不是 reader。也就是說,對於足夠大的輸入,要由呼叫端自行負責緩衝並分批呼叫詞法分析器。實務上,程式語言的輸入通常不會「大到需要這麼做」,因此會一次對整個原始碼檔案進行詞法分析;Zig 目前也是這樣運作的。 - 不會進行配置 - 詞法分析器不會進行任何堆積(heap)配置。在系統程式設計中,了解一個 API 的記憶體使用與配置特性總是很有用的。附帶一提:在 Zig 中,這一點一目了然,因為 Tokenizer 在任何地方都沒有將
Allocator作為參數。
詞法分析器的結構同樣簡單(如以下所示)。詞法分析器儲存了緩衝區、指向緩衝區的目前索引(從零開始),以及一個可能出現的無效詞元(本文將忽略此部分)。
pub const Tokenizer = struct {
buffer: [:0]const u8,
index: usize,
pending_invalid_token: ?Token,
// ...
};即使你對詞法分析器一無所知,應該也能從這個狀態看出它是如何運作的。詞法分析器會在緩衝區中一次向前移動一個位元組的索引,並建構出詞元。
詞元的結構
在了解了詞法分析器的高階 API 之後,下一個問題是:詞元的結構是什麼?在 Zig 中,詞元是如下的結構:
pub const Token = struct {
tag: Tag,
loc: Loc,
pub const Loc = struct {
start: usize,
end: usize,
};
pub const Tag = enum {
invalid,
// ...
};
};tag 欄位是一個列舉(enum),代表所有可能的詞元類型。標籤的例子包括 .keyword_comptime, .l_brace, .r_brace, .eof。在撰寫本文時,Zig 約有 120 種不同的標籤。
接著,詞元的位置儲存在 loc 欄位中,由 start 與 end 索引組成。有了這些索引和用於初始化詞法分析器的原始碼文字,呼叫端就能使用 source[tok.loc.start .. tok.loc.end] 擷取出詞元的文字。只儲存起點與終點是一種常見的效率技巧。
詞元中提供的資訊是很基本的——一個標籤、一個位置——但不同詞法分析器之間的結構可能會有所不同。例如,Go 詞法分析器將詞元建模為整數常數,而 Go 中相當於 next() 的函式會將詞元的整數值、作為整數位元組偏移量的位置,以及詞元的字面字串擷取結果,作為不同的回傳值來回傳,因為 Go 支援多重回傳值。這或多或少與 Zig 相同,但這些細微的差異確實會對編譯器的易用性與效能產生重要影響。
尋找下一個詞元
詞法分析器透過呼叫 next() 函式一次回傳一個詞元。
這個函式從緩衝區中目前的索引開始,一次查看一個位元組來建構詞元。它使用單一的 state 變數來實作狀態機,以追蹤接下來可能出現的內容。
在深入 Zig 詞法分析器的實際狀態之前,讓我們先從高層次來看看這種做法。來看看輸入 while(包含空格)會如何被詞法分析。
首先,詞法分析器會看到字元 w。在那個當下,我們知道它不可能是數字,但它仍可能是關鍵字或識別字,所以我們還沒有得到一個完整的詞元。我們一次只看一個字元,所以接著抓取下一個:h。這仍然可能是識別字或關鍵字,所以我們繼續。i、l、e。現在,我們得到了 while,我們知道它單獨來看是一個關鍵字,但它仍然可能是識別字,因為後面可能還有更多字元,所以我們必須再往前多看一個字元。下一個輸入是空白字元。現在我們就能確定它是 while 關鍵字,而不是識別字,並可以回傳一個詞元。如果下一個位元組是像 1 這樣的字元,我們就會知道我們正在建構的是一個識別字(尚未完成),而且它永遠不可能是關鍵字(沒有任何關鍵字是以 while1 開頭的)。
這就是 Zig 詞法分析器的運作方式。它維持一個目前的狀態(例如「我們知道自己正在建構識別字或關鍵字」),並一次查看一個字元,直到能明確地判斷出詞元為止。
詞法分析器只會查看狀態與目前的字元;它不會往回看,也不會向前偷看。大多數詞法分析器都是如此。如開頭所述,詞法分析器不關心語意,因此像 if while comptime x = 7 { else } 這樣不合理的輸入,仍會產生一串有效的詞元。要將一串詞元賦予語意,則是剖析器的工作。
Zig 的實作
next() 的實作是透過巢狀的 while 與 switch 來完成的。下方顯示了一段程式碼片段。
while (true) : (self.index += 1) {
const c = self.buffer[self.index];
switch (state) {
.start => switch (c) {
'a'...'z', 'A'...'Z', '_' => {
state = .identifier;
result.tag = .identifier;
},
}
}
}第一個 while 是一個會持續執行的無窮迴圈,一次一個字元地遍歷緩衝區。預期迴圈本體會在找到詞元或緩衝區為空時 break。Zig 的一個優點是,buffer 的型別是 [:0] const u8,而其中的 :0 告訴我們緩衝區保證會以一個 0 位元組結尾,因此我們可以透過尋找它來跳出迴圈。
接著,詞法分析器會對目前的 state(一個列舉)進行 switch。然後,在給定狀態下,再對目前的字元進行 switch,以決定下一步要做什麼。在上面的程式碼片段中,你可以看到當看到像 A 這樣的第一個字元時,會如何將狀態轉為 .identifier 來建構識別字。
從詞元到語法樹
編譯器的下一個階段是剖析器。剖析器會接收由詞法分析器產生的詞元流,並將其轉換為更具意義的抽象語法樹。請繼續閱讀 Zig 剖析器的相關內容。
隨機一篇部落格
留言
登入後參與討論