Zig 词法分析器
原文由 Mitchell Hashimoto 于 发布,订阅该博客
本文是Zig 编译器内部原理系列的一部分。
词法分析是典型编译器流水线的第一步。词法分析是将字节流(即编程语言的语法)转换为 Token 流的过程。
以 Zig 为例,语法 comptime {} 会被分词为 [.keyword_comptime, .l_brace, .r_brace]。词法分析器通常不处理语义,也不判断一组 Token 是否有意义。例如,comptime [] 并不是有意义的 Zig 语法,但词法分析器仍会将其转换为 [.keyword_comptime, .l_bracket, .r_bracket]。而为 Token 流赋予语义并在无效情况下报错,则是解析器(词法分析之后的下一步)的职责。
编写词法分析器的方法有很多。本文将聚焦于 Zig 的词法分析器是如何工作的,并不打算将其与其他实现方式进行对比。
Zig 词法分析器
Zig 语言的词法分析器是 Zig 标准库的一部分,位于 lib/std/zig/tokenizer.zig,对外暴露为 std.zig.Tokenizer。该词法分析器以字节切片作为输入,每次产生一个 Token,直至遇到 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 目前也是这样做的。 - 不分配内存 - 词法分析器不会进行任何堆分配。在系统编程中,了解一个 API 的内存使用和分配特性总是很有用的。顺带一提,在 Zig 中这一点一目了然,因为 Tokenizer 的任何参数中都不会接收
Allocator。
词法分析器的结构同样简单(如下所示)。它存储了缓冲区、指向缓冲区的当前索引(从零开始),以及一个可能存在的无效 Token(本文将忽略这一项)。
pub const Tokenizer = struct {
buffer: [:0]const u8,
index: usize,
pending_invalid_token: ?Token,
// ...
};即使你对词法分析器一无所知,也应该能从这个状态看出它是如何工作的。词法分析器在缓冲区中每次向前移动一个字节的索引,并据此构造出 Token。
Token 的结构
在了解了词法分析器的上层 API 之后,下一个问题是:Token 的结构是怎样的?在 Zig 中,Token 是如下的结构体:
pub const Token = struct {
tag: Tag,
loc: Loc,
pub const Loc = struct {
start: usize,
end: usize,
};
pub const Tag = enum {
invalid,
// ...
};
};tag 字段是一个枚举,代表所有可能的 Token 类型。Tag 的例子有 .keyword_comptime, .l_brace, .r_brace, .eof。在撰写本文时,Zig 大约有 120 种不同的 tag。
接着,Token 的位置信息存储在 loc 字段中,它由 start 和 end 两个索引组成。结合这些索引和初始化词法分析器时使用的原始源文本,调用方可以通过 source[tok.loc.start .. tok.loc.end] 来提取 Token 的文本。只存储起始和结束位置是一种常见的优化技巧。
Token 中包含的信息是非常基础的——一个 tag、一个位置——但不同词法分析器之间的结构可能有所不同。例如,Go 词法分析器将 Token 建模为整型常量,并且 Go 中与 next() 等价的函数会将 Token 整型值、作为整型字节偏移的位置,以及 Token 的字面量字符串分别作为独立的返回值返回,因为 Go 支持多返回值。这与 Zig 大同小异,但这些细微的差别确实会对编译器的易用性乃至性能产生重要影响。
寻找下一个 Token
词法分析器通过调用 next() 函数一次返回一个 Token。
该函数从缓冲区中的当前索引开始,一次查看一个字节来构造 Token。它使用单个 state 变量来实现状态机,以追踪接下来可能出现的内容。
在深入 Zig 词法分析器的具体状态之前,我们先从宏观上看看这种做法。来看看输入 while(包含后面的空格)是如何被分词的。
首先,词法分析器会看到字符 w。此时,我们知道它不可能是数字,但仍可能是关键字或标识符,因此还没有得到一个完整的 Token。我们一次只看一个字符,于是接着读取下一个:h。它依然可能是标识符或关键字,所以继续处理。i、l、e。现在,我们得到了 while,它本身确实是一个关键字,但它仍然可能是标识符,因为后面可能还有更多字符,所以必须再向前多看一个字符。下一个输入是空白字符。此时我们才能确定它就是 while 关键字,而非标识符,并可以返回一个 Token。如果下一个字节是像 1 这样的字符,我们就会知道正在构造的是一个标识符(尚未完成),而且它永远不可能是关键字(没有以 while1 开头的关键字)。
Zig 词法分析器的工作方式正是如此。它维护一个当前状态(例如“我们知道正在构建的是标识符或关键字”),并一次查看一个字符,直到能够明确地确定 Token。
词法分析器只关注当前状态和当前字符;它不会回看,也不会向前窥视。大多数词法分析器都是如此。如前所述,词法分析器不关心语义,因此像 if while comptime x = 7 { else } 这样无意义的输入也会产生一串合法的 Token。将 Token 流赋予语义是解析器的职责。
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 是一个无限循环,会一直持续下去,每次在缓冲区中迭代一个字符。循环体预期在找到一个 Token 或缓冲区为空时 break。Zig 的一个很好的特性是,buffer 的类型是 [:0] const u8,其中的 :0 告诉我们缓冲区末尾一定有一个 0 字节,因此可以通过检查它来跳出循环。
接着,词法分析器会根据当前的 state(一个枚举)进行 switch。然后,在给定状态下,再根据当前字符进行 switch 来决定下一步做什么。在上面的片段中,可以看到当看到像 A 这样的首字符时,状态会变为 .identifier 以开始构建标识符。
从 Token 到语法树
编译器的下一阶段是解析器。解析器接收由词法分析器生成的 Token 流,并将其转换为更具意义的抽象语法树。请继续阅读关于Zig 解析器的内容。
随机一篇博客
评论
登录后参与讨论