TT Lab
はじめる
学ぶ 学習パス コース

コンパイラ — 小さな言語を最初から最後まで作る

Pratt パーサーとエラー回復を作る

TT Labで続きを見る

目標

トークンの一覧をAST(/root/mini/ast_nodes.pyのノード)に組み立てるパーサーを作ります。式はPratt方式で、優先順位と結合の方向を表1つで扱い、文は再帰下降で読み、エラーが起きたら控えて、次の文から読み直します。

なぜ重要なのか

優先順位や結合の方向が1つでもずれると、プログラムはエラーなしに別の値を計算します。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, 오류 목록)で、プレースホルダーはエラー一覧です。レキサーのエラーはその1つだけを入れ、木はNoneにします。
  7. STARTERSとsynchronize()を埋めます。採点ツールが、エラープログラムのエラー一覧と、救い出した木を基準と突き合わせます。
  8. 採点ツールが、ランダムなプログラム120個の木と、トークンを1つ壊した240個のエラー一覧を、基準と突き合わせます。

参考

トークンを見て、消費し、期待する

pos1つでトークンの一覧を指します。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がより大きく、^より小さくて初めて、-abは(-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なら、消費せずに止まります。そのキーワードで始まる文はキーワードから消費するので、その場で回り続けることはありません。そうでなければ少なくとも1トークンを消費し、previous()が';'であるか、peek()がSTARTERSなら止まります。

ランダムなプログラムと壊したプログラム

新しいコードはありません。壊したプログラムでエラーが基準より多いなら、回復のあとに見当違いの場所から読み直している(連鎖エラー)のであり、少ないなら、1つのエラーのあとで遠くまで読み飛ばしています。