Ruby Parser開発日誌 (12) - LR parser generatorの提供する文法の健全性

LR parser generatorを難しいと感じるのはおそらくShift/Reduce ConflictやReduce/Reduce Conflictに遭遇したときでしょう。 嫌われがちなこれらのConflictが、実はわかりにくい文法を生み出すことを防いでいるというのが今日のお話です。

Bugs にあがった2つのチケット

https://bugs.ruby-lang.org/1 で最近文法に関する議論を2つ行ないました。その両者においてConflictが議論において重要な示唆を与えたので紹介します。

Bug #17925 (caseによるパターンマッチを1行で書いた時)

bugs.ruby-lang.org

Issueの内容は簡潔でcase ... whenの場合はセミコロンをいれることにより1行で書くことができるが、case ... inの場合はできないというものです。

# Syntax OK
case expression when 42; end
# syntax error
case expression in 42; end

これはcaseのあとにexpression in 42という1行パターンマッチ(expr)を書くことができるためです。つまりcase (expression in 42); endとパースされるので、その後にinないしwhenを書いていないものはsyntax errorになります。

なので例えば次のような書き方は通ります。

# Syntax OK
case expression in 42; in true; :t end

ちなみにこのexprというのは他には例えばifの後や、後置whileの後ろなどにも書ける要素と同じです。

いまのparse.yを変更してcase (expression) in 42; endと解釈するようにすることも可能です2expression in 42trueもしくはfalseの値しか返さないのでcaseに渡すというユースケースはあまり多くないとも考えられます。なのでこの部分だけ特別に解釈を変えることも選択肢のひとつだと思います。

ではこれを変更したときの影響にはどのようなものがあるのでしょうか。すでにリリースしている機能なので変更時の影響範囲を把握することは大切です。

当然case ... inは影響を受けます。例えば

expression = 42

case expression in 42
in true
  p :t
in false
  p :f
end
#=> :t

expression = 421

case expression in 42
in true
  p :t
in false
  p :f
end
#=> :f

というコードは以下のように挙動が変わります。

expression = 42

case expression in 42
in true
  p :t
in false
  p :f
end
#=> nothing printed, because body for 42 is empty

expression = 421

case expression in 42
in true
  p :t
in false
  p :f
end
#=> 421 (NoMatchingPatternError)
# because nothing matches

それ以外にも影響を受ける箇所があります。そうですcase ... whenですね。

case expression in 42
when true
  p :t
when false
  p :f
end

というコードはsyntax errorを起こすようになります。

case expression in 42
when true
  p :t
when false
  p :f
end

# test2.rb:2: syntax error, unexpected `when' (SyntaxError)
# when true
# ^~~~

case expression inまで読んだ時点でcase ... inの構文だと解釈するので、その後にwhenがくるとエラーになります。おなじcaseから始まる構文なので片方の変更がもう一方にも影響します。

なおこの影響はShift/Reduce Conflictとして観測できます。case ... inだけをexpr_value0という1行パターンマッチを除いたルールで置き換えると以下のようなConflictがレポートされます(見やすいように一部編集)3

State 394

    shift/reduce conflict on token "`in'":
       60 expr0: arg •
       71 expr: arg • "`in'" @7 @8 p_top_expr_body
      First example: $@1 k_case arg • "`in'" @7 @8 p_top_expr_body opt_terms @19 case_body k_end "=>" @5 @6 p_top_expr_body opt_terms
      Shift derivation

       ↳ 358: k_case expr_value                                     opt_terms @19 case_body k_end
                     ↳ 78: expr
                           ↳ 71: arg • "`in'" @7 @8 p_top_expr_body

      Second example: $@1 k_case arg • opt_terms "`in'" @38 @39 p_top_expr then $@40 compstmt p_cases k_end '[' opt_call_args rbracket "operator-assignment" lex_ctxt command_rhs opt_terms "end-of-input"
      Reduce derivation

       ↳ 361: k_case expr_value0       opt_terms p_case_body                                                 k_end
                     ↳ 77: expr0                 ↳ 500: "`in'" @38 @39 p_top_expr then $@40 compstmt p_cases
                           ↳ 60: arg •

Bug #18080 (メソッド呼び出しに対する1行パターンマッチ)

bugs.ruby-lang.org

こちらもIssueの内容は簡潔でブロック付きのメソッド呼び出しを1行パターンマッチの左に置くことができるケースと、できないケースがあるというものです。具体的には引数がないケースやカッコで引数を囲んでいる場合は通りますが、カッコで囲まないケースではsyntax errorになってしまいます。

p(1) do
end => a
# OK

p 1 do
end => a
# syntax error

これもparse.yを変更して通すことは可能です4

こちらにも実は一つ問題があって、以下の組み合わせのときは思った挙動になりません。

  • ブロックがない
  • 引数をカッコで囲まない
  • pattern matchの記号が =>
[].append => a
[].append in a
[].append(1) => a
[].append(1) in a
# このケースだけはpattern matchingにならない
[].append 1 => a
[].append 1 in a

#append{1 => a}というhashを引数として呼び出すという、すでにある記法になってしまうからです。

このような一貫性のなさもShift/Reduce Conflictとして観測できます。今回のpatchではConflictをさけるために=>のときはblock_callを、inのときはcommand_callを許可するように変更しましたが、=>のときもcommand_callを許可すると以下のようなConflictがレポートされます(見やすいように一部編集)5

State 223

    shift/reduce conflict on token "=>":
      308 args: arg_value •
      759 assoc: arg_value • "=>" arg_value
      First example: $@1 k_return arg_value • "=>" arg_value opt_block_arg "=>" @7 @8 p_top_expr_body opt_terms "end-of-input"
      Shift derivation

       ↳ 65: command                                                                      "=>" @7 @8 p_top_expr_body
             ↳ 97: k_return call_args
                            ↳ 299: assocs                                   opt_block_arg
                                   ↳ 757: assoc
                                          ↳ 759: arg_value • "=>" arg_value
      Second example: $@1 k_return arg_value • opt_block_arg "=>" @7 @8 p_top_expr_body opt_terms "end-of-input"
      Reduce derivation

       ↳ 65: command_call                                                 "=>" @7 @8 p_top_expr_body
             ↳ 82: command
                   ↳ 97: k_return call_args
                                  ↳ 298: args               opt_block_arg
                                         ↳ 308: arg_value •

手書きパーサーで検出できるのか

手書きパーサーの場合はどうなるでしょうか。手書きパーサーはこれらの点を発見・指摘してくれません。ということは実装する人が把握する必要がありますが、毎回見落としなく行うのはかなり困難だと思います。困難な理由はいくつかあります。

1. あらゆる組み合わせを考慮する必要がある

文法の要素というのは1つからなるのではありません。例えば11 + 21..2も見た目は異なりますがRubyではargという同じグループに属しています。またp 1, 2 do endp 1, 2のように一部を省略したものが同じグループのこともあります(これらはともにcommand_call)。文法の一貫性を考えるうえではp 1, 2 do endという個別具体的なコードだけを議論、検証するのではなくarg全体、expr全体など、あるグループに注目して議論をする必要があります。さらにargexprexprstmtのような置き換えもあるのでargを議論するときはそれがexprstmtとして扱われる状況も考慮する必要があります。

2. コンフリクトはつねにReduceとともにある

Shift/Reduce ConflictもReduce/Reduce ConflictもReduceが関係します6。Reduceというのはそこまでをひとまとめにして、別の生成規則のなかに入れるという操作です。これは今注目しているルールが他のルールのどこで使われているかに関係するので、大局的な視点が必要になります。

1つめのBug #17925の例でいうとexpression in 42というexprcase ... inだけでなくcase ... whenのなかにも書くことができます。そのためcase ... whenのほうのルールも調整しないとcase expression in 42 ...case (expression) in 42 ...case (expression in 42) ...のどちらに解釈していいか定まらなくなってしまうのでした。

3. 文法に関する議論や実装は局所的である

これらのチケットでの議論もそうですが、文法の議論は対象となる最小のものを中心に行われることが多々あります。議論の対象が絞られるという点で最小ケースについて議論すること自体はよいことだと思います。しかしここまでみてきたように、文法の曖昧性というのはある構文をほかの構文と組み合わせた時に発生します。

またパーサーの実装も局所的な実装の組み合わせからなります。LR parser generatorであれば個別ルールの組み合わせで全体が実装されていますし、手書きパーサー(再帰下降パーサー)の場合であってもparse_stmtparse_exprなどの関数の組み合わせでパーサー全体を構成することになります。

[].append 1 => aの例でいうと、パターンマッチをパースする部分と引数をパースする部分の間で衝突が起きています。しかし実装をするときは以下のようにそれぞれを局所化して実装します。

// pattern matching
expr: arg tASSOC p_top_expr_body ;

// 引数の一部
assoc: arg_value tASSOC arg_value ;

言い方をかえればLR parser generatorの報告するConflictというのは、実装を局所化した時に全体として不都合が起きてないかをチェックしてくれる機構ともいえます。そしてそのようなチェック機構があるから実装を局所化することができるといえます。

4. Rubyの文法の特徴

以上の点に加えて、Rubyの文法の特徴のうちConflictを起こしやすい特徴が2つあると思っています。

1つは小さな単位に多くのルールがあることです。Rubyの文法構造は大きく4つに分けることができ、大きいものから順に以下のような並びになります。

  • stmt (statement / 文)
  • expr (expression / 式)
  • arg (argument / 引数)
  • primary (primary / 項)

このうちprimaryargに関するルールはexprstmtよりも多いです。より小さな要素が多いということはそれだけ組み合わせが多くなります。

もう1つは省略記法があるということです。p 1のようにメソッド呼び出しのカッコは省略可能ですし、def add(i, j) = i + jのようにendを省略した記法もあります。このように自明な終端がない構文はどこまでを読んでいいのか不明確になりやすく、結果としてConflictを起こしやすくなります7

これらの特徴を考えるに、計算機科学の助けを借りず人力で文法の一貫性を担保していくのは無理があると思います。 新しい文法の追加や文法の変更の影響を把握するために、人間がRubyの文法を全部頭にのせたうえで議論する必要があるという状況はよくないでしょう。今後の構文の変更や追加の際のコストが高くなりますし、そもそも議論に参加できる人が極めて限られてしまいます。

LR parser generatorは完璧なのか

LR parser generatorのレポートしてくるConflictは文法全体の健全性を担保するチェック機構であるという話をしました。ではいまのLR parser generatorのレポートは十分なのでしょうか? 十分ではないのでLR parser generatorを触った人の多くがConflictに抵抗感を持っているのだと思います。最近はBisonもLramaもCounterexamplesを実装したので、どういったケースでConflictが起きているかわからないというのは減ったと思います。次に改善すべきはShift/Reduceのどちらにするか決めた時に簡単にそれを指示できるような機能でしょう。RustのコンパイラGCC, Clangのようにエラーメッセージをもとにどう修正すればよいか分かるようなparser generatorを目指していきたいです。

まとめ

  • 文法の追加や変更をする際には文法全体の一貫性を考慮する必要がある
  • 文法の変更をする際には他の構文への影響範囲を把握できる仕組みがあると変更の是非を議論しやすい
  • これらは組み合わせの問題と大局的な検証が必要なことから人間が行うのは難しい
  • LR parser generatorのレポートするConflictは文法全体の健全性を担保するチェック機構である

ところで世界で最も成功している手書きパーサーの1つがRoslynだとおもうのですが、C#の構文の追加時などにはどのようにして文法全体の一貫性や曖昧性がないことをチェックしているのでしょうか、ご存知の方がいたら教えてください8


  1. Rubyのissue tracker
  2. https://github.com/yui-knk/ruby/tree/bugs_17925
  3. Comparing ruby:master...yui-knk:bugs_17925_2 · ruby/ruby · GitHub
  4. https://github.com/yui-knk/ruby/tree/bugs_18080
  5. Comparing ruby:master...yui-knk:bugs_18080 · ruby/ruby · GitHub
  6. ShiftとShiftであればShiftすればだけなのでShift/Shift Conflictにはならない
  7. Lookahead-setによって切れ目を決めることになるが、Lookahead-setはReduceした場合に来うるtokenのlistのことなので文法全体に対する大局的な理解が必要
  8. アンダース・ヘルスバーグの力とMicrosoft資本力でなんとかしているという可能性もなきにしもあらず...

Ruby Parser開発日誌 (11) - RubyKaigi 2023 follow upで進捗について話してきた

8/19に開催されたRubyKaigi 2023 follow upで、Rubyのparserとparser generatorに関する進捗と今後の方針について話をしてきました。

rhc.connpass.com

当日の資料はこちらにアップロードしてあります。

speakerdeck.com

進捗とこれからの話

RubyKaigiでの発表を踏まえて3つの点についてRubyKaigiからの進捗とこれからの話をしました。

  • Error-tolerant
  • Universal Parser
  • Maintainability

Lramaとは

github.com

RubyでかかれたLALR parser generatorです。BNFをベースにしたDSL(parse.y)を受け取って、C言語実装のLALR parserを生成します。現在のCRubyはLramaが生成したparserを使用しています。

Error-tolerant

進捗は大きく2つあって、一つはRubyKaigiに向けて作りっぱなしになっていたerror_recovery branchをmergeしました。もう一つはError-tolerantの機能をcompile時ではなくruntimeで制御できるようにYYERROR_RECOVERY_ENABLEDYYMAXREPAIRというinterfaceを追加しました。1つめのerror_recovery branchのmergeに関しては alitaso345 さんの尽力のおかげです。ありがとうございます。

次のステップとしてはError-tolerantの機能をRubyに繋ぎ込んでいきたいと思っています。現状繋ごうとすると、どこかメモリ周りのbugがあるようで参照先が途中で変わってしまってSEGVするので、どこかで集中してデバッグする必要がありそうです。

Universal Parser

進捗としてはRuby本体にUniversal Parser modeでbuildできる設定をいれたことです。[Feature #19719] Universal Parser by yui-knk · Pull Request #7927 · ruby/ruby · GitHub

S-H-GAMELINKS さんなどからPRもいただき、少しずつCRuby固有の関数への依存を減らしている状態です。

char関連の関数は移植できたので、そろそろAST NodeからRubyのobjectを消したり、unionで定義されているNodeをstructで定義するように書き換えていこうかなと思っています。

お手元でUniversal Parser modeでビルドしてみる方法について S.H. さんがブログにまとめてくださいました。

blog.agile.esm.co.jp

Maintainability

今回Maintainabilityについて話すうえであらためてparser generatorやLR parserの優れている点について考えてみました。いくつかあるのですが、まずはじめにBNFという広く使われているinterfaceで文法を記述できる点があげられます。つぎにparser generatorは人間が見落としがちな曖昧性(conflict)を自動で検出して報告してくれます。またある種のアルゴリズムでは具体的な文法によらずBNFをもとに自動的にError-tolerantなパーサーを生成することも可能です 1

いいことばかりに見えるアプローチですが、LALR parser generatorについては一定のデメリットも指摘されています。

よくいわれるのがコンフリクトしたときに何が起きているのかよくわからない、またどのように解決したらいいのかわからないといったコンフリクトに関すること。どのように構文を拡張したらいいのかよくわからないといった拡張性に関すること。省略可能な構文や繰り返しの構文に関して一定のお作法があるなど文法ルールに関する書き味に関することなどです。

総じていえばプログラミング言語の文法を開発するときの体験が悪いということが問題だといえます。

コンフリクト解消の大変さ

コンフリクトに関してはLrama v0.5.4でCounterexamplesという機能を実装しました。ぶら下がり(dangling) elseというShift/Reduce コンフリクトの有名な例ですが、このようにコンフリクトがあるときに、それぞれ具体的な例を表示してくれる機能です。最適化の余地がまだ結構あるので速度については今後改善していきたいとおもっています。

shift/reduce conflict on token k_else:
    stmt: k_if expr k_then stmt • k_else stmt  (rule 1)
    stmt: k_if expr k_then stmt •  (rule 2)
  Shift derivation
    0:  stmt                                                                    "end of file"
        1: k_if expr k_then stmt                                    k_else stmt
                            1: k_if expr k_then stmt  • k_else stmt
  Reduce derivation
    0:  stmt                                                         "end of file"
        1: k_if expr k_then stmt                         k_else stmt
                            2: k_if expr k_then stmt  •

これでコンフリクトに関する難しさが全て解決したわけではありません。コンフリクトの例を理解して、ShiftするかReduceするかを決めたとします。そのときに文法ファイルをどのように書いたらいいか今のparser generatorはなにもアドバイスをくれません。実務的には %prec xxx と書くことで優先度を与えてShiftもしくはReduceのどちらに振舞うかを指示することが多いです。であればそれを最初から教えてくれてもいいのではないでしょうか。

shift/reduce conflict on token keyword_and:
    endless_arg: endless_arg • "`and'" endless_arg  (rule 267)
    endless_stmt: endless_arg •  (rule 263)

    If you want to shift "`and'", specify "endless_stmt : endless_arg %prec tLOWEST"
    If you want to reduce, specify "endless_stmt : endless_arg %prec keyword_not"

もう一つ改善点をいうと%prec xxx という表記はあまりわかりやすくありません。とくにあとからみたときに何をどう解決するために書かれたものかという情報があまりありません。ある地点において、あるtokenをshiftせよといったことがわかるような良い表記を思いつけばそれをサポートしたいとおもっています。

// 書き方の例
endless_arg     : ...
                | endless_arg keyword_and endless_arg %shift keyword_and
                | endless_arg keyword_or endless_arg  %shift(1)
                ;

文法ファイルをいじることの大変さ。または書き味がいまいちなこと

コンフリクト以外の点では2つのトピックをお話ししました。ひとつは今後の構文拡張について開発者にヒントをあたえられないかという話。もうひとつは文法定義にもう少しリッチな書き方を提供できないかという話です。

Rubyに新しい文法を追加するときに、既存の文法を壊さないように注意しながら文法を議論します。新しい書き方をしたときに今のRubyでSyntaxErrorになることを確認するというテクニックがよく使われます。ここから新しい文法を考えるときにヒントを得ることができるのではないかと考えています。具体的にはSyntaxErrorというのはある状態で次にくるべきtoken以外が来た場合に起きるので、文法のルールの書く部分で次に来てはいけないtokenを可視化することで新しい文法のヒントを提供できないかと考えています。

文法ファイルの書き味についても改善の余地があります。,区切りのarg_valueの連続(args)や省略可能な改行(opt_nl)といったルールの書き方は頻出で、書き方もテンプレート化しています。Menhirなどを参考にこれらテンプレート化した書き方向けにシンタックスシュガーを提供していきたいと思っています。

args        : arg_value
            | args ',' arg_value
            | ...
            ;

opt_terms   : /* none */
            | terms
            ;

opt_nl      : /* none */
            | '\n'
            ;

LexerとParserは分離可能なのか

Rubyのparserをいじっていると色々な情報をLexerからParserに移動したほうが見通しがよくなるのではないかと思うことがたまにあります。

例えばifという文字列に対応するtokenがRubyには2つあります(keyword_ifmodifier_if)。Lexerはlex_stateで状態を管理しながら、このどちらを切り出すかを判断しています。いまはLexerとParserの両方がlex_stateを更新しています。しかし文法に関してはParserの方がより多くの情報をもっているので、%lexのような記法を用意してlex_stateを更新する箇所を全部Parser側に寄せてみてはどうかというアイデアがあります。

stmt | stmt modifier_if expr_value

=>

stmt | stmt %lex(EXPR_END) modifier_if expr_value

LRパーサーの機構はtokenによって遷移するオートマトンをstackで管理するというものです。一方でlex_stateもまたtokenによって遷移するオートマトンです。オートマトンオートマトンを組み合わせたものもまたオートマトンなので、それをLRパーサーのstackで管理するというのは割と自然な発想ではないでしょうか。

他にもParser側の情報をうまく使えそうな例として、引数のないブロック呼び出しの例を紹介しました。

a do ||
end

Rubyには|||(tOROP)という異なるtokenがあります。素朴にLexerを実装するときはより長いほうからマッチを確認します。しかし先ほどのブロック呼び出しの例では||ではなく||である必要があります 2。これを区別するためにもlex_stateが使われています。しかしdoのあとには||が来ないことは文法定義からわかりますし、いまdoの直後にいるかどうかはparserの状態からわかります。であればその情報をParserからLexerに伝えることでlex_stateを使わずとも適切なtokenを切り出すことができるのではないでしょうか。

最近このようなアプローチに関する論文があることをしったので、今回はあたらしくPSLR(1)という種類のparserを紹介しました 3

最終的に目指すところ

Rubyの"parse.y"のなかでもLexerの部分はC言語で手書きされたロジックのかたまりです。ということは教科書などで構文解析を勉強したとしても、Lexerの部分はコードを読んで何が起きているか一から理解をしないといけません。これをPSLRなどの論理に沿った形で整理していくことで最終的には、教科書などで構文解析を勉強すればRubyのparserが理解できる状態にしていきたいと思っています。

懇親会で話したこと

懇親会で複数人とparserの話をする機会があったので、そのときに話したことを簡単にまとめておきます。

Error-tolerant parserのさらなる拡張の話

将来的にいくつかの機能拡張を考えています。

  • 今はtokenを補う(insertする)という操作だけなので入力ファイルのtokenを削除する操作も組み合わせてリカバリーを考えるようにする
  • その際にはinsertとdeleteのコストをそれぞれ設定できるようにしたい。というのもおそらく多くの場合において可能な限りもとの入力を残しておいた方がいいと想像されるので、insert 3回に対してdelete 1回が同じコストというような傾斜をかけたくなるはず
  • syntaxは通るがsemanticsが通らないかたちにリカバリーしてしまうことがあれば、生成規則にannotationをつけて特定のruleをリカバリー時に使わないようにするなどの工夫が必要になるかもしれない

LR parser 上から読むか横から読むか

makenowjust-labs.github.io

に関する話もしました(主にEarley法のほう)。私はLR parserを教科書通りに理解しているようで、以下のような状態をみると、defまで読んだ段階であってまだdef method ...の可能性もdef self.method ...の可能性もある1つの状態なんだと理解しています。一方これを2つのオートマトンを抱えている状態であって、以降の入力によってどちらのオートマトンが残るか(もしくはどっちも残らなくてエラーになるか)が決まるというふうに読むこともできます。ブログで紹介されている技法は後者の"2つのオートマトンを抱えている状態"に近しいと思ったのでした。

defn_head: • k_def def_name
defs_head: • k_def singleton dot_or_colon def_name

あとProcedural Automatonという単語をはじめて知りましたという話もした記憶があります。

いろいろあるLR parser、結局なにが違うのか

LR(0), LR(1), SLR(1), LALR(1), IELR(1)といろいろな種類があるが端的になにが違うのかという話になったので、それらはどのようなケースでReduceするかが異なりますね。という話をしました。どの種類であっても、ルールとdotの組み合わせ (k_if expr_value then compstmt if_tail • k_end など)から状態を構成しますし、各状態においてshiftをするかどうかについては変わりはありません。では何が違うかというと各状態でReduceするかどうかの判断が異なってきます。

例えば最も素朴なLR(0)ではルールの終わりにいるときは無条件でReduceをします。対してLR(1)などではルールの終わりにいてかつ次のtokenが一定の条件を満たす場合にのみReduceをします。条件というのは簡単にいえば、Reduceをしていったときに次のtokenが出現することがあるかというものです。LR(1)は一般に状態数が膨大になることがあるので、それらの状態をmergeしたものがSLR(1), LALR(1)です。状態をmergeした結果としてコンフリクトが新たに生じることがあるので、それらのうち状態を分割することで回避可能なものについて再度状態を分割するというのがIELR(1)のアプローチです。

まとめ

発表の準備はそれなりに大変でしたが、締め切りがあったおかげで実装も進みましたし、今後の展望についてもあらためて整理してお話しすることができました。

主催の笹田さん。会場提供 & 音響・配信 & DJ & 懇親会の運営などをしてくださったピクシブ株式会社様。スポンサーのみなさん。当日絡んでくださったみなさん。ありがとうございました。


  1. 例えばCPCT+ algorithm ([1804.07133] Don't Panic! Better, Fewer, Syntax Errors for LR Parsers)
  2. 過去にはtOROPのほうをつかって文法を定義していたので最終的には実装依存ですが、||のあいだに省略可能な引数があるというルールのほうがわかりやすいと思います
  3. 詳しくは PSLR(1): Pseudo-Scannerless Minimal LR(1) for the Deterministic Parsing of Composite Languages

Ruby Parser開発日誌 (10) - parse.y リファクタリングチャレンジ はじめました

前回のあらすじ

Ruby Parser開発日誌 (9) - RubyKaigi 2023で発表してきた ~ 世はまさに”大パーサー時代” ~ - かねこにっき

RubyKaigiにいってきました。スライドや登壇時の動画は以下のリンクから参照できます。ぜひご覧ください。

rubykaigi.org

parse.y リファクタリングチャレンジ

"parse.y"を読んでいる時にrb_obj_hiderb_builtin_class_nameRB_OBJ_WRITTENといった関数やマクロが出てきて驚いたことのある人は少なくないでしょう。RubyのparserではGCRubyのObjectなどRuby本体の機能が多く使われています。それらはRubyの長い歴史のなかで洗練されてきた機能である一方、一定の御作法に従って使う必要があります。たとえばRB_OBJ_WRITERB_OBJ_WRITTENを書き忘れた結果、思わぬところでGCによって必要なオブジェクトが回収されてしまったというバグは誰しも一度は踏むものでしょう。

またRubyのAST Node(struct RNode)は多様なNodeをunionで管理しているため、どのNodeのどのフィールドがどの型であるかわからなくて途方にくれたこともあると思います。

ruby/rubyにいくつかのpatchをいれて準備が整ったので、このような状況を改善するために"parse.y"を本格的にリファクタリングしていきます。

リファクタリングのアイデア

リテラルオブジェクトをRubyのオブジェクトから卒業させる

1"abc"といったリテラルはparserのなかでRubyのオブジェクトに変換されています。このままではC言語のintをRubyのInteger(Fixnum)に変換する関数などが必要になりますし、Rubyのオブジェクトを扱っている限りGCからも離れられません。リテラルオブジェクトについては"123"というStringとしてNodeに持たせるようにし、"parse.y"の外側(たとえば"compile.c")でRubyのオブジェクトに変換するように書き換える必要があります。

これを

# +- nd_body:
#     @ NODE_LIT (id: 0, line: 1, location: (1,0)-(1,3))*
#     +- nd_lit: 123 (これはCRubyのInteger object)

こうする

# +- nd_body:
#     @ NODE_LIT (id: 0, line: 1, location: (1,0)-(1,3))*
#     +- nd_lit: "123" (char *)
#        len: 3
#        type: Integer

Fixnum, Bignum, Float, Rational, Complex, Stringなどが対象です。 ちなみに"123"というStringと書きましたが、これもRubyのStringを使わずにparser用に自前のString構造体と関連する関数を用意します1

Nodeを1つのunionから別々の構造体にする

冒頭にも書いたようにRubyのAST Node(struct RNode)は多様なNodeをunionで管理しています。そのためNodeの種類と各フィールド(u1, u2, u3)の型の対応を理解するのが難しいです。 たとえばメソッド呼び出しのNODE_CALLの場合、レシーバーがu1RNodeとして、メソッドのidがu2IDとして、引数がu3RNodeとしてそれぞれ入っています。

この対応を調べるときにnode_dump関数や、node_children関数を見ている人も多いのではないでしょうか。

この実装をそれぞれNodeの種類ごとに構造体を定義して整理していきます。

// 現在の実装では3つのunionがあり、この構造体だけをみても実際のフィールドの型を判別するのが難しい
typedef struct RNode {
    VALUE flags;
    union {
        struct RNode *node;
        ID id;
        VALUE value;
        rb_ast_id_table_t *tbl;
    } u1;
    union {
        struct RNode *node;
        ID id;
        long argc;
        VALUE value;
    } u2;
    union {
        struct RNode *node;
        ID id;
        long state;
        struct rb_args_info *args;
        struct rb_ary_pattern_info *apinfo;
        struct rb_fnd_pattern_info *fpinfo;
        VALUE value;
    } u3;
    rb_code_location_t nd_loc;
    int node_id;
} NODE;

...
#define nd_recv  u1.node
#define nd_mid   u2.id
#define nd_args  u3.node
...

parserレベルの最適化とASTを変換するロジックを削除する

おそらくCRubyの評価機がVMになる前の名残だと思いますが、parserの内部で細かい最適化を行っています。

たとえばリテラルだけが書かれた行が途中にあったとき、その行はプログラムの実行結果に何も影響しません。このような行はparserのレベルで最適化されASTには残りません(block_append関数を参照)。

def m
  :a
  :b
end
# :aがどこにもない

# @ NODE_SCOPE (id: 6, line: 1, location: (1,0)-(4,3))
# +- nd_tbl: (empty)
# +- nd_args:
# |   (null node)
# +- nd_body:
#     @ NODE_DEFN (id: 1, line: 1, location: (1,0)-(4,3))*
#     +- nd_mid: :m
#     +- nd_defn:
#         @ NODE_SCOPE (id: 5, line: 4, location: (1,0)-(4,3))
#         +- nd_tbl: (empty)
#         +- nd_args:
#         |   @ NODE_ARGS (id: 2, line: 1, location: (1,5)-(1,5))
#         |   +- nd_ainfo->pre_args_num: 0
#         |   +- nd_ainfo->pre_init:
#         |   |   (null node)
#         |   +- nd_ainfo->post_args_num: 0
#         |   +- nd_ainfo->post_init:
#         |   |   (null node)
#         |   +- nd_ainfo->first_post_arg: (null)
#         |   +- nd_ainfo->rest_arg: (null)
#         |   +- nd_ainfo->block_arg: (null)
#         |   +- nd_ainfo->opt_args:
#         |   |   (null node)
#         |   +- nd_ainfo->kw_args:
#         |   |   (null node)
#         |   +- nd_ainfo->kw_rest_arg:
#         |       (null node)
#         +- nd_body:
#             @ NODE_LIT (id: 4, line: 3, location: (3,2)-(3,4))*
#             +- nd_lit: :b

連続したstringリテラルは連結した1つのstringになりますが、これもまたparserレベルの最適化で結合が行われています(literal_concat関数を参照)。

"a" "b"
# "a" "b" ではなく"ab"になっている

# @ NODE_SCOPE (id: 2, line: 1, location: (1,0)-(1,7))
# +- nd_tbl: (empty)
# +- nd_args:
# |   (null node)
# +- nd_body:
#     @ NODE_STR (id: 0, line: 1, location: (1,0)-(1,3))*
#     +- nd_lit: "ab"

またshareable_constant_valueマジックコメントはASTのNodeを追加することで実装されています(nd_mid: :make_shareableのあたり)。この手の実装は"parse.y"の外側、たとえば"compile.c"へもっていきます。

# shareable_constant_value: experimental_everything
FOO = Set[1, 2, {foo: []}]
$ ruby -v --dump=p test.rb
ruby 3.2.0 (2022-12-25 revision a528908271) [arm64-darwin21]
...
# +- nd_body:
#     @ NODE_CDECL (id: 0, line: 2, location: (2,0)-(2,26))*
#     +- nd_vid: :FOO
#     +- nd_else: not used
#     +- nd_value:
#         @ NODE_CALL (id: 15, line: 2, location: (2,0)-(2,26))
#         +- nd_mid: :make_shareable
#         +- nd_recv:
#         |   @ NODE_LIT (id: 13, line: 2, location: (2,0)-(2,26))
#         |   +- nd_lit: #<Class:0x000000010322cd20>
#         +- nd_args:
#             @ NODE_LIST (id: 14, line: 2, location: (2,0)-(2,26))
...

リファクタリングの先にあるもの

これらのリファクタリングが完了したとき、大きく3つの恩恵を得ることができると考えています。

1. 素直なASTが手に入る

Abstract Syntax Tree(抽象構文木)はその名のとおり抽象化されたものです。しかし各種解析ツールのことを踏まえ、昨今ではより情報を付与する方向にASTを整理することが求められているように感じます。入力されたコードを素直に構造化したデータとしてのASTが手に入ることは、各種ツールの実装者に必要な情報を提供することに繋がります。

2. parse.yの可読性が上がる

以前 Ruby Parser開発日誌 (6) - parse.yのMaintainabilityの話 - かねこにっき ではparserとlexerの密結合という観点から"parse.y"のMaintainabilityの話をしました。この密結合の解消以外にもNodeの構造を整理したり、parserにおける最適化(Nodeの変形)を削除することもMaintainabilityや可読性の向上につながります。

3. Universal Parser への貢献

Universal ParserとはTypeProf, Sorbetといった解析ツールや、mrubyなどCRuby以外のRuby実装でもCRubyのparserを使えるようにするというプロジェクトです2。そのためにはいまのparserからCRubyの機能への依存を消していく必要があります。"parse.y"がRubyのObjectに依存しなくなったときUniversal Parserが手に入ります。

目標としては今年のRubyのリリース(2023/12/25)までにCRubyの関数への依存を全部消したいと思っています3

まとめ

ここまでみてきたようにこのチャレンジには

  • RubyのStringのサブセットを実装する
  • ASTの構造や組み立て方を変更する
  • "parse.y"の変更にあわせて"compile.c"を変更する
  • parserの内部データ構造をGCから卒業させる(メモリ管理の変更)

などの幅広いジャンルの課題があります。これらはparserの構文規則をいじるというよりは、parserの扱うデータ構造やロジックといったC言語で書かれた部分のリファクタリングが中心です。ということはparser generatorに詳しくなくても、構文規則を変更しなくても、"parse.y"に手を入れるチャンスが広がっているということです。またASTをいじると結構な確率で"compile.c"を調整することになるはずなので、"compile.c"についても詳しくなれるはずです。

"parse.y"を実質書き直すといっても過言ではないプロジェクトにあなたも参加してみませんか?

挑戦者求む!!

これらを全部一人で進めるのはやることがいろいろあって大変です。もし興味をお持ちの方がいたら、ぜひ手伝っていただきたいです。ruby-jp.slack.com | ruby-jpに#lr-parser というチャンネルがありますので、詳しい話が聞きたいという方はお越しください。


  1. ということはEncodingは? と思った人はするどいですね。Stringリテラルを移植するさいには、どのEncodingが指定されていたかという情報をもたせてCRubyのEncodingと対応づける必要があります。
  2. Universal Parserについて詳しくはRuby Parser開発日誌 (8) - Universal Parserへの道 - かねこにっきをみてください。
  3. 最終的に残るmallocfreeはoptionalに渡すことができる状態にします。なのでこの目標を達成すれば外からなにも渡さなくてもparserをつくってparseしてAST Nodeが手に入るようになります。

Ruby Parser開発日誌 (9) - RubyKaigi 2023で発表してきた ~ 世はまさに”大パーサー時代” ~

5/10から5/14の5日間、RubyKaigi 2023に参加するために松本市に行ってきました。前回参加したのがRubyKaigi 2019の福岡のときなので、じつに4年ぶりの参加でした。 今回はコミッター/登壇者/LTスピーカーとしての参加になりました。その結果、0日目のDevMeeting含めて3種類のスライドをつくり、3日目の"Ruby Committers and The World"含めて3回登壇するというイベント盛りだくさんなKaigiでした。

いやー、自分のRubyKaigi史上、最高のRubyKaigiでしたね。まさにParserKaigiだったのではないでしょうか。

いろいろ書きたいことはありますが、まずは時系列で振り返っていきましょう。

Day 0 (5/10) - DevMeeting

DevMeetingに参加するためDay 0から松本へ向かいました。新宿から特急に乗り込んだ時点で、登壇資料が90%くらい、LTが85%くらい、Day 0の資料が0%の仕上がりでした。車内で仕上げるぞ!と思っていたのですが、想像以上に列車が揺れて東京を抜けるころにはちょっとグロッキーになってしまったので後半はぼーっとしていました(一敗)。

松本についてまずやるべきことはスライドの見出しに使う写真の撮影。ということで松本城へいったり、松本市美術館へいったり、街の中をふらふらしたりして写真をとっていました。最初にいった松本城でいきなり野生のRubyistに遭遇し、"そうそうこれこれ、こういう遭遇イベントをやりたかったんだよ!!!"と無駄にテンションが高くなってしまいました。DevMeetingは登壇資料をコンパクトにまとめたものを作って現状の進捗と方針を共有して無事終了、と思いきやBisonからLramaに置き換える話でGoサインが出て、結果このあと僕は2日目まで資料作りとPR作成に追われるのでした(この時点ではまだ1日目の発表で頭がいっぱいでした)。

RubyVM::AbstractSyntaxTreeのときも仙台のRubyKaigiの1日目の会議でいれることがきまったので、RubyKaigiにいくと進捗がでて良いですね1

DevMeetingのあとは少人数でご飯を食べに行ったりしました2。 ホテルに戻ってからスライドを整理し、何回かリハーサルをして最終的に28分くらいに収まることを確認して就寝。

Day 1 (5/11) - 登壇 & LT

朝食を食べに珈琲美学アベにいったところ当然Rubyistに遭遇し、"そうそうこれこれ"(略。

Matzのキーノートを聞いて、お昼を食べていよいよ登壇。

speakerdeck.com

togetter.com

途中何回か観客席を見回す機会があって、そのときみんなの熱気を感じました。会場との一体感というやつですね。とはいえ登壇している本人としては、夢中でしゃべっていて気がついたら発表が終わっていたというのが正直なところです。

登壇後はスポンサーブースや4階のHack Spaceをふらふらしたり、いくつかセッションを聞いたりして一度クールダウンをしていました。気持ちが落ちついてきたのでHack Spaceに移動してLTのリハーサルをしたものの、普通にやると半分いかないくらいのところで5分を超えてしまうんですよね(知っていた)。しばらく考えたところで、Advanced CourseのあとにあったExtra Courseをまるっと落として今の構成にすることを思いつき、無事LTの枠に収まるようになったのでした3

LT発表者はLTの時間中舞台の袖に順番通りに並んでスタンバイしているので、袖から他の人の発表を聞くという面白い体験ができます。通常の登壇と違って徐々に自分の番が迫ってくる感じがあって順番待ちが楽しいんですよね。僕は後半のほうだったんですが、みんなそれぞれのLTをやっていて、ここ数年できなかったLTの熱量を一箇所にまとめた感があって聞いていて楽しかったです。僕の直前の Adding custom rule for Rubocop in the 2 month of employment - Speaker Deckがすごくいい話で、会場が感動に包まれてしんみりとしたところであのLTを引っさげて登場。ギャップもふくめていい演出ができたかなとおもいます。

世はまさに”大パーサー時代”。一度は言ってみたかったセリフをあのホール、あの人数の前で言えたのは最高でしたね。

speakerdeck.com

togetter.com

この写真の組み合わせとても気に入っています。

セッションのときとLTのときで喋り方や雰囲気が全然違っていて、スイッチが入っていたというコメントをいただきました(自覚がなかった)。

その後は流れるようにOfficial Partyに参加。久しぶりに会う人も、オフラインで初めて会う人も、全くの初めての人も、いろいろな人とお話しできて気がついたらOfficial Party終わっていましたね...

Day 2 (5/12) - Lramaのmerge

この日も朝食を食べに珈琲美学アベにいったところ当然Rubyistに(略。この日は@hasumikinさんと会えたので朝食から会場につくまでずっと(PicoRubyの)parserの話をしていました。これまでhasumikinさんとじっくりparserの話をしたことがなかったんですが、それぞれ違うparserの開発者なのに、会話がこんなハイペースで進むんだ!!と感動しましたね。僕にとってRubyKaigi 2023の何気ないけど幸せに満ちた時間になりました。

前半はセッションを見つPRの準備をしていましたが、CIがなかなか全部通らない。あまりにもbuild周りの知見がなさすぎたので後半はHack Spaceでnobuの隣に陣取って、いろいろ教えてもらったり調査をしてもらったりして、なんとかLramaをrubyにmergeしました。

github.com

その後Leaner Drinkupでpreview1がリリースされたので、いまのpreview最新版はLramaの生成したparse.cをもとに動いています。

という流れがRubyKaigi 2023 参加報告とちょっとエモい話 - joker1007’s diaryの"エモい話"に繋がっています。

Lrama mergeにあたりいろいろな人に助けていただきました。 特にnobuさんにはbuild周りを助けていただきました。hsbtさんにはCIやBASERUBY周りを見ていただき、僕がPRにcommitをpushするたびにこけているCIのタスクを見ていただきました。成瀬さんにはリリースのタイミングを調整していただきました。ありがとうございました。

Day 3 (5/13) - The bison slayer

Day 4 (5/14) - RubyKaigiが終わらない

今年のRubyKaigiが自分の中であまりにも大きかったのでしょう。4日目になっても帰る気持ちになれなかったのです。美術館にいっても、気になっていた洋食屋さんにいっても、喫茶店にいっても、お土産を買っても、とにかく自分のなかでRubyKaigiが終わらない... RubyKaigiには7回目の参加ですがこんな気持ちになったのは初めてで、正直戸惑いました。お勧めされた浅間温泉にいって温泉に入ることで最終的には気持ちが落ち着いて、帰る気持ちになりましたが。

松本駅で会ったRubyistとも別れてこれでRubyKaigiも終わったなと思っていたのですが、なんと新宿駅で特急を降りたあとにばったりRubyistにあったので僕のRubyKaigiは新宿まで続いていたのでした。

RubyKaigi翌週 - 非連続な日常

RubyKaigiから帰ってきて少しずつ感覚も日常に戻り始めた矢先、RubyKaigiに行く前はまったく想像していなかったことが立て続けに起きたのでした。 実はLR parser, parser generator, parse.yをいじってみたい人は結構いて、いままでちょうど良いとっかかりがなかっただけなのではないかと思うようになりつつあります。

1. LramaにPRがくる

いろいろな人がLramaを触ったり読み始め、それぞれ興味がある部分にPRが送られてくるようになりました。

github.com

2. LramaへのPRが初めてのOSSへのPRという人が現れる

手を動かして振り返る RubyKaigi 2023 - connpass のLTでも発表があったのですが、初めてのOSSへのPRです!という人もいて、このプロジェクトやっていて本当によかったなと思いました。僕もOSSがあったから今こうしているので、その入り口になるのであればこんなに嬉しいことはないですね4

speakerdeck.com

3. LTでLramaの話がでてくる

同じく"手を動かして振り返る RubyKaigi 2023"でのLTで、ima1zumiさんもLramaのお話をしていました。

scrapbox.io

4. Lramaで自作言語のパーサーを書く人が現れる

自作言語などのパーサーの生成に使えるのはそうなんですが、こんなに早く使ってみたがでてくると思わなくてびっくりしました。

qiita.com

セッションについて

特に印象にのこったセッションを。

Implementing "++" operator, stepping into parse.y

speakerdeck.com

一見++を実装する"だけ"の平和な話になるはずが、rubyの処理系の上へ下へとparser以外の部分も含めていろいろなところを旅する話になっています。処理系をhackするというのはこういうことだという声が聞こえてくるような発表で、とても楽しかったです。

試している方法も様々で、生成するtokenを変えればいいというアイデアは僕からはでないですね... 物語の中盤でMatzのチケットへのコメントを引用して、つまりこういうことなんですよ。と説明していたのがシーンが印象的でした。僕がDay 1で宣言した”大パーサー時代”というものをまさしく体現した発表だったと思います。

このセッションはいくつもの解釈の仕方があるようで、聞いていた人によっておもしろかったポイントが違うという点もいいですね。

UTF-8 is coming to mruby/c

UTF-8の実装が存在しない世界の話から始まったのが印象的でした。この手の"XXがない世界の話"が個人的に大好きなんです。2023年に一からUTF-8周りの実装をできる機会が目の前にあるんですよ、というような趣旨のことを言っていて、その気持ちすごくよくわかると思ったのでした。僕もparser周りでEncodingについて考えないといけないので頑張ります5

speakerdeck.com

その他

通常のセッションとLTをやってみた感想

2つともやってよかったです、というのが素直な感想です。どちらもparserの話をしているのですが発表の核が結構違っていて、通常のセッションは未来の話がメインなのでいまのparse.yの魅力を伝える時間はないし、それを入れるとメッセージがぶれてしまいます。それを補完する関係にあるのが今回のLTで、LTは完全に今のparse.yの話に振り切ることができました。2回登壇機会があったので、通常のセッションでつかったドラゴンブックネタをLTでちょっとひねって使うといった遊びもできました。

自分が実装したメソッドがかるたに収録されるという実績を解除した

RubyVM::AbstractSyntaxTreeからparseメソッドが収録されました。かるたをきっかけにASTやparserに興味をもつ人が増えるといいなと思います。RubyVM::AbstractSyntaxTree.ofというさらに便利な玄人向けメソッドもあるので興味をもった人は試してみてください。

Rubyメソッドかるた裏話 - ESM アジャイル事業部 開発者ブログ

RubyKaigi 2024に向けて

来年に向けた話を最後にすこし。

今回Bisonというボスを倒すことに成功したわけですが、これは物語の最初に出てくる仲間がいない状態で戦う最初のボスなんですよね。

  • AST nodeの整理をする
  • LramaのRBS対応
  • universal parserを作り上げる
  • Named References/Typed Midrule ActionsといったBisonにある機能をLramaに移植する
  • User defined stackというアイデアで、Ripperのメンテナンスコストを下げられないか試す

などなどC/Ruby、パーサー/パーサージェネレーター問わずやるべきことがたくさんあります。とても一人でできることではないので、一緒に冒険してくれる仲間を募集中です。"去年は一人でBisonを倒しました、今年はこれだけの仲間と一緒にこのボスを倒してきました"という話ができるといいな。

ruby-jp slackに#lr-parserを作ったので、ご興味のある方ぜひぜひお越しください。

ruby-jp.github.io

RubyKaigi 2023 お疲れ様でした、ありがとうございました!


  1. Ruby Committers vs the World - RubyKaigi 2018 4:00ぐらいから
  2. 実際は翌日の発表の緊張でブルーな感じになっており、食事に行ったさいにはみなさんに励ましてもらっていました。その節はありがとうございました。
  3. 実際に収まっていたかどうかは自分の目で確かめてみよう!
  4. そういえば自分がOSSに関わり始めたのが2013年なので、10年後にこうなるとはなぁ
  5. むしろいままで考えずにこれたのが不思議

Ruby Parser開発日誌 (8) - Universal Parserへの道

前回のあらすじ

Ruby Parser開発日誌 (7) - doについて考える - かねこにっき

Rubydoのもつ複雑さを中心にMaintainabilityの改善方法について考えました。Practical LR Parser Generationで紹介されているNonterminal attributesというアプローチにprecedence(優先度)をさらに組み合わせることで、lexerの状態として管理しているものを構文に組み込むことに成功したのでした。

ところでRuby Committers vs The Worldの20222021をあらためてみると、おもに次の3つが解くべき問題だと言われています。

  1. Usability (Error-tolerant parser)
  2. Maintainability
  3. Universal Parser

1と2についてはすでに分析と実装をしてきました。なので今回はUniversal Parserについて考えていきたいと思います。

なぜUniversal Parserが必要なのか

端的にいえばCRuby以外でもparserを使いまわしたいからです。これまで見てきたようにCRubyのparserとlexerはそれなりに複雑で、一から実装するのは結構大変な作業になります。またRubyは毎年リリースされ、その度にparserの実装の詳細も変化します。自前で実装した場合は毎年その変更にキャッチアップする必要があります1

CRuby以外でもparserを使いたい例としては

  • mruby, PicoRubyといったC言語による他のRuby実装
  • JRuby, TruffleRuby, rurubyといったC言語以外のRuby実装
  • sorbetなどのtool

などがあげられます。これら様々なプロジェクトで1つのparser実装を使いまわしたいというのがUniversal Parserの目的になります。

なぜ今のparserがUniversalでないのか

現在の"parse.y"から生成されるコードはCRubyの他の機能に依存しているため、これを使おうとするとrubyのバイナリ一式が必要になってしまいます。 例えばメモリ管理。parser用のデータ、AST用のデータ、パース中に使う一時的なデータ(heredocの解析用のデータ)など、入力をパースしてASTを返すまでにも様々な形でメモリを管理する必要があります。現在これらはCRubyのメモリ管理、つまりGCの仕組みによって管理されています2

こういったCRubyの他の機能への依存を減らしていき、parserだけを共有ライブラリとして提供するのがゴールです。

Universal Parserへの道

CRubyの提供する関数やマクロへの依存を"parse.y"から剥がしていくのですが、これにはおおきく2つの方向があります。

1. "parse.y"に対して外から関数を渡す

具体的にはrb_parser_config_structという構造体を用意して、必要な関数をこの構造体経由で"parse.y"に渡すようにします。 CRubyの場合はいままでと同じ関数が使えるので、それらを使うようにします

void
rb_parser_config_initialize(rb_parser_config_t *config)
{
    config->counter = 0;

    config->st_functions.nonempty_memcpy = nonempty_memcpy;

    config->malloc   = ruby_xmalloc;
    config->calloc   = ruby_xcalloc;

    config->ary_new           = rb_ary_new;
    config->ary_push          = rb_ary_push;
...

2. 一部の関数を切り出したりコピーしたりして共有ライブラリに含める

"node.c"の一部のようにparserに密接に関係する処理は別ファイルに切り出したうえで直接共有ライブラリに含めてしまったほうがよかったりします。また"st.c"に関しては規模が大きくないのと、他のRubyの関数への依存が比較的少ないので必要な部分を別ファイルにコピーしました。

こうして作られた"parse.o", "node2.o"と"st.c"をコピーして編集した"st2.o"をlinkしてshared libraryをつくります

実際にshared libraryを呼び出して1 + 2をparseするために必要な関数がこちらになります。

$ ./myparser
SCOPE:
    OPCALL:
    id: +
        LIT:
        lit: (number) 1
        LIST:
            LIT:
            lit: (number) 2

必要な関数を整理する

rb_parser_config_struct構造体をみるとわかるのですが、必要とする関数は現時点で209個あります。これを全部渡してくださいというのは流石にちょっと大変なので、いくつかのパターンに分類しながら必要な関数を減らせないか考えてみましょう。

メモリ管理用の関数

mallocfreeといった関数は外から渡せるようにしておいたほうが便利でしょう。

imemo関連

imemoというのはCRubyの内部で使うデータをGCに管理させるための仕組みです。例えば文字列リテラルやheredoc、%記法のパースのために閉じ記号やinterpolationをするかどうかといったデータを管理する必要があり、それらはimemo_parser_strtermというデータ構造で管理しています。このあたりはCRubyの内部実装なのでGCの管理から外すことでimemoへの依存を消していきたいです。

リテラルオブジェクト関連

1"a"といったリテラルはparserの中ですでにCRubyのオブジェクトに変換されています。ということはINT2FIXrb_str_newといったRubyのオブジェクトをつくるための関数が必要になります。Rubyの処理系をつくるのであればそのような関数を用意することはさほど難しくないと思いますが、C, C++, Rustなどでツールを作りたい場合には必ずしも各種オブジェクトを実装しているとは限りません。例えば型などの静的解析を行う場合には各種オブジェクトの種類(Integer, Stringなど)が分かれば十分かもしれません。

そのような場合にはparserからオブジェクト生成のロジックを完全に切り離したほうがよいでしょう。

# +- nd_body:
#     @ NODE_LIT (id: 0, line: 1, location: (1,0)-(1,3))*
#     +- nd_lit: 123 (これはCRubyのInteger object)

となっているものを、文字列で保持するように変更することでオブジェクトの生成を完全にparserから切り離すことができます。

# +- nd_body:
#     @ NODE_LIT (id: 0, line: 1, location: (1,0)-(1,3))*
#     +- nd_lit: "123" (char *)
#        len: 3
#        type: Integer

GC関連

obj_write(RB_OBJ_WRITEの実態)などを渡す必要があります。これはリテラルオブジェクトとも関係するのですが、CRubyのオブジェクトを扱う場合適切にwrite barrierを設定したり、markをする必要があります。

メモリ管理の方法にもよりますが、ナイーブな実装で共有ライブラリを使う場合は特に何もしない関数を渡せばよいです。とはいえほとんどのケースで独自のobj_writeを渡したいケースはないと思うのでparserから追い出していきたいです。

文字列関連

isspaceisalnumなどの関数は実装も複雑じゃないのでまるっとコピーするなどして共有ライブラリに含めるのがよいかなと思っています。

例外関連

SyntaxErrorについてはparserの外側で例外を発生させているのでよいとして問題はrb_eArgErrorなんですが、こちらはエラー用の処理を外から渡してもらうことになると思います。

parserで行っているobjectの操作

-1をパースするときに直接Integerを操作するnegate_litのように、objectを直接操作する箇所があります。この関数を実装するのにFIXNUM_Pなどのobjectの種類を調べる関数/マクロ、bignum_negaterational_set_numなどobjectの種類に合わせた関数がそれぞれ必要になります。IntegerをあらわすNodeにマイナスを表すフラグをつける、もしくはNODE_NEGを導入するなどしてこの手の操作をparserから追い出していきたいところです。

その他

CRubyの機能のなかにはASTにNodeを追加してくる機能があります。例えばshareable_constant_valueマジックコメントは定数への代入時にrb_mRubyVMFrozenCoreのメソッドを呼び出すnodeを追加することがあります。make_shareable_nodeなどをみると確かにNODE_CALLなどを追加しています。こういうのは"compile.c"側でやるように書き換える必要があるでしょう。

# shareable_constant_value: experimental_everything
FOO = Set[1, 2, {foo: []}]
$ ruby -v --dump=p test.rb
ruby 3.2.0 (2022-12-25 revision a528908271) [arm64-darwin21]
...
# +- nd_body:
#     @ NODE_CDECL (id: 0, line: 2, location: (2,0)-(2,26))*
#     +- nd_vid: :FOO
#     +- nd_else: not used
#     +- nd_value:
#         @ NODE_CALL (id: 15, line: 2, location: (2,0)-(2,26))
#         +- nd_mid: :make_shareable
#         +- nd_recv:
#         |   @ NODE_LIT (id: 13, line: 2, location: (2,0)-(2,26))
#         |   +- nd_lit: #<Class:0x000000010322cd20>
#         +- nd_args:
#             @ NODE_LIST (id: 14, line: 2, location: (2,0)-(2,26))
...

まとめ

Universal Parserが必要な理由およびUniversal Parserをどのように実装するかを検討し、実際に共有ライブラリをつくり、その共有ライブラリを活用して1 + 2というスクリプトをパースしてみました。

共有ライブラリの使い勝手を良くするには使う側で用意しないといけない関数の数を減らしていく必要があります。具体的には

  • メモリ管理の方法の変更
  • リテラルオブジェクトの生成の中止
  • "compile.c"へのロジックの移動

といった作業をしていかなくてはなりません。

とはいえこれで、parserにまつわる3つの問題について目処がたったと言えます。

  1. Usability (Error-tolerant parser)
  2. Maintainability
  3. Universal Parser

RubyKaigi 2023ではここまでの議論を踏まえてRuby Parserの未来の話をします。parserに興味をお持ちの方、当日お会いしましょう!

rubykaigi.org


  1. parser gemには"rubyXX.y"というファイルがversionごとに管理されており、本当にすごいと思います。
  2. NODEの管理は Ruby の NODE を GC から卒業させた - クックパッド開発者ブログ によってGCから切り離されていますが、rb_ast_tは引き続きimemoとしてGCが管理しています。

Ruby Parser開発日誌 (7) - doについて考える

前回のあらすじ

Ruby Parser開発日誌 (6) - parse.yのMaintainabilityの話 - かねこにっき

Rubyのparserの複雑さを分析し、parserとlexerの密結合を解消に挑戦しました。Practical LR Parser Generationという論文を参考に条件付き生成規則をLramaに実装し、Rubyのparserに適用してみましたが一部でconflictが発生してしまいました。

今回は引き続きdoを中心に密結合を解消できないか考えていきましょう。

問題を整理する

doについて理解を深める

doの何が問題なのかもう一度考えてみましょう。Rubyにおいてdoは複数の箇所で使われます。

# lambdaのdo (keyword_do_LAMBDA)
-> do e1 end

# whileのdo (keyword_do_cond)
while true do e1 end

# 引数の括弧なしでのメソッド呼び出しのdo (keyword_do_block)
cmd a1, a2 do e1 end

# 引数の括弧ありでのメソッド呼び出しのdo (keyword_do)
m(a1, a2) do e1 end

このような文法をサポートするうえで問題になるのがS/R conflictやR/R conflictです。 たとえばwhileの条件の部分にメソッド呼び出しが書かれたとき、

# while (m(a1, a2)) do e1 end と解釈される。つまりdoはwhileにくっつく
while m(a1, a2) do e1 end

もしくは引数の括弧なしでのメソッド呼び出しの引数に、引数の括弧ありでのメソッド呼び出しが書かれたとき、

# cmd a1, (m(a1, a2)) do e1 end と解釈される。つまりdoはcmdのblockになる
cmd a1, m(a1, a2) do e1 end

こういったケースでdoがどこにくっつくのかを決めておく必要があります。 Rubyではこれらを4種類の異なるトークンに分け、優先順位を決めています。優先度の高い方から順位に

keyword_do_LAMBDA > keyword_do_cond > keyword_do_block > keyword_do

となっています。具体的にはlexerのロジックとして実装されています。 各種doの間には優先度があるので、whileの条件式にlambdaを書くことはできますが、一方でwhileの条件式にdoブロックを伴うメソッド呼び出しを書くことはできません。

# OK
while -> do e1 end do e2 end

# NG
while cmd a1, a2 do e1 end do e2 end
-e:1: syntax error, unexpected `do', expecting end-of-input # "do e2 end"のdoでSyntaxError
while cmd a1, a2 do e1 end do e2 end
-e: compile error (SyntaxError)

doに関するその他の特徴として、括弧のなかに入ると外側に関係なく再びdoを書くことが可能になるという特徴があります。

#  NG
cmd a1, m(a1, a2) do e1 end do e2 end

# OK
cmd a1, (m(a1, a2) do e1 end) do e2 end

# OK
cmd a1, [m(a1, a2) do e1 end] do e2 end

# OK
cmd a1, {m(a1, a2) do e1 end => 1} do e2 end

parserの気持ちになって考える

前回、条件付き生成規則を単純に利用しただけではうまくいきませんでした。しかし冷静に考えてみると今のRubyのparseは全ての局面でどう振る舞うべきか(shiftするべきかreduceするべきか)わかっているわけです1。見方を変えればparserをメンテナンスしている人もまたどう振る舞うべきかわかっているわけです。

具体例をいくつかあげると以下のようなケースでそれぞれshfit/reduceを判断できているわけです。

m(a1, a2) do e1 end
          ~~ ここではshiftする

cmd a1, m(a1, a2) do e1 end
                  ~~ ここではreduceする

cmd a1, (m(a1, a2) do e1 end)
                   ~~ ここではshiftする

ということは何かしらの方法で我々の意図をparser generatorに教えてあげることができれば、その通りに振る舞ってくれるparserを作ることができるかもしれません。

これまでの分析でわかっている要件を整理してみましょう。

  1. 同じにみえるdoに対してどのくらい結びついているかという強さのようなものを宣言したい。lambdaのdoは一番強いなど優先度がある。
  2. その強さは常に一定ではなく、外側の構文によって変化する。whileのなかのメソッド呼び出しと、メソッド呼び出しの中のwhileで許可されるかどうかが変わってくる。

Nonterminal attributesを拡張する

じつは私たちはこの2つの要件を満たすことができそうなテクニックにすでに出会っています。 まず一つ目の結びつきの強さのようなものといえばPrecedenceが思い浮かぶことでしょう。Bisonに限らず多くのparser generatorで実装されていると思います2*/+-よりも優先されるということをparserの定義ファイルで宣言することで、本来であればconflictしてしまうようなルールを書くことができます。

%left  '+' '-'
%left  '*' '/' '%'

arg : ...
      | arg '+' arg
      | arg '-' arg
      | arg '*' arg
      | arg '/' arg

parser generatorがstateとその遷移を計算するときに、直前のtokenと次のtokenの優先度を比較することでconflictを解消してくれます3

arg '+' arg '*' arg
            ~~~ '*' は '+'よりも強いのでshiftする

arg '*' arg '+' arg
            ~~~ '+' は '*'よりも弱いのでreduceする

つぎに二つ目の外側の構文によって変化するという点ですが、これは前回紹介したNonterminal attributesが使えそうです。

C++やRustで<...>のなかで比較演算子Greater Thanの>が出てきて欲しくないとか、Golangでif ... {}の条件のexpressionのところで{}が末尾にきて欲しくないという例があげられています。これってまさしく「この条件のときはこのルールを適用しない」という話ではないですか。

Nonterminal attributesはいわゆる状態をルール間で受け渡すことができる仕組みと言えます。

Let's 実装

さっそくLramaに実装したものがこちらになります4

github.com

今回の拡張のポイントを見ていきましょう。アクションなどは一部省略しています。

/*
 * keyword_do:
 *   do_LOWEST  = 0.
 *   do_BLOCK   = 1. keyword_do_block  (CMDARG_P)
 *   do_COND    = 2. keyword_do_cond   (COND_P)
 *   do_LAMBDA  = 3. keyword_do_LAMBDA (lambda_beginning_p)
 *   do_HIGHEST = 4.
 */
%int-attr keyword_do do_LOWEST do_BLOCK do_COND do_LAMBDA do_HIGHEST -- (1)

lambda      : tLAMBDA f_larglist(do_LOWEST) lambda_body(do_LAMBDA)  -- (2)

expr_value_do   : expr_value(do_LOWEST) do(do_COND)

block_call  : command do_block(do_BLOCK)

command_args    :  call_args(do_LOWEST)

primary     | tLPAREN compstmt(do_HIGHEST) ')' -- (3)

program     :  top_compstmt(do_HIGHEST) -- (4)
  1. 特定のtoken(ここではkeyword_do)に対してAttributeを宣言する。今回はkeyword_do以外に3つのtokenが必要だったのでdo_BLOCK, do_COND, do_LAMBDAを優先度の低い順に定義し、他に最も優先度の低いdo_LOWESTと最も高いdo_HIGHESTを定義している。
  2. lambdaの解析、whileなどの条件式の解析、コマンド呼び出しの解析の箇所でそれぞれ優先度を指定する。これらはいずれも右側のdoの方が優先される。例えばlambda_bodyの中にlambdaのdoが宣言されており、その優先度は他のどのdoよりも高いので左側(f_larglist)のdoの優先度を最低にしている。
  3. 括弧のなかでは再びdoの結合の仕方がもとに戻るため、括弧で囲まれた非終端記号では再度優先度を最大にする。
  4. スクリプトの解析をはじめたときの初期値は優先度最大にする。

実際にRubyのparse.yに応用してみる

RubyにLramaをインストールし、parse.yも書き換えたものはこちらです。

github.com

test-allも通っています

Lramaの実装について

parser generator側ではどういう実装になっているのかも気になるところだと思います。 端的にいうとLR parserの状態を細かく分けることでこの機能を実現しています。

そもそもLR parserの状態というのはいまどのルールのどこを解析しているのかを示すものでした5。例えばifに関する1つのルールを細かく分類して状態にしています。

primary: k_if expr_value then compstmt if_tail k_end
• k_if expr_value then compstmt if_tail k_end # ifの直前にいる
k_if • expr_value then compstmt if_tail k_end # ifの直後にいる
k_if expr_value • then compstmt if_tail k_end # thenの直前にいる
...

Attributesの機能を実装するときにはこれらの状態に加えてAttributesの値も含めて状態を作っています。前回の例でいえばDO_ALLOWEDがtrueかfalseか、今回の例でいえばkeyword_doの値がdo_LOWEST (0)からdo_HIGHEST (4)のどれであるかを考慮した状態を管理しています。

片方はshift、もう片方はreduceを期待する2つのスクリプトがあったとします。

# (1) doはメソッド呼び出しのdoなのでshiftしてほしい
m(a1, a2) do e1 end

# (2) doはwhileのdoなので"m(a1, a2)"でreduceしてほしい
while m(a1, a2) do e1 end

いままではどちらも同じ状態 105として管理していました。全く同じ状態でdoが来たときに一方ではshift、一方ではreduceをしてほしいと言えばそれはconflictなので、"do""do for condition"のようにtokenを分けてあげる必要がありました。分けた以上はlexerがどのタイミングでどちらのtokenとして切り出すか知っている必要があり、それがparserとlexerの密結合につながっていたのでした。

$ ruby -v
ruby 3.2.0 (2022-12-25 revision a528908271) [arm64-darwin21]

$ ruby --dump=y -e 'm(a1, a2) do e1 end'
Stack now 0 2 105
Reading a token
...
Next token is token "`do'" (1.10-1.12: )

$ ruby --dump=y -e 'while m(a1, a2) do e1 end'

Stack now 0 2 95 385 105
Reading a token
...
Next token is token "`do' for condition" (1.16-1.18: )

Attributesの機能を使うことでwhileなどの条件式の解析中であるという状態を状態 544という別の状態として管理することで、同じ"do"であっても片方ではshiftをし、もう片方ではreduceをすることができるようになりました。

$ ./miniruby -v
ruby 3.3.0dev (2023-04-08T08:51:29Z lrama-attributes_1 c26bd5ab28) [arm64-darwin21]

$ ./miniruby --dump=y -e 'm(a1, a2) do e1 end'
Stack now 0 2 105
Reading a token
...
Next token is token "`do'" (1.10-1.12: )

$ ./miniruby --dump=y -e 'while m(a1, a2) do e1 end'
Stack now 0 2 95 385 544
...
Next token is token "`do'" (1.16-1.18: )

Lramaの生成するreportも見ておきましょう。一見同じにみえる状態ですが一方はdo_HIGHEST、もう一方はdo_LOWESTになっています。またdo_HIGHESTの場合はrule 343のlookahead setよりも優先度が高くなっているのでshift、逆にdo_LOWESTの場合はrule 343のlookahead setが優先されるのでreduceするようになっています。

State 105  -- 'm(a1, a2) do e1 end'の場合はこっち

  343 primary: method_call •  ["end-of-input", "`rescue'", "`ensure'", "`end'", "`then'", "`elsif'", "`else'", "`when'", "`in'", "`and'", "`or'", "`if' modifier", "`unless' modifier", "`while' modifier", "`until' modifier", "`rescue' modifier", "dummy end", '.', "**", "<=>", "==", "===", "!=", ">=", "<=", "&&", "||", "=~", "!~", "..", "...", "<<", ">>", "&.", "::", "=>", "{ arg", "'}'", tLAMBEG, '?', ':', '>', '<', '|', '^', '&', '+', '-', '*', '/', '%', '}', '[', ',', ')', ']', ';', '\n'] (keyword_do: 4)
  344        | method_call • brace_block (keyword_do: 4)
  381 k_do: • "`do'" (keyword_do: 4)
  473 brace_block: • '{' brace_body '}' (keyword_do: 4)
  474            | • k_do do_body k_end (keyword_do: 4)

    "`do'"  shift, and go to state 329
    '{'     shift, and go to state 330

    $default  reduce using rule 343 (primary)

    k_do         go to state 333
    brace_block  go to state 427

    Conflict between rule 343 and token "`do'" resolved as shift ( < "`do'").

State 544 -- 'while m(a1, a2) do e1 end'の場合はこっち

  343 primary: method_call •  ["end-of-input", "`rescue'", "`ensure'", "`end'", "`then'", "`elsif'", "`else'", "`when'", "`in'", "`do'", "`and'", "`or'", "`if' modifier", "`unless' modifier", "`while' modifier", "`until' modifier", "`rescue' modifier", "dummy end", '.', "**", "<=>", "==", "===", "!=", ">=", "<=", "&&", "||", "=~", "!~", "..", "...", "<<", ">>", "&.", "::", "=>", "{ arg", "'}'", tLAMBEG, '?', ':', '>', '<', '|', '^', '&', '+', '-', '*', '/', '%', '}', '[', ',', ')', ']', ';', '\n'] (keyword_do: 0)
  344        | method_call • brace_block (keyword_do: 0)
  381 k_do: • "`do'" (keyword_do: 0)
  473 brace_block: • '{' brace_body '}' (keyword_do: 0)
  474            | • k_do do_body k_end (keyword_do: 0)

    '{'  shift, and go to state 1032

    $default  reduce using rule 343 (primary)

    k_do         go to state 1035
    brace_block  go to state 1141

    Conflict between rule 343 and token "`do'" resolved as reduce ("`do'" < ).

既存の機能との関係

Bisonに詳しい人の中にはprecedence declarationsRuleへのprecedenceとどう違うのか疑問に思う方もいると思います。

前者は以下のような設定でトークン間に優先度(と結合の方向)を設定する機能です。今回のAttributesの機能はひとつの同じtoken doに対して複数の優先度を設定したいという点が異なります。

...
%left  '+' '-'
%left  '*' '/' '%'
%right tUMINUS_NUM tUMINUS
...

後者は生成規則に優先度を設定できるものですが、これだけでは"whileの条件式のなかでは優先度を下げる"といった細かい指定ができません。指定しようとすると生成規則を分割する必要がありますが、前回議論したようにそれでは煩雑すぎます。

command_rhs  : command_call   %prec tOP_ASGN

まとめ

Nonterminal attributesだけではRubyの文法を整理しきれないことがわかったので、1つのtokenに対して経路に応じて複数の優先度を設定することでconflictを解消する方法を新しく考案しました。この方法を使うことで4つに分かれていたRubydo関連のtokenを1つに統合できることがわかりました。またこの方法は既存のprecedence機能が解決している問題と異なる問題に有効であることを確認しました。

ちなみにこの手法、Practical LR Parser GenerationのNonterminal attributesに対しても新規性があるかもしれないと思っています。論文のなかでNonterminal attributesをprecedenceにも応用できることは触れられていますが、私の理解が正しければそれは既存のBisonなどが提供している機能を別の方法でも実現できるということまでしか言っていないはずです。また既存のBisonのprecedenceアルゴリズムの延長に実装可能ということも今回示すことができたのではないかと思います。

parse.yのMaintainabilityの話はdoだけではないので、次回も引き続き別のMaintainability上の問題を見ていきたいと思います。

最後に宣伝ですが、RubyKaigi 2023ではRuby Parserの未来の話をします。parserに興味をお持ちの方、当日お会いしましょう!

rubykaigi.org


  1. Rubyのparserはconflictが0になるようにチェックが入っています
  2. Bisonの場合はContextual Precedence (Bison 3.8.1)を参照
  3. How Precedence (Bison 3.8.1)が詳しい
  4. Branchだと今後コミットが変わる可能性があるので、Commitでいうと https://github.com/yui-knk/ruby/commit/7f8e1ea91434a2793ea4f26ab709de6942e49f55
  5. 詳しくはRuby Parser開発日誌 (3) - かねこにっきを参照

Ruby Parser開発日誌 (6) - parse.yのMaintainabilityの話

前回のあらすじ

Ruby Parser開発日誌 (5) - Lrama LALR (1) parser generatorを実装した - かねこにっき

Error Recoveryを実装するためにLrama LALR (1) parser generatorを実装しました。 Error Recoveryについては目処がたったので今回はparse.yのMaintainabilityをいかにして改善するか考えたいと思います。

parse.yの難しさ

Rubyのparse.yの難しさについては聞く人によって異なる回答が返ってくるところですが、おおよそ以下のようにまとめることができると思います。

  1. ファイルの行数が多い
  2. shift/reduce conflictやreduce/reduce conflict時に何が起きているか分かりにくい
  3. Bisonが原始的な記法しか提供していないので全ての規則を書く必要がある1
  4. Ripperとコードベースを共有していて見通しが悪い
  5. parserとlexerが密結合している

RubyのparserのMaintainabilityに関する発表といえばstable branch maintainerのnagachikaさんによるkeynote All bugfixes are incompatibilities - RubyKaigi 2019があります。このkeynoteでは"Fixing SyntaxError caused another SyntaxError"という話で3つの具体例をあげてnagachikaさんが直面した問題を説明しています2。これらの問題は5番目の"parserとlexerが密結合している"という点が原因だと思うので、parserとlexerがなぜ密結合しているのか、密結合がどのような問題を引き起こしているのか、どのようにして解消するかについて見ていきましょう。

parserとlexerがなぜ密結合しているのか

ruby/parse.y at v3_2_0 · ruby/ruby · GitHub をみるとわかるようにRubyのparserに関する構造体には結構な数のメンバが定義されています。なかでも特に以下のメンバはlexerが切り出すtokenの種類をコントロールするのに使われます

  • enum lex_state_e state: 現在13種類の状態が定義されている。いろいろなtokenの切り出しに影響する
  • int paren_nest: (, [, {の現時点での深さをカウントしている。lambda (-> {})のparseに使われる
  • int lpar_beg: lambda (-> {})のparse開始時点のparen_nestを保持している。これもまたlambda (-> {})のparseに使われる
  • int brace_nest: {の現時点での深さをカウントしている。string interpolation ("#{var}")のparseに使われる
  • stack_type cond_stack;: while ... do などのdoのparseに使われる
  • stack_type cmdarg_stack: foo 1, 2 do などのdoのparseに使われる

これらの状態はlexer(parser_yylex関数)で更新されることもありますし、parserのアクション内部で更新されることもあります。参照に関してはlexerからの参照が圧倒的に多いです(見落としがなければparserでは参照してないはず)。というのもLALR parserはtokenの種類によって遷移が変化することはあってもその他の方法で遷移を変更することはできないので、parserの中でlexerの状態をみても役に立たないことがほとんどだからです。

ではなぜこのように複数の状態を管理する必要があるのでしょうか。その役割は大きく3つに分けられると思います。

1. shift/reduce conflictやreduce/reduce conflictを解消するため

これについてはdoが有名でしょう。たとえばwhile m do ... end ...という入力があり、mまでshiftをして次のtokenがdoであるときにshiftをするかreduceをするかを決めなくてはいけません。shiftをするのであればwhile (m do ... end) ...と解釈することになりますし、reduceするのであればwhile (m) do ... end ...と解釈することになります。Rubyではwhileの条件部分にはdoを書けないという仕様(つまりreduceする)なのでwhile (m) do ... end ...となります。

Matzにっき(2004-04-26) に書いてあるように、

lexerに手を回して予約語doに対して違うトークンを返すようにする。

という方法が現在のRubyで採用されています。具体的にはm do enddokeyword_doというtokenにし、while 1 do enddokeyword_do_condというtokenにして生成規則を定義します。そしてwhileをshiftしたときにparserのアクションでフラグをたてて(COND_PUSH(1)して)"do"という文字列からtokenを生成するときには、そのフラグをみて生成するtokenを変えます。違うtokenなのでconflictしなくなるということですね。

2. 特定の生成規則をある条件下では無効するため

先ほどの例を"whileのなかの条件式にはdoが書けない"例と捉えることもできます。ほかの例として"lambdaの仮引数には ... がきてはいけない"という例があります。 -> (a) {}-> (a=1) {}と書くことはできますが、-> (...) {}とは書けません。一方で生成規則を書く時はlambdaの引数(f_larglist)であれ、メソッド定義の引数(f_arglist)であれ基本的には同じルール(f_args)を使いわましたいものです。

ちなみにこの差分の実装のためにlambdaの場合はtDOT3を返すことでsyntax errorにするという方法が採用されています3

3. tokenの長さを場所によって調整するため

lexerを素直に実装すると貪欲にマッチさせるような実装になるでしょう。例えば|から始まるtokenを切り出す時は以下のtokenに該当するかどうかを上から順に確かめていくことになるでしょう

  • ||= (tOP_ASGN)
  • || (tOROP)
  • | ('|')

しかしRubyではこれでうまくいかないケースがあって、例えば m do || end のようにblock parameterを省略したときには||とふたつの|が連続して出現します。かつては素直にtOROPを受け取るようにしていましたが、文法を定義するときには"||の間に省略可能なparameterがある"と定義したいものです。なので現在はlexerの方で調整をしています。doのあとはEXPR_BEGのstate bitがたつのでそれをみて切り出すtokenを変えています。

密結合が引き起こす問題

端的にいうと見通しが悪くなるというのが問題です。見通しが悪い理由は大きく2つに分類できます。

1. lexerの状態がどの範囲で影響するのかがわかりにくい

All bugfixes are incompatibilities - RubyKaigi 2019 の3番目の例がわかりやすいかと思います4。 もともとのチケットBug #11107は以下のコードが通らないというものでした。

-e:1: syntax error, unexpected keyword_do_block, expecting keyword_end
p ->() do a(1) do end end
                 ^

全体はp argsという引数の括弧を省略したメソッドの呼び出しになっていて、その場合はkeyword_do_blockというtokenを切り出すようにlexerの状態が設定されます。一方でa(1) do endのdoは括弧付きのメソッド呼び出しなのでkeyword_doを期待しています。チケットが報告された時点ではp ->() doの前後でdoに関する状態が変わらないようになっていて、lambdaのbodyのなかにも影響するようになっていたため上記のコードがsyntax errorになっていたのでした5

このようにある時点で設定したlexerの状態がその後どこまで影響するか把握しにくく、また把握するためにはparseとlexerの両方を理解しないといけないのが現状です。

2. ある時点でのlexerの状態がパッとみたときにわからない

lex stateの更新の仕方にもいくつかパターンがあります

  1. tokenの種類から一意に決まるパターン。たとえばtOROP(||)なら次はEXPR_BEGになるといったケースです
  2. tokenの種類と現在のstateから決まるパターン。tCMP(<=>)は通常はEXPR_BEGdef.(メソッド呼び出しのドット)のあとなどの一部の状況ではEXPR_ARGになるといったケースです
  3. parserのactionで更新するパターン。たとえばシングルトンメソッドの定義時(def obj.m; end)に.のあとはEXPR_FNAMEでないといけないので、その調整をparserのアクションで行っています。lexerは常に.のときにEXPR_DOTを設定するので、その効果をparserがわで調整する必要があります。

これらが絶妙に絡まり合った結果、parse中の各地点で結局lex stateがどうなっているのかを理解するのが難しくなっています。

問題の解消に向けて

yaccの宣言的な文法は条件が書けない。 「この条件のときはこのルールを適用しない」というような文法はありえない。

Matzにっき(2004-04-26) に書いてあるこの指摘はその通りで、確かにBisonにはそのような機能はありません。しかしBisonにないからといってそれが不可能とは限らないのです。 2022年に発表されたPractical LR Parser Generationという論文をみてみましょう。

However, the prevailing consensus is that automatic parser generation is not practical for real programming languages: ... As a result, virtually all modern languages use recursive-descent parsers written by hand, a lengthy and error-prone process that dramatically increases the barrier to new programming language development.

という嘆きから始まるこの論文は、LR parser generatorへの5つの改善を加えることでより便利で効率のよいparser generatorを提案しています。また論文の筆者は具体的な実装として https://github.com/jzimmerman/langcc を実装し、GolangおよびPythonのparserを生成できることを示しています。

Nonterminal attributes

複数の異なる技法が紹介されていますが、なかでも今回のケースで有効な技法がNonterminal attributesです。C++やRustで<...>のなかで比較演算子Greater Thanの>が出てきて欲しくないとか、Golangif ... {}の条件のexpressionのところで{}が末尾にきて欲しくないという例があげられています。これってまさしく「この条件のときはこのルールを適用しない」という話ではないですか。

Nonterminal attributesが具体的にどういった機能かというと

  1. 非終端記号にattributes(属性)を持たせられるようにする
  2. attributeはbinary-valued(二値)もしくはinteger-valued(数値)を指定できる
  3. 各生成規則ではattributeを用いた制約を書くことができる

Rubydoに注目してbinary-valuedのケースをみていきましょう。さっそくLramaに実装したので、簡略化した以下のようなparse.yを渡して各状態を確認していきます。

%{
// Prologue
%}

%union {
  int i;
}

%token <i> k_while
%token <i> k_do
%token <i> k_end

%token <i> tSTRING
%token <i> tIDENTIFIER

%attr DO_ALLOWED -- (1)

%%

program: stmt(DO_ALLOWED) ; -- (2)

stmt  : k_while expr(!DO_ALLOWED) k_do stmt k_end -- (2)
      | expr
      ;

expr  : tSTRING
      | command_call
      ;

command_call: tIDENTIFIER
            | @lhs(DO_ALLOWED) tIDENTIFIER k_do stmt k_end -- (3)
            ;

%%

通常のparse.yとの主な差分は

  1. %attr DO_ALLOWEDでattributeを宣言。このattributeがtrueのときはその中でdoを含むルールが出現してよいという意味
  2. @lhs(DO_ALLOWED) tIDENTIFIER k_do stmt k_endのように特定のルールに対してDO_ALLOWEDなときのみこのルールを使うことができるという指定をしている
  3. stmt(DO_ALLOWED)k_while expr(!DO_ALLOWED) k_do stmt k_endのように右辺の非終端記号に対してattributeを指定して値を書き換えている。whileの条件部分ではdoを含むルールは禁止するという意味

この文法ファイルからレポートをつくると確かにk_whileを経由してexprのparseをしているとき(State 1)ではcommand_callのルールが1つになっていて、そうでないとき(State 3)はcommand_callのルールが2つになっています。普段はdoが来ていいけどwhileの条件式の部分ではdoは来てはいけないというのが表現できています。

State 1 # こちらは command_call の2つめのルールがなく、doが禁止されている

    2 stmt: k_while • expr k_do stmt k_end (DO_ALLOWED: true)
    4 expr: • tSTRING (DO_ALLOWED: false)
    5     | • command_call (DO_ALLOWED: false)
    6 command_call: • tIDENTIFIER (DO_ALLOWED: false)  -- ここが1つしかない

    tSTRING      shift, and go to state 8
    tIDENTIFIER  shift, and go to state 9

    expr          go to state 10
    command_call  go to state 11


State 3 # こちらは command_call の2つめのルールがあり、doが許可されている

    6 command_call: tIDENTIFIER •  ["end of file", k_end] (DO_ALLOWED: true)
    7             | tIDENTIFIER • k_do stmt k_end (DO_ALLOWED: true)  -- doを含むルールがある

    k_do  shift, and go to state 12

    $default  reduce using rule 6 (command_call)

実際にRubyのparse.yに応用してみる

whileなどのdoの統合

keyword_do_condkeyword_doに統合したものがこちらになります。 whileなどの条件の箇所(expr_value_do)を処理するときはdoの出現を禁止する。doが出現するルール(brace_block)ではDO_ALLOWEDをチェックする。COND_PUSH(0)を呼んでいる箇所を探して、その後ではDO_ALLOWEDを渡すという修正です。これはconflictなしでいけました。

-expr_value_do  : {COND_PUSH(1);} expr_value do {COND_POP();}
+expr_value_do  : {COND_PUSH(1);} expr_value(!DO_ALLOWED) do {COND_POP();}
                    {
                        $$ = $2;
                    }
                ;

@@ -4301,7 +4301,7 @@ brace_block       : '{' brace_body '}'
                        nd_set_line($$, @1.end_pos.lineno);
                    /*% %*/
                    }
-               | k_do do_body k_end
+               | @lhs(DO_ALLOWED) k_do do_body k_end

コマンド呼び出しのdoの統合

続いてkeyword_do_blockkeyword_doに統合したものがこちらになります。

これは括弧なしのメソッド呼び出しの引数のdoに関するルールです。以下のように引数の中にはdoが書けないというものです(Syntaxとしては正しいけど外側のcmdのブロックとして扱われる)。

cmd a1 do e1 end

# こうではなく
cmd (a1 do e1 end)

# こう解釈される
cmd(a1) do e1 end

コマンドの引数を処理するときは(command_call)blockに関係するルールでDO_ALLOWEDをチェックするようにする。コマンドの引数を処理が始まったら(command_args)doの出現を禁止する。CMDARG_PUSH(0)を呼んでいる箇所を探して、その後ではDO_ALLOWEDを渡すという修正です。

 command_call   : command
-               | block_command
+               | @lhs(DO_ALLOWED) block_command
                ;

@@ -3086,7 +3086,7 @@ command_args      :   {
                        CMDARG_PUSH(1);
                        if (lookahead) CMDARG_PUSH(0);
                    }
-                 call_args
+                 call_args(!DO_ALLOWED)
                    {

今回はconflictが発生しました。ためしにBisonでcounterexamples6を計算させると、returnが絡む入力でShift/Reduce conflictする様子がわかります。 これまでは普通のdo(k_do)ならshift、コマンドのdo(k_do_block)ならreduceと棲み分けができていたので衝突していなかったのですが、今回その2つを統合したので衝突するようになってしまいました7

    shift/reduce conflict on token "`do'":
      341 primary: method_call •
      379 k_do: • "`do'"
      Example: k_return method_call • "`do'" do_body k_end call_op operation2 command_args
      Shift derivation
        command_call
        ↳ 75: command
              ↳ 89: k_return call_args
                             ↳ 288: command
                                    ↳ 83: primary_value                                                  call_op operation2 command_args
                                          ↳ 368: primary
                                                 ↳ 342: method_call brace_block
                                                                    ↳ 472: k_do            do_body k_end
                                                                           ↳ 379: • "`do'"
      Reduce derivation
        command_call
        ↳ 76: block_command
              ↳ 78: block_call                                                                                                              call_op2       operation2 command_args
                    ↳ 458: command                                                                       do_block                           ↳ 766: call_op
                           ↳ 89: k_return call_args                                                      ↳ 457: k_do_block    do_body k_end
                                          ↳ 289: args                                      opt_block_arg        ↳ 380: "`do'"
                                                 ↳ 299: arg_value                          ↳ 289: ε
                                                        ↳ 271: arg
                                                               ↳ 263: primary
                                                                      ↳ 341: method_call •

LALRパーサーから踏み出した実装をしたため、知らないうちに意図しないエッジケースが生まれてしまっています8。もしくは知らないうちに仮定するルールが増えているとも考えられます。どちらにせよこれは困ったことになりました。

まとめ

Rubyのparseの複雑さはparserとlexerが密結合していることが原因の一つであるという分析をし、そのなかでもとくにdoに関する問題をみてきました。 2022年に発表されたPractical LR Parser Generationという論文を参考に「この条件のときはこのルールを適用しない」という文法を導入してみた結果、一部では有効ですがある部分ではconflictが発生することがわかりました。どうしたものでしょうか...


  1. 例えばMenhirにはoptionnonempty_listといった便利機能があります。http://gallium.inria.fr/~fpottier/menhir/manual.html#sec32
  2. 動画の36:16あたりから
  3. p->lex.lpar_beg >= 0 && p->lex.lpar_beg+1 == p->lex.paren_nest かつ IS_lex_state_for(last_state, EXPR_LABEL) をみて -> (の中にいる状態だと初見で理解するのはちょっと難しいと思います。ちなみにIS_lex_state_for(last_state, EXPR_LABEL)のチェックをせずtDOT3を返すようにすると今度は-> (a = ...2) {}が通らなくなります。こういう絶妙なバランスがこの分岐で実装されているわけですね
  4. 動画の43:31あたり
  5. ちなみにwhile ->() do a(1) do end end do endは3.2でも通りません。これはlambdaf_larglistのあとでCMDARG_PUSH(0)はしているけどCOND_PUSH(0)はしていないためです。特に報告もあがっていないので、このままでいいと思います
  6. 便利なのでLramaにもほしいです。Finding Counterexamples from Parsing Conflicts参照
  7. 既存の文法が壊れるのを覚悟でk_return call_args(!DO_ALLOWED)というように禁止してもまだだめで、endless method definitionの中のargでのdoを禁止する必要があります。cmd a1, def m = obj.m1 do e1 endみたいなケースです。cmd a1, return a2 do e1 endvoid value expressionでSyntaxErrorになるので別途returnなどが出現できる位置を制御するattributeを定義するという手もありますが煩雑になります
  8. Practical LR Parser GenerationではAddendumでGoの型パラメーターの構文やPythonのパターンマッチングの構文に曖昧さがあるという指摘をしています