从零编写现代编译器 03 - 语句解析:let、赋值和分号
上一篇把源码拆成了 Token 序列。一行 let mut x: i64 = 0; 经过词法分析后变成 Let、Mut、Ident("x")、Colon、Ident("i64")、Assign、Int(0)、Semi 这样一串扁平记号。但 Token 序列不包含结构信息——编译器还不知道这是一条变量声明、一条赋值还是一条返回语句。这一篇的任务是把 Token 流解析成结构化的语句节点:let 声明、赋值、return 和块。
语句的文法
先用一组产生式把 Sprout 当前支持的语句写下来。这里只处理语句层面,表达式的优先级和结合性留给第 04 篇。
1 | |
几个要点。第一,每种语句都以分号结尾,这是语法层面的硬性要求,不是可选项。第二,let_stmt 和 assign_stmt 都以标识符出现,区分它们靠的是有没有前导关键字 let。第三,block 本身不以分号结尾——右花括号已经是明确的终止标记。第四,类型注解暂时只有 i64 和 bool 两种,后续篇幅会逐步扩展。
这套文法属于 LL(1) 的子集:看当前 Token 就能决定走哪条规则。let 开头走 let_stmt,return 开头走 return_stmt,标识符开头且下一个 Token 是 = 走 assign_stmt,其他情况走 expr_stmt。不需要回溯。
递归下降:每条规则变成一个函数
递归下降解析(recursive descent parsing)的核心思想很直接:文法里每条产生式对应解析器里的一个函数。函数内部按产生式右侧的顺序依次消费 Token。遇到终结符就检查当前 Token 是否匹配并推进;遇到非终结符就调用对应的函数。
解析器需要维护的状态很少:一个 Token 列表和一个指向当前位置的游标。核心操作有三个:
peek():查看当前 Token 但不消费。advance():消费当前 Token 并把游标前移。expect(kind):检查当前 Token 是否为指定类型,如果是则消费,否则报错。
1 | |
expect 是整个解析器中出现频率最高的操作。后面每一步"消费冒号"“消费等号”"消费分号"都通过它完成。当实际 Token 和预期不符,expect 直接携带源码位置生成错误——这比"解析失败"四个字有用得多。
解析 let 语句
let_stmt 的产生式是 "let" ["mut"] IDENT ":" type "=" expr ";"。翻译成代码,就是按顺序消费每个成分:
1 | |
注意 mut 是可选的。解析器通过 peek 检查下一个 Token 是不是 Mut,如果是就消费掉并记录为可变;如果不是就跳过,不报错。这是方括号 ["mut"] 在代码中的直接对应。
类型注解紧跟在冒号后面。parse_type 函数目前只接受两种类型名:
1 | |
这里 i64 和 bool 暂时当作特殊的标识符来匹配。等类型系统变复杂后会引入专门的类型解析逻辑,但起步阶段没必要过早设计。
解析赋值语句
赋值语句的结构比 let 简单:没有关键字前缀,没有类型注解。
1 | |
赋值和 let 的区分在调用方完成。parse_stmt 先看当前 Token:如果是 Let 就进入 parse_let_stmt;如果是 Ident 且下一个 Token 是 Eq,就进入 parse_assign_stmt。这个两步前瞻的代价很小,换来的是解析逻辑的清晰分离。
1 | |
这里有一处值得注意的设计选择:赋值的左侧目前只允许简单标识符。a[0] = 1 或 s.field = 2 这类复合左值留到引入数组和结构体时再处理。起步阶段限制越紧,错误信息越容易写对。
解析 return 语句
return 是三种语句中最规整的——固定前缀、一个表达式、一个分号:
1 | |
当前要求 return 后面必须跟表达式。对于不返回值的函数(返回类型为 unit),后续可以允许单独的 return;,但在只有 i64 和 bool 的阶段没有必要。
块:语句的容器
块(block)是用花括号包裹的语句序列。函数体是一个块,后续的 if 分支和 while 循环体也是块。
1 | |
循环条件是"当前 Token 不是右花括号就继续解析语句"。这意味着空块 {} 是合法的——循环一次都不执行,直接消费 }。
块内的每条语句通过 parse_stmt 解析,而 parse_stmt 内部可能再次调用 parse_block(比如后续加入 if 和 while 时)。这就是"递归"下降的由来:解析函数互相调用形成递归结构,天然映射了语法树的嵌套层次。
块本身不需要尾部分号。{ return 42; } 最后一个 Token 是 },不需要在后面再加 ;。这是因为花括号本身就是明确的定界符,解析器看到 } 就知道块结束了,不需要分号来做额外的终止标记。这和语句不同——语句在 Token 流中没有明确的结束标记,分号充当了这个角色。
分号为什么不可省略
有些语言允许省略分号(比如 Go 用换行代替,JavaScript 有自动分号插入)。Sprout 选择要求显式分号,原因很实际:省略分号需要复杂的启发式规则,而这些规则经常制造令人困惑的错误。JavaScript 的 ASI(Automatic Semicolon Insertion)规则就是反面教材——一个 return 后面换行,返回值变成了 undefined,因为分号被自动插入到了 return 后面。
对编译器实现来说,显式分号也简化了解析器的错误恢复。当解析器在某处遇到不期望的 Token 时,它可以跳到下一个分号处重新开始解析新的语句。分号是天然的同步点。如果分号是可选的,这个同步策略就不可靠了。
AST 节点类型
所有语句节点的 Rust 定义汇总如下:
1 | |
每个节点都带 Span,这是贯穿整个编译器前端的设计原则。从词法分析到语法分析再到语义分析,每一层的数据结构都携带源码位置。这样无论在哪个阶段发现错误,都能指出它在源文件中的准确位置。这个成本——每个节点多八到十六字节——和它带来的诊断质量相比完全值得。
Statement 用枚举而不是 trait object,因为语句的种类在编译期就完全确定了。枚举在 Rust 中可以进行穷举模式匹配,编译器帮你检查是否遗漏了分支。每次新增一种语句类型,所有 match 语句上的漏洞会被编译器逐个揪出来——这种保障在编译器开发中极其有价值。ExprStmt 的存在是因为表达式本身也能充当语句,比如一个函数调用 print_i64(42);。它的返回值被丢弃,执行只为副作用。
AST 节点定义
前面的代码片段已经用到了 Statement、LetStmt 等类型。完整的 AST 定义如下:
1 | |
每个节点都携带 Span——源码中的起止位置。上一篇建立的源码映射在这里开始发挥作用:后续类型检查或代码生成报错时,可以沿着 Span 找到对应的源码行列,而不是给出一个没有上下文的"语法错误"。
Expr 类型在本篇暂时作为占位,第 04 篇会完整定义表达式解析和优先级爬升。现阶段可以先支持整数字面量和标识符两种简单表达式,足够演示语句解析的完整流程。
分号与错误报告
分号在 Sprout 中不是装饰,而是语法分隔符。每条语句以 ; 结尾,解析器通过 expect(TokenKind::Semi) 强制检查。
少写一个分号会怎样?假设输入是:
1 | |
解析器消费完表达式 1 之后调用 expect(Semi),此时当前 Token 是 Ident("x")。expect 报告:
1 | |
错误位置指向第一行末尾。因为 Token 自带 Span,所以"在哪里缺了分号"不需要猜。
再看一个缺少类型注解的例子:
1 | |
解析器消费完标识符 x 后调用 expect(Colon),当前 Token 是 Assign。报告:
1 | |
还有缺少等号的情况:
1 | |
消费完类型 i64 后 expect(Assign) 发现当前是 Int(1):
1 | |
这三个例子展示了同一个机制:expect 在每个位置检查预期的 Token,不符就立刻报错并携带源码位置。代码里不需要专门写三种错误处理逻辑——文法结构本身就决定了错误消息的内容。
手动解析一段程序
把下面三条语句当作输入,手动走一遍解析器的执行过程:
1 | |
词法分析后的 Token 序列(省略 Span):
1 | |
第一条语句。 parse_stmt 看到 Let,进入 parse_let_stmt。消费 Let。peek 看到 Mut,消费之,记录 mutable = true。expect(Ident) 消费 Ident("x"),记录 name = "x"。expect(Colon) 消费冒号。parse_type 看到 Ident("i64"),返回 Type::I64。expect(Assign) 消费等号。parse_expr 消费 Int(0),返回表达式节点。expect(Semi) 消费分号。得到 Statement::Let { mutable: true, name: "x", ty: I64, init: Lit(0) }。
第二条语句。 parse_stmt 看到 Ident("x"),前瞻发现下一个是 Assign,进入 parse_assign_stmt。消费 Ident("x"),expect(Assign) 消费等号。parse_expr 消费 Ident("x") Plus Int(1),返回二元运算节点。expect(Semi) 消费分号。得到 Statement::Assign { name: "x", value: BinOp(Add, Var("x"), Lit(1)) }。
第三条语句。 parse_stmt 看到 Return,进入 parse_return_stmt。消费 Return。parse_expr 消费 Ident("x"),返回变量引用节点。expect(Semi) 消费分号。得到 Statement::Return { value: Var("x") }。
三条语句组成一个块,对应的 AST 结构如下:
1 | |
练习
-
在纸上解析
let flag: bool = true; return flag;,画出完整的 AST 树。注意true在词法阶段应该产生什么 Token,parse_type在这里匹配的是哪个分支。 -
修改
parse_stmt使其支持while循环:while_stmt → "while" expr block。写出函数签名和主体逻辑,并解释为什么while的块不需要后续分号。 -
故意去掉
let x: i64 = 1;中的冒号,写成let x i64 = 1;。追踪parse_let_stmt的执行路径,确认expect(Colon)会在哪个 Token 上报错,错误消息应该包含什么信息。
