実装Note - ruby構文解析の難しさ

Ruby は、不必要に構文が複雑になっている。最初期からある構文がまずく (曖昧で), 実装が複雑になっている。その割に独自の利便性の高い構文があるわけではない。CommonLisp のほうが、よほどできることが大きい。

サブセットと割り切って、簡単にした。

パーサの大きさ

けっこう小さなサブセットと割り切ったつもりだが、それでも合わせて3,000行に膨らんでしまった。

  • ruby 3.1
    • parse.y Bisonファイル 14,000行. 字句解析は 行9,230 辺りから parser_yylex() 関数.
    • lex.c gperf コマンドで生成. 入力 = defs/keywords ファイル
    • node.h AST ノードを定める。104 種類, すごい多い。
  • minilang
    • parse.yy Bisonファイル 1,200 行程度
    • lexer.cpp 手書きの字句解析器。1,700行程度
    • ast.h ノードを定める。36 種類。 Ruby ではメソッド呼出しになっている include, extend, attr_accessor, attr_reader, attr_writer を予約語化

字句解析は, minilang v0.6 では Quex という字句解析器生成系を使ったが、不十分で、結局手書きした。

Rubyは文脈自由文法ではない

文脈依存キーワード contextual keywords は、今どき、どのプログラミング言語でも見られる。例えば JavaScript の async はキーワードではない。C# も互換性維持のために多い。

Ruby の難しさはそこではなく、スクリプトの字面のつながり方だけでは構文が一意に定まらず、コンパイル時に意味を併用しなければならない。C/C++ も同様。(context-free grammar ではない.) 未実装 [incompat]

例えばこの記事「あなたが理解できない,たった一行のRubyのコード(動的言語に対する静的解析の限界)」は完全に誤認しているが、それでも, "/" の曖昧性を解決するためには, f が変数かどうかを踏まえなければならない。

Ruby
[RAW]
  1. # ランダムに定義を振り分ける
  2. if rand >= 0.5
  3. # 正規表現の関数として定義する
  4. # Crystal: Error: can't declare def dynamically
  5. def f( reg_exp )
  6. str = "gggh"
  7. #return str.match( reg_exp ) != nil
  8. end
  9. # Crystal: g を定義しても, f が定義されない経路を見破る
  10. # 24 | p f /g+h/i
  11. # ^
  12. # Error: undefined method '/' for Nil (compile-time type is (Int32 | Nil))
  13. g = 5
  14. else
  15. # 変数に数値を代入
  16. f = 2 # ここをコメントアウトすると、f は関数と見做される。
  17. # 静的解析で構文が決まる。元記事は誤認している。
  18. g = 1
  19. h = 2
  20. i = 1
  21. end
  22. # 演算結果を `p` で出力する
  23. #p f(/g+h/i) .. これは曖昧ではない
  24. p( f /g+h/i )
  25. # Ruby は半々でエラー. ただ、関数の f ではなく変数の f が見つからないエラー.
  26. # -> 構文解析は除算一択になっている。構文解析が曖昧なのではない。
  27. # undefined method `/' for nil:NilClass (NoMethodError)
  28. # p( f /g+h/i )
  29. # ^

正規表現リテラルかどうかの判定は、1パス目でやらなければならない。あらゆる文字が含まれうるので、先読みして決めることはできない。

意味は同じはずが、エラーになったり通ったり

if 式と後置 if とで挙動が異なる。抽象構文木 (AST) も異なる。

Ruby
[RAW]
  1. if a # undefined local variable or method `a' for main:Object (NameError)
  2. a = 42
  3. end
  4. a = 42 if a # これは通る。意味は同じだが挙動が異なる
  5. p a
  6. code = "a = 42 if a"
  7. ast = RubyVM::AbstractSyntaxTree.parse(code)
  8. pp ast
  9. #=> (SCOPE@1:0-1:11 tbl: [:a] args: nil body: (IF@1:0-1:11 (LVAR@1:10-1:11 :a) (LASGN@1:0-1:6 :a (LIT@1:4-1:6 42)) nil))
  10. # local-variable `LVAR` に解決されている。
  11. code = <<-EOS
  12. if a
  13. a = 42
  14. end
  15. EOS
  16. ast = RubyVM::AbstractSyntaxTree.parse(code)
  17. pp ast
  18. #=> (SCOPE@1:0-3:5 tbl: [:a] args: nil body: (IF@1:2-3:5 (VCALL@1:5-1:6 :a) (LASGN@2:4-2:10 :a (LIT@2:8-2:10 42)) nil))
  19. # local-variable or method `VCALL` が解決されていない。

上のスクリプトは、単に未代入のローカル変数の評価はエラーにしたらいいのでは? という話。

それはともかく, Ruby は Lisp-2 で、変数の名前空間と関数のそれが分かれている。変数と同名の関数があってよい (定義できる)。しかし、関数呼出しのカッコを省略できる (仕様がまずい) ので、字面から変数評価か関数呼出しかが区別できない。なので, if 式の条件式のほうは AST node VCALL (未解決) になっている。

1パス目では確かに, 単に f と書いたときにどちらの意味か分からないが、構築した AST を静的解析すれば、どちらか分かる。型チェックもここで入れることができる。[[まだ未実装]]