Ruby で packrat parser

packrat parsing というパーサの実装方法があります。特徴は、再帰下降パーサ + 無限先読み可能 + memoize*1 という感じです。僕が下手な説明をするより、英語わからなくても Haskell わかればわかる素晴らしいスライドPappy (reference implementation) を参照してもらったほうがいいです。

で、練習として Ruby で packrat parser を使って足し算と掛け算だけの電卓を作ってみました。

$ ruby arith.rb
1 + 2 + 3
=> 6
1 + 2 * 3
=> 7
1 + 2 * (3 + 4)
=> 15

文法はこんな感じ。

式      = 空白 加算式 文字列終端
加算式    = 乗算式 ("+" 乗算式)*
乗算式    = プリミティブ ("*" プリミティブ)*
プリミティブ = 数値 / "(" 加算式 ")"
(他省略)

以下は加算式とプリミティブの実際の定義。全コードは絶望的に長いので最後に載せます。

# 加算式 = 乗算式 ("+" 乗算式)*
def add
  cache(:add) do                   # memoize のためのラッパ
    v = mul                        # 乗算式読み込み
    vs = many do                   # 0 回以上の繰り返し
      s = sym                      # シンボル読み込み
      no_parse unless s == "+"     # シンボルが "+" 以外だったら失敗
                                   # バックトラックして別の候補を探す
      v2 = mul                     # 乗算式読み込み
      v2
    end
    vs.inject(v) {|v2, z| v2 + z } # 計算して返す
  end
end
# プリミティブ = 数値 / "(" 加算式 ")"
def prim
  cache(:prim) do                  # memoize のためのラッパ
    try(                           # 順序つき OR
      proc { v = decimal; v },     # 数値読み込み
      proc do
        s = sym                    # シンボル読み込み
        no_parse unless s == "("   # シンボルが "(" 以外だったら失敗
        v = add                    # 加算式
        s = sym                    # シンボル読み込み
        no_parse unless s == ")"   # シンボルが "(" 以外だったら失敗
        v
      end
    )
  end
end

try(proc { ... }, proc { ... }, ...) がいけてないのですが、他にいい書き方を思いつきませんでした。


ついでに Pappy を移植する感じで Parsing Expression Grammar の処理系も作ってみましたが、いまいちなのでそのうち。今のところ

  • 最適化を省いたのですごく遅い
  • エラーハンドリングがよくわからなくて実装できてないのですごくデバッグしにくい
  • Pappy の文法が微妙に気に入らない

という感じです。


で、packrat parsing / PEG の感触ですが、

  • (+) conflict とか考えなくていいのがとても幸せ
  • (+) 構文解析がプログラム的にどう行われているか想像しやすい (バックトラック) のがとても幸せ
  • (-) 文法のそこら中で空白を読み飛ばすことを明示しないといけないのを忘れやすい
  • (?) 実用性 (スケーラビリティ・モジュラリティ) はまだわからない
  • (?) 正規表現の代替になるかどうかは、やっぱりうまいリテラルが設計できるかどうか次第

という感じです。


以下ソース。

# packrat parsing ライブラリ
class Packrat
    def initialize(string)
        @string = string
        @index = 0
        @cache = {}
    end

    # memoize のためのラッパ
    def cache(s)
        if (@cache[s] ||= {}).include?(@index)
            v, i = @cache[s][@index]
            i ? (@index = i; v) : no_parse
        else
            begin
                i = @index
                v = yield
                @cache[s][i] = [v, @index]
                v
            rescue NoParse
                @cache[s][i] = nil
                raise
            end
        end
    end

    # パース失敗
    def no_parse
        raise NoParse
    end
    class NoParse < StandardError
    end

    # 順序つき OR
    def try(*ary)
        i = @index
        ary.each do |a|
            begin
                return a.call
            rescue NoParse
                @index = i
            end
        end
        no_parse
    end

    # 先読み AND
    def followed_by
        f = true
        i = @index
        begin
            yield
            f = false
        rescue NoParse
        end
        @index = i
        f ? no_parse : nil
    end

    # 先読み NOT
    def not_followed_by
        f = false
        i = @index
        begin
            yield
            f = true
        rescue NoParse
        end
        @index = i
        f ? no_parse : nil
    end

    # 0 回以上繰り返し
    def many
        try(
            proc { many1 { yield } },
            proc { [] }
        )
    end

    # 1 回以上繰り返し
    def many1
        v = yield
        vs = many { yield }
        [v] + vs
    end

    # 任意の 1 文字にマッチ
    def any_char
        c = @string[@index]
        @index += 1
        c ? c.chr : no_parse
    end

    # pred を満たす 1 文字にマッチ
    def satisfy_char
        c = any_char
        yield(c) ? c : no_parse
    end

    # 指定した 1 文字にマッチ
    def char(ch)
        c = any_char
        c == ch ? c : no_parse
    end

    # 指定した文字列にマッチ
    def string(str)
        str.each_byte do |ch|
            c = any_char
            no_parse if c != ch.chr
        end
        str
    end
end

# 足し算と掛け算だけの電卓
class ArithPackrat < Packrat
    # 式 = 空白 加算式 文字列終端
    # expr = space v:add !any_char -> {v}
    def expr
        cache(:expr) do
            space; v = add; not_followed_by { any_char }; v
        end
    end

    # 加算式 = 乗算式 ("+" 乗算式)*
    # add = v:mul vs:("+" mul)* -> { vs.inject(v) {|v2, z| v2 + z } }
    def add
        cache(:add) do
            v = mul
            vs = many do
                s = sym
                no_parse unless s == "+"
                v2 = mul
                v2
            end
            vs.inject(v) {|v2, z| v2 + z }
        end
    end

    # 乗算式 = プリミティブ ("*" プリミティブ)*
    # mul = v:prim vs:("*" prim)* -> { vs.inject(v) {|v2, z| v2 * z } }
    def mul
        cache(:mul) do
            v = prim
            vs = many do
                s = sym
                no_parse unless s == "*"
                v2 = prim
                v2
            end
            vs.inject(v) {|v2, z| v2 * z }
        end
    end

    # プリミティブ = 数値 ("(" 加算式 ")")*
    # prim = v:decimal -> {v} / "(":sym v:add ")":sym -> {v}
    def prim
        cache(:prim) do
            try(
                proc { v = decimal; v },
                proc do
                    s = sym
                    no_parse unless s == "("
                    v = add
                    s = sym
                    no_parse unless s == ")"
                    v
                end
            )
        end
    end

    # 数値 = (正規表現で \d にマッチする文字)+ space
    # decimal = cs:(c:any_char &{ c[/^\d/] })+ space -> { cs.join.to_i }
    def decimal
        cache(:decimal) do
            cs = many1 { satisfy_char {|c| c[/^\d/] } }
            space
            cs.join.to_i
        end
    end

    # 数値 = ("+" / "*" / "(" / ")") space
    # sym = s:("+" / "*" / "(" / ")") space -> {s}
    def sym
        cache(:sym) do
            s = try(
                proc { char "+" },
                proc { char "*" },
                proc { char "(" },
                proc { char ")" }
            )
            space
            s
        end
    end

    # 空白 = (正規表現で \s にマッチする文字)*
    # space = (c:any_char &{ c[/^\s$/] })*
    def space
        cache(:space) do
            many { satisfy_char {|c| c[/^\s$/] } }
        end
    end
end

while line = $<.gets
    puts "=> " + ArithPackrat.new(line).expr.to_s
end

*1:遅延評価をうまく使えば明示的に memoize しないで済みます。