TT Lab
开始
学习 学习路径 课程

编译器 — 从头到尾亲手打造一门小语言

做一个带错误恢复的 Pratt 解析器

在 TT Lab 中继续学习

目标

做出一个语法分析器,把词法单元列表搭成 AST(/root/mini/ast_nodes.py 中的节点)。表达式用 Pratt 方式,通过一张表处理优先级和结合方向;语句用递归下降来读;出错时记下错误,并从下一条语句重新开始读。

为什么重要

优先级或结合方向只要有一处出错,程序就会不报错地计算出另一个值。如果把 10 - 4 - 3 从右边开始结合,结果不是 3 而是 9,而且没有任何警告。另外,在第一个错误处就停下的语法分析器,会让用户有多少个错误就得修改、重新运行多少次。

语法与树

program     := declaration* EOF
declaration := "let" IDENT "=" expr ";"  |  "fn" IDENT "(" [IDENT ("," IDENT)*] ")" block  |  statement
statement   := "print" expr ";" | "if" "(" expr ")" block ["else" (if문 | block)]
             | "while" "(" expr ")" block | "return" [expr] ";" | block | expr ";"
block       := "{" declaration* "}"
결합력      = 1(오른쪽) · || 2 · && 3 · == != 4 · < <= > >= 5 · + - 6 · * / % 7
             앞 - ! 는 피연산자를 8 로 · ^ 9(오른쪽) · 호출 ( 10
노드 위치   ast_nodes.py 맨 위 표 — 연산자 토큰, 호출은 여는 괄호, Let·Fn 은 이름 토큰, 문장은 키워드,
             Block 은 여는 중괄호, ExprStmt 는 식의 위치. && 와 || 는 Logical, 나머지 두 항은 Binary
오류 글자   ParseError(토큰, 메시지), str() 은 "줄:칸: 메시지". 위치는 기대한 것을 찾지 못한 그 토큰
             expected expression · expected ';' · expected ')' · expected '(' · expected '{' ·
             expected '}' · expected identifier · expected '=' · invalid assignment target(그 = 의 위치)
복구        declaration 이 ParseError 를 잡아 self.errors 에 글자를 적고 synchronize() 한 뒤 None.
             synchronize: 지금 토큰이 '}' 가 아닌 STARTERS(fn let if while print return { }) 면 그대로
             멈춘다. 아니면 한 토큰을 소비하고, 끝이 아닌 동안 ';' 바로 뒤이거나 STARTERS 앞이면 멈춘다.

步骤

  1. 补全 /root/mini/parser.py 的 ParseError、Parser(tokens)(属性有 tokens、pos 和 errors)以及 peek、previous、at_end、advance(在 EOF 处原地不动)、check、match(*kinds) 和 expect(kind, message)。
  2. 补全 prefix()——INT、true/false、IDENT 和用括号括起来的表达式。- 和 ! 交给 prefix_operator()(第 4 步),其他词法单元则在其位置报 expected expression。
  3. 补全 POWER 表、expression(rbp=0) 和 infix(left)——二元运算的优先级与左结合。(、^ 和 = 交给 finish_call 和 right_assoc。
  4. 补全 UNARY、prefix_operator() 和 right_assoc(left)——前缀运算符、^ 和 = 的右结合,以及赋值目标不是名字时的 invalid assignment target。
  5. 补全 finish_call(callee)——从没有参数到多个参数,以及 f(x)(y) 这样的连续调用。
  6. 补全 declaration、let_declaration、fn_declaration、statement、if_statement、block、program,以及模块级的 parse_program(src)。它返回 (Program, 오류 목록)(占位符为错误列表);如果出现词法分析器的错误,列表里只放那一条,树为 None。
  7. 补全 STARTERS 和 synchronize()。评分器会把错误程序的错误列表和挽救出来的树与参考对照。
  8. 评分器会把 120 个随机程序的树,以及 240 个破坏了一个词法单元的程序的错误列表,与参考对照。

参考

查看、消耗并期望词法单元

用一个 pos 指向词法单元列表。advance 返回当前词法单元,并且只有当它不是 EOF 时才增加 pos。expect 在种类相符时 advance,否则就在当前词法单元(peek)的位置抛出 ParseError——不是刚刚经过的那个词法单元。

表达式的第一块

按词法单元的种类区分。INT 用 int(text),true/false 是 Bool,IDENT 是 Var,节点位置是该词法单元的行和列。括号把内部用 expression() 读出来并期望 ')',但不另外创建括号节点,而是原样返回内部的表达式。

用结合力表解决优先级

while POWER.get(peek().kind, 0) > rbp: left = infix(left)。infix 消耗运算符,并把右侧读作 expression(该运算符的结合力)。同一层的运算符结合力相同,越不过 >,所以回到外层循环,成为左结合。

前缀运算符与右结合

前缀运算符把操作数读作 expression(UNARY)。UNARY 必须比 * 大、比 ^ 小,-a*b 才是 (-a)*b,-a^b 才是 -(a^b)。^ 和 = 把右侧读作结合力 - 1,让右侧一直吞到同一层。如果 = 的左边不是 Var,就在那个 = 的位置报错。

调用——结合得最紧

把 ( 也看作结合力为 10 的后缀运算符。finish_call 消耗左括号,如果不是 ')',就读 expression(),并在有 ',' 时重复,最后期望 ')'。Call 节点的位置是左括号。

语句与块——递归下降

declaration 区分 let 和 fn,statement 区分 print、if、while、return、块和表达式语句。else 后面如果是 if,就再次调用 if_statement 来构成链。declaration 捕获 ParseError,记下之后 synchronize,再返回 None;block 和 program 会丢弃 None。

出错之后重新站起来

如果出错的词法单元是不为 '}' 的 STARTERS,就不消耗,直接停下——以该关键字开头的语句会先吃掉关键字,所以不会原地打转。否则至少消耗一个词法单元,然后在 previous() 是 ';' 或 peek() 是 STARTERS 时停下。

随机程序与被破坏的程序

没有新代码。被破坏的程序里,如果错误比参考多,说明恢复之后在莫名其妙的地方重新开始读(连锁错误);如果比参考少,说明在一个错误之后跳得太远。