RustでOCamlのパーサーを書くために
25日目の記事です.
これは1日目の記事 シミュコンパイラ係はやめとけ - f03d-5bab's blog でちょっと言及した話で,先行研究:
の続き,というか1年後の記事です.コンパイラの初めの方の処理の理解が怪しい人は読んできてください.
読みましたか?なんかもう上の記事で結論が出されている感じですが,私の結論は違ったので書きます.
要旨
logos
も十分あり- 自由な型を入力できるようなparser generatorは多い
yacc
定義のPEG形式への翻訳は,precedenceは簡単でassociativityは(専用の機能がなければ)ちょっと難しいlalrpop
はconflictを解消できなくて大変
動機
今回書きたいparserはOCamlのサブセットの言語を入力とするものです.書きたいは嘘だな.もう書いたので書きたくはないです.
ここでは動機づけとしてCPU実験の話をします.1日目の記事で触れたように,CPU実験のコンパイラ係はOCamlのサブセットの言語を入力とするparserを書くことになり(書くことになるわけではなく,自分からこの道を選んでいます(為念)),←こういうネストしたコメントが残念ながらコードに登場してしまいます.どう考えてもネストさせる必要がないものが3箇所だけ*1.だから,ネストしたコメントに対応するparser(lexer)を作りたかったのでした.
実はこの動機づけには不足があり,というのも一体型(文字列から直接構文木を構成するようなもの)になっている多くのパーサジェネレータで,ネストしたコメントに対応するのは簡単だからです.多くのパーサジェネレータは生成規則自身を呼び出すことができ,ocamllex
と同じようにコールスタックで実現できます.peg
なら次のように書けばよいです.上2つのruleは関係ありません.
peg::parser!(grammar parser() for str { pub rule expr() = ident() _ "+" _ ident() rule ident() -> &'input str = $(quiet!{[c if c.is_ascii_alphabetic()][c if c.is_ascii_alphanumeric()]*}) / expected!("identifier") rule ws() = [' ' | '\t' | '\n']* rule _() = ws() ** ("(*" comment()) rule comment() = [^ '(' | '*']* ( "*)" / "(*" comment() comment() / [_] comment() ) });
"(*"
で再入して2回comment
を呼んでいるところもocamllex
による標準的な実装と同じですね.ここを1回にするとネストできない版になります.lexerを分離する場合と同じく空白を読み飛ばす部分にコメントを飛ばす処理が混ざってくるため少しだけ非自明な感じですが,C-styleのコメント/* ... */
と同じで難しくはないでしょう.(12/29追記:コメントが複数あるものを受け付けない実装になっていたのを修正.)
例の記事のデモも次のように書けます.型定義は例の記事と同じです.
エラー処理は省いていますが明らかな変更のみで対応できます.普段からエラー処理をさぼっているわけではありません.うそじゃないよ
peg::parser!(grammar demo() for str { rule num() -> i64 = s:$(['0'..='9']+) { s.parse().unwrap() } rule ident() -> &'input str = $(quiet!{[c if c.is_ascii_alphabetic()][c if c.is_ascii_alphanumeric()]*}) / expected!("identifier") rule ws() = [' ' | '\t' | '\n']* rule _() = ws() ** ("(*" comment()) rule comment() = [^ '(' | '*']* ( "*)" / "(*" comment() comment() / [_] comment() ) use BinOpKind::*; rule term_op() -> BinOpKind = "+" { Add } / "-" { Sub } rule factor_op() -> BinOpKind = "*" { Mul } / "/" { Div } rule atom() -> Box<Expr<'input>> = "(" _ e:term() _ ")" { e } / l:position!() _ x:num() _ r:position!() { Box::new(Spanned::new(ExprKind::Num(x), (l, r))) } / l:position!() _ i:ident() _ r:position!() { Box::new(Spanned::new(ExprKind::Var(i), (l, r))) } rule term() -> Box<Expr<'input>> = precedence!{ e1:(@) _ op:term_op() _ e2:@ { let l = e1.span.0; let r = e2.span.1; Box::new(Spanned::new(ExprKind::BinOp(op, e1, e2), (l, r))) } -- e1:(@) _ op:factor_op() _ e2:@ { let l = e1.span.0; let r = e2.span.1; Box::new(Spanned::new(ExprKind::BinOp(op, e1, e2), (l, r))) } -- e:atom() { e } } rule stmt() -> Stmt<'input> = l:position!() _ "let" !ident() _ x:ident() _ "=" _ t:term() _ ";" r:position!() { Spanned::new(StmtKind::Let(x, t), (l, r)) } / l:position!() _ "print" !ident() _ t:atom() _ ";" r:position!() { Spanned::new(StmtKind::Print(t), (l, r)) } pub rule program() -> Vec<Stmt<'input>> = stmt()* }); fn main() { demo::program( r#"(* x = 3 *) let (* x = 3 *) (* x = 3 *) x = 1 + 2; let y = x * 4; let z = x + y * x; (* 39 *) print (x + y + z); (* 54 *) let w = z / 3 + y; (* 計算の (* 結果は *) 0 になる *) let ans = x * (y - w) + y; print ans;"#, ) .unwrap(); }
空白を読み飛ばす部分が大量に出てきて鬱陶しいですね.peg
は_
, __
, ___
の名前がついたrule
を括弧なしで呼べるため薄目で見れば気にな…ります.仕方ない.
じゃあなんでまだパーサーの選定で悩むことがあるかというと,3Sの講義でLR構文解析のアルゴリズムとそれがそこそこ複雑であることを学び,失敗したらバックトラックをする(失敗する前にコピーをする)ような構文解析はLR文法であるところのOCamlに不向きかなと思ったのです.実装して比較する時間すら惜しかったため憶測でしかありません.同じLR法なら文法ファイルの移植も簡単だと踏んでいたという理由もあります(実際は全然そんなことありませんでした.上の例からわかるように,precedence指定されているものだけならPEGで容易に書き換えができ,combine
やpeg
はプログラミング言語で出てくるような文法をparseすることに特化した機能を持っているため,LR法に強いこだわりがなければ全然これらのほうが良いです).
というわけで,コンパイラとしてOCamlのサブセットの文法を入力とし,エラー出力のために位置情報を改行込みで認識したいという前提で,LR法にどれだけ準拠しているかと,nested commentを扱うために行う作業の量の観点から,rustで使えるlexer + parser各ライブラリの比較をします.例えば,入力がバイト列であったり,C-styleのコメントだけで十分だったりする状況なんかは考慮していません.
時間が取れなくて全部は試せていないもののテストやexampleは全部読んでいます.なお実際に実装で用いたのはplex
とlalrpop
の組み合わせです.
比較
crate name | version | type |
---|---|---|
plex |
0.2.5 |
lexer, parser |
logos |
0.12.1 |
lexer |
nom |
7.1.1 |
parser |
combine |
4.6.6 |
parser |
peg |
0.8.1 |
parser |
lalrpop |
0.19.8 |
parser |
この記事がmajor update後に読まれている気が全くしませんが,versionを固定しています.
コンパイラやアセンブラやlinterを作ろうと思った場合は特に,構文木の要素が入力のどの部分から来たのかという情報がほしいです.これを位置情報と呼ぶことにします.構文木の各要素の,対応する入力文字列におけるline numberとcolumn numberのことです.rustc
やclippy
のあの丁寧なエラー報告も,構文解析時の開始地点と終了地点それぞれのline numberとcolumn numberを全ての要素がずっと持ち続けているからexactな場所に波線を引けるんですね(波線を引いている人は位置情報を受け取ったannotate-snippets
とかrust-analyzer
だけど).今回は文字のbyte offsetだけだと困ります.人間には改行文字が特別な形で見えているため.後から改行文字を数えるのはなんか気に入らないので,それを踏まえるとplex
くらい自由度があるとよさそうです.
文法の記述をどこに行うかは意外と重要で,lexerについてはlogos
もplex
も.rs
に書けるので大差なく,parserについてはpeg
に軍配が上がります.人間よりIDEに考えてもらったほうが変なミスが少ないですからね.
lexerとparserで分けてはいますが,parserが正規表現に近いことしかしないなら機能としてはlexerなんですよね(ここでparserとして紹介しているものをlexerとして用いることができるということです.文字列からVec
などに入ったトークン列を構成する,.lex
と似たような非常に単純なparserを作る(ライブラリを使わないほうが簡単に書けそうだけど)).なので,lexerの選択肢が極端に少ないということはなく,問題はlalrpop
とpeg
,nom
,combine
くらいしか入力に(ほぼ)任意の型を取れない点にあります.あれ,意外と多いじゃん
lexer
全体の比較はこれくらいにして,まずはlexerからそれぞれの特徴を見てみます.
plex
「次の文字列」を読んでユーザー定義の型を返すというインターフェースが用意されます.任意の型Token
に対し,次のように書き,
lexer! { fn next_token(text: 'input) -> Token; /* ここにruleを書く */ }
次のシグネチャを持つグローバル関数定義が出力されます.Token
は当然(←w)'input
に依存できます.
fn next_token<'input>(input: &'input str) -> Option<(Token, &'input str)>;
「次の文字列」というのはこの関数の入力の先頭からの文字列のことです.何らかにマッチしたらその文字分進んだ&'input str
が一緒に帰ってくるので,それを適当な構造体で持ち回してIterator
とかを実装するのが主な使い方です.Token
としてトークンではなく制御パターン(とその中身にトークン)を持つようにすると,簡単にDFAなどを構成できます.実装量は少し多めだけど,マクロの箱の中身が単純であり,インターフェースが入力とほぼ乖離しないところがだいぶ素朴で扱いやすい.
nested commentには,例の記事で実装が示されていたように,コメントの内部とそれ以外の2種類のlexer!
を用いて,後者に制御のための構造をはさむことで対応できます.
位置情報は,改行のたびに現在位置を適切に更新し,文字列を読んだ際に残りの文字列に対して.chars().count()
をした結果を覚えておき,消費された文字数を計算すると計算回数が減らせます.こういう感じです
pub struct Lexer<'input> { remain: &'input str, last_remain_len: usize, current_loc: Loc, } // in next(): let state = next_token(self.remain); let (state, remaining) = state?; self.remain = remaining; let remain_len = remaining.chars().count(); let consumed = self.last_remain_len - remain_len; self.last_remain_len = remain_len; let lo = self.current_loc; self.current_loc.col += consumed; let hi = self.current_loc; // ...
ルールの右辺は<syn::Expr as syn::parse::Parse>::parse
で処理される部分につき,rust-analyzer
による補完が効きます.
logos
トークンを一つのenum
で表す(これをenum Token
とします)ことを前提にし,derive(logos::Logos)
を生やしてToken
の各メンバのattributeに&str
からの対応を書くようです.一つのメンバにつき複数の対応を書くことができ,クロージャをその場で書けたり関数を呼べたりするみたいです.attributeの内部に書くのはrust-analyzer
が何も言ってくれないので微妙そう.pure rustで書いた関数を呼べる上に戻り値の型で簡単に制御を表せる点はとても良いですね.callbackの戻り値はenumに包まれていないのでテストも分離しやすい.
Token
のメンバについたattributeについて,入力を読んで正規表現とのマッチに成功した際に実行されるクロージャや関数は,lex: &mut logos::Lexer<Token>
を引数にとり,マッチした文字列はlex.slice()
により獲得します.
nested commentに対応するには,plex
で考えた実装と同じようなことをすればよいです.2つのenumにLogos
を生やして,コメントの開始のcallbackでlogos::Lexer::remainder(&self)
(lex.slice()
より後の文字列を返す)とかlogos::Lexer::bump(&mut self, n: usize)
(n
文字進める)を呼んでいい感じにします.コメントを飛ばす処理はスクラッチした方が簡単だと思います.
位置情報はbyte posしか取れないみたいです.
速いことは確かなようで,lookup tableとかを自分で実装する手間もないため,単純な字句解析がしたいならこれで十分そうです.使い方を知るのが大変寄り.
parser as lexer
parser generatorにlexerを生成させることを考えます.可能というだけで効率が良いわけでは決してなさそうなので軽く.nested commentは違いますけど,正規言語の範囲ならふつうバックトラックが不要なのでpeg
やcombine
でも本領発揮できるはずで,nom
のように入力をバケツリレーするとかえってわかりにくくなりそうなものです.この話おわり.logos
かplex
使おう.
parser
nom
現行のversion 7はコンビネータもしっかりサポートされており,だいぶcombine
に似ているというか仕事を奪っている感じです.今回は関係ないけどstreamingに対応しているのが偉い.
parserの役割を担うのはnom::Parser<I, O, E>
を実装する型であり,このtraitのfn parse(&mut self, input: I) -> nom::IResult<I, O, E>
がparseを行います.IResult
はpub type IResult<I, O, E = Error<I>> = Result<(I, O), nom::Err<E>>;
のことで,
nom
の長所である,I
が&[u8]
や&str
であるparserが多いということが入力の型を任意にしようとすると活かせません.それでもand
やor
はnom::Parser
にくっついているのでそんな不自由することでもないし,トークンに対するパターンマッチを実行するのも容易です.
pom
はnom
の下位互換だと思うので紹介しません.
combine
parsec
を元にしているだけあってトレイトが重く使われており,入力もそんな感じです.
parserの役割を担うのはcombine::Parser<Input: combine::Stream>
を実装する型であり,このtraitのfn parse(&mut self, input: Input) -> Result<(Self::Output, Input), <Input as StreamOnce>::Error>
がparseを行います.combine::Stream
はpub trait Stream: combine::StreamOnce + combine::stream::ResetStream + combine::Positioned { }
であり,特にcombine::StreamOnce
でSelf::Token: Clone
が課せられています.提供されている実装は&str
とimpl<'a, T> StreamOnce for &'a [T] where T: Clone + PartialEq
(と&mut
版)に対してで,一応そこそこ自由な入力ができることがわかります.もちろんpub traitなので実装もできます.
入力の型を変えるとコンビネータしか使えなくなるのはnom
と同じで,違うのはResult::<T, E>
のT
のタプルの順と,しれっと付いているToken: Clone
です.トークン列のスライスを入力にすればいいだけですけど,attempt
を頻繁に使いたいような場合,選ぶならnom
でしょうか.
try operatorはいいですね.やっぱりparsec
が書きやすく読みやすいのはコンビネータ中心であることよりdo記法に依るところが大きいと思います.使うことに関してのモナドはみんなそうか.
peg
入力に&[T] where T: Copy
を選べます.正確にはruleで用いる機能ごとにpeg::ParseElem
(パターンマッチ), peg::ParseLiteral
(リテラル(これはlalrpop
に似ている)), peg::ParseSlice
(マッチする入力のスライスを返す)の3つのtraitがあり,それぞれを実装するものがその機能を利用できます.使わない機能のtraitを実装しないことができます.T: 'input + Copy
に対して[T]
へのこれらのtraitがすでにimplされています.T: Copy
は次のように参照を渡せばトークンの型には要求されません.
ParseElem
にrustのpat
の文法が利用できます.char
に対してパターンマッチというと聞こえが悪いですが,後置if
が使えるんですよね.
位置情報の取得は,規則で得た値の束縛と同じように行います.position!()
をrule名が来る場所で使うとpeg::Parse::PositionRepr
が束縛できます.トークン列の型Tokens: Parse
に対し<Tokens as peg::Parse>::position_repr(&'input self, p: usize)
を呼んでいるということです.
入力が正しい場合,rust-analyzer
のsemantic highlightingがいい感じに効きます.規則にマッチした後実行されるコード部分の{
,}
内とruleの引数の(
,)
内は直のproc_macro2::TokenStream
なので補完も効きます.ruleの名前をそのまま関数名に使ってくれていないようで,go to definitionできないのが少しだけ残念.かなり気に入っています.
pest
はpeg
の下位互換だと思うので紹介しません.
plex
plex
もparserを作れます.入力は自由なenum
にできるみたいですね.crate lalr
が提供するLALR(1)状態の構成を使っているようで,そこでconflictが見つかるとエラーとするようです.lalr
はpriority(?)が同一なreduce/reduce conflictと,全てのshift/reduce conflictをconflictとして報告します.precedenceとassociativityを反映する機能はなさそうでした.lalrpop
の下位互換です.
lalrpop
lalrpop
が入力とするのは,Writing a custom lexerに記載のある通り,pub type Spanned<Tok, Loc, Error> = Result<(Loc, Tok, Loc), Error>;
をItem
とするIterator<Item>
です.ここでTok
, Loc
, Error
はユーザー定義の型であり,Loc: Clone
以外のtrait制約はありません.
文法は.lalrpop
という拡張子のファイルに保存します.grammar<'input>;
としておくと必要な箇所全てでこのライフタイムが使えるようになるため,Tok
やError
が入力のライフタイムを持ち越せます.ちなみにclippy
するとここまで紹介した機能でめっちゃ警告がでます.codegen部に#[allow()]
差し込むだけでいいのに.
位置情報の取得は,規則で得た値の束縛と同じように行います.@L
や@R
を文法名が来る場所で使うとそれぞれ最左,最右の要素の左,右の位置が束縛できます.
名前に反してLR法を謳っており(LALRに弱めることはできる),まだ(2年間も)ドキュメント化されていないものの生成規則のprecedenceとassociativityを自動で解決する例の機能*2はあるのに,shift/reduce conflict, reduce/reduce conflictの解消方法を持たないんです.yacc
はprecedenceとassociativityを指定することにより次の規則に従って勝手にreduce
とshift
を選択してくれますが,lalrpop
にはそれができません..y
の中身をただ置き換えるだけで動かないということに気づいたのがこれを選んで翻訳を終えた後で,結構ショックでした.目立つ場所に書いておいてほしい.うちではconflict解消やってません,って.
- If there is a shift-reduce conflict and both the grammar rule and the input character have precedence and associativity associated with them, then the conflict is resolved in favor of the action -- shift or reduce -- associated with the higher precedence. If precedences are equal, then associativity is used. Left associative implies reduce; right associative implies shift; nonassociating implies error.
https://docs.oracle.com/cd/E19504-01/802-5880/6i9k05dh3/index.html
dangling ifのshift/reduce conflict解消ですら難しいのに,OCamlくらいの規模の文法で発生するconflictを解消するのがかなり骨の折れる作業で,これにかなり時間を持っていかれた記憶があります.次からは絶対peg
の方使う.yacc
の現代的なrust portとして十分輝けるだけに惜しいです.役割として被っていそうな,最近出てきたlrpar
はこれを読む限りreduce/reduce conflictを教えることはできるみたいです.yacc
との互換性がほしいならこっちを使ったほうがよさげ.
結び
この記事はこれで終わりです.実装の章がないので短めですね.
あ,ぼっち・ざ・ろっく!は観てくださいね.最高のアニメでした.
ISer Advent Calendar 2022に記事を書いてくれた21er, 22er, 23erの方々,ありがとうございました!
*1:え?そんなにネストしたコメントが嫌なら字句解析の前に処理すればよいのでは,だって?そうですね.それをCPU実験の担当者がコードを配布する前にしてくれればよかったんですけどね(カウンター).今度交渉してみます.