Python: Larkを使って構文解析器をつくってみた(その2)

こないだつくった構文解析器をちょっと改良、というか修正して、入力データをコマンド形式にしてみた。

コマンド形式、っていうのは、入力データ(の内部表現)を作るための一種の DSL だ。代入も制御構造もないシェルスクリプトみたいなものと考えてくれればいい。コマンド形式にしておけば、あとから機能追加によって入力データの項目が増えたとしても対応するコマンドを追加するだけ済み、パーサ(構文解析器)を修正する必要がない。

入力データの見た目はほとんど変わらなくて、* がなくなっただけ。これは、こないだのはデータの種類を表すラベルだったのに対して、今回のはコマンド名を表すからそれらしくした。

// Example for input data.
MODEL
  RO
GAMMA0.5  // %
  0.150
HMAX
  0.200
PLOT      // %
  0.0001
  0.0002
  0.0005
  0.001
  0.002
  0.005
  0.01
  0.02
  0.05
  0.1
  0.2
  0.5
  1.0
  2.0
  5.0
 10.0
END

各コマンドは、コマンド名(行の先頭から始まる必要がある)と 0 個以上の引数からなり、改行で終わる。空白文字で始まる行は前の行の続きとみなすのと// から行末までをコメントとするのもこないだと同じ。

文法定義ファイルはこうなった。

?script : statement+

statement : command arg* "\n"

command : /[A-Z0-9.]+/

arg : string
    | real

string : UCASE_LETTER+

real : FLOAT

%import common.UCASE_LETTER
%import common.FLOAT
%import common.WS_INLINE
%ignore WS_INLINE

コメントや継続行の処理も文法でできるようだけど、今回はパースする前の前処理でやってしまうことにした。そのほうが文法が楽だったから。

さて、Lark には lark.Interpreter というクラスが用意されている。このクラスの visit() メソッドを使うと、パースした木構造を他のデータ構造に変換する代わりに、ノードをたどりながら処理を実行できる。今回はコマンド名と引数リストを出力してるだけだけど、これでデータを構築する処理を書けばいいわけだ。

from lark import Lark
from lark.visitors import Interpreter
import sys
import re
from pprint import pprint


class MyInterpreter(Interpreter):
    def script(self, tree):
        print("SCRIPT")
        for c in tree.children:
            self.visit(c)

    def statement(self, tree):
        cmd = self.visit(tree.children[0])
        args = [ self.visit(a) for a in tree.children[1:] ]
        print("  STATEMENT")
        print("    COMMAND: " + cmd)
        print("    ARGS: " + str(args))

    def command(self, tree):
        return tree.children[0]

    def arg(self, tree):
        return self.visit(tree.children[0])

    def string(self, tree):
        return "".join(tree.children)

    def real(self, tree):
        return float(tree.children[0].value)


def contract(text):
    lines = text.splitlines(keepends=False)
    lines_new = []
    for line in lines:
        line = re.sub(r"//.*$", "", line)
        if len(line) == 0:
            pass
        elif line.startswith(" "):
            lines_new[-1] += line
        else:
            lines_new.append(line)
    return "\n".join(lines_new) + "\n"


with open("larksample2.lark", "r", encoding="utf-8") as grammar:
    parser = Lark(grammar.read(), start="script")

with open(sys.argv[1], "r") as f:
    input_data = contract(f.read())

tree = parser.parse(input_data)
MyInterpreter().visit(tree)

実行するとこうなった。

takatoh@apostrophe:larksample$ python larksample2.py larksample2.dat
 SCRIPT
   STATEMENT
     COMMAND: MODEL
     ARGS: ['RO']
   STATEMENT
     COMMAND: GAMMA0.5
     ARGS: [0.15]
   STATEMENT
     COMMAND: HMAX
     ARGS: [0.2]
   STATEMENT
     COMMAND: PLOT
     ARGS: [0.0001, 0.0002, 0.0005, 0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0]
   STATEMENT
     COMMAND: END
     ARGS: []

Python: Larkを使って構文解析器をつくってみた

構文解析器を「つくってみた」というと、四則演算や簡単なプログラミング言語をつくってみたというのをよく見かけるけど、個人的には何かのプログラムの入力データをパース(構文解析)することが多い。

四則演算ができたところで結局は「つくってみた」以上のものではないし、プログラミング言語を(本格的に)つくってみようという人もどちらかといえば稀だろう。

一方でプログラム(それが何であれ)というのはたいてい入力が必要なものだし、ファイルから入力を読み込むことも少なくない。ただデータが並んでいればいい、というのでなければ何らかのフォーマットを決める必要がある。JSON とか YAML とかいった汎用のフォーマットもあるけど、必ずしも目的に対して適当とは言えないこともある。

というわけで構文解析器(パーサ)の出番になるわけだ。

以前には Haskel のパーサコンビネータ(Parsec)や Ruby のパーサジェネレータ(Racc)を使ったことがあるけど、Python でははじめて。かるく調べてみたところ、タイトルに書いた Lark というライブラリが良さげだったので試してみることにした。

今回はずいぶん前に Haskell でつくったあるプログラムの入力ファイルをサンプルとしてとりあげる。↓こんなデータ。

// Example for input data.
*MODEL
  RO
*GAMMA0.5  // %
  0.150
*HMAX
  0.200
*PLOT      // %
  0.0001
  0.0002
  0.0005
  0.001
  0.002
  0.005
  0.01
  0.02
  0.05
  0.1
  0.2
  0.5
  1.0
  2.0
  5.0
 10.0
*END

* で始まる文字列がデータの種類を表すラベルで、その後に1つ以上のデータが空白で区切られて続く。改行は意味を持たない(空白とみなす)。ただし // から行末まではコメント。

さて、Lark ではまず解析対象の文法を定義したファイルを用意する。文法は EBNF ベースの構文で定義する。今回の例ではこうなった。

?input : model gamma hmax plot end

model : "*MODEL" modelname

modelname : UCASE_LETTER+

gamma : "*GAMMA0.5" real

hmax : "*HMAX" real

plot : "*PLOT" real+

end : "*END"

real : FLOAT

%import common.UCASE_LETTER
%import common.FLOAT
%import common.WS
%ignore WS
%import common.CPP_COMMENT
%ignore CPP_COMMENT

で、これを Lark に渡せばパーサを返してくれる。プログラムはこう。

from lark import Lark
import sys


with open("larksample.lark", "r", encoding="utf-8") as grammar:
    parser = Lark(grammar.read(), start="input")

with open(sys.argv[1], "r") as f:
    input_data = f.read()

tree = parser.parse(input_data)
print(tree)

たったこれだけで構文解析をして構文木を返してくれる。実行してみると:

takatoh@apostrophe:larksample$ python larksample.py larksample.dat
 Tree('input', [Tree('model', [Tree('modelname', [Token('UCASE_LETTER', 'R'), Token('UCASE_LETTER', 'O')])]), Tree('gamma', [Tree('real', [Token('FLOAT', '0.150')])]), Tree('hmax', [Tree('real', [Token('FLOAT', '0.200')])]), Tree('plot', [Tree('real', [Token('FLOAT', '0.0001')]), Tree('real', [Token('FLOAT', '0.0002')]), Tree('real', [Token('FLOAT', '0.0005')]), Tree('real', [Token('FLOAT', '0.001')]), Tree('real', [Token('FLOAT', '0.002')]), Tree('real', [Token('FLOAT', '0.005')]), Tree('real', [Token('FLOAT', '0.01')]), Tree('real', [Token('FLOAT', '0.02')]), Tree('real', [Token('FLOAT', '0.05')]), Tree('real', [Token('FLOAT', '0.1')]), Tree('real', [Token('FLOAT', '0.2')]), Tree('real', [Token('FLOAT', '0.5')]), Tree('real', [Token('FLOAT', '1.0')]), Tree('real', [Token('FLOAT', '2.0')]), Tree('real', [Token('FLOAT', '5.0')]), Tree('real', [Token('FLOAT', '10.0')])]), Tree('end', [])])

パースした結果が木構造になってるのがなんとなくわかる。とはいえさすがにこれは見づらいので、Tree クラスに用意されてる pretty() メソッドを使ってみよう。print(tree) を print(tree.pretty()) に変えてやればいい。

takatoh@apostrophe:larksample$ python larksample.py larksample.dat
 input
   model
     modelname
       R
       O
   gamma
     real    0.150
   hmax
     real    0.200
   plot
     real    0.0001
     real    0.0002
     real    0.0005
     real    0.001
     real    0.002
     real    0.005
     real    0.01
     real    0.02
     real    0.05
     real    0.1
     real    0.2
     real    0.5
     real    1.0
     real    2.0
     real    5.0
     real    10.0
   end

見やすくなったね。

この木構造をプログラムで利用しやすい形に変換するには lark.Transformer クラスを継承したクラスをつくってやる。プログラムはこうなった。

from lark import Lark
from lark import Transformer
import sys
from pprint import pprint


class MyTransformer(Transformer):
    def input(self, tokens):
        (model, gamma, hmax, plot, _) = tokens
        return {
            "model" : model,
            "gamma0.5" : gamma,
            "hmax" : hmax,
            "plot" : plot,
        }

    def model(self, tokens):
        (m,) = tokens
        return m

    def modelname(self, tokens):
        return "".join(tokens)

    def gamma(self, tokens):
        (g,) = tokens
        return g

    def hmax(self, tokens):
        (h,) = tokens
        return h

    def plot(self, tokens):
        return list(tokens)

    def end(self, tokens):
        return None

    def real(self, tokens):
        (r,) = tokens
        return float(r)


with open("larksample.lark", "r", encoding="utf-8") as grammar:
    parser = Lark(grammar.read(), start="input")

with open(sys.argv[1], "r") as f:
    input_data = f.read()

tree = parser.parse(input_data)
result = MyTransformer().transform(tree)
pprint(result)

lark.Transformer を継承した MyTransformer クラスの transform() メッソッドにパースした結果を渡してやると、変換した結果が返ってくる。今回は普通の辞書にした。

MyTransformer クラスに定義しているメソッドは、それぞれ文法ファイルで定義した構文要素に対応していて、transform() は構文木をたどりながらノード(つまり構文要素)に対応するメソッドを呼び出す。各メソッドの引数 tokens は子ノードのリスト。だからこれらのメソッドから適切な値を返せば、最終的に目的の構造を持ったデータ(今回は辞書)が手に入る。

これを実行するとこんなふうに出力される。

takatoh@apostrophe:larksample$ python larksample.py larksample.dat
 {'gamma0.5': 0.15,
  'hmax': 0.2,
  'model': 'RO',
  'plot': [0.0001,
           0.0002,
           0.0005,
           0.001,
           0.002,
           0.005,
           0.01,
           0.02,
           0.05,
           0.1,
           0.2,
           0.5,
           1.0,
           2.0,
           5.0,
           10.0]}

無事、目的のものが手に入った。

[追記]

インストール:

takatoh@apostrophe:larksample$ pip install lark-parser

参考ページ:

その他:

  • PyPI には lark てのと lark-parser てのがあるけどどう違うの?
  • lark.Transformer.transform() から呼び出される各メソッドは、子要素(を評価した値)のリスト tokens を引数にとり、自身を評価した値を返す。
  • 子要素とはつまり、文法ファイルで定義した : の右側の要素のこと。
  • ではあるんだけど、文法ファイルで文字列を直接使う(たとえば "*MODEL" のように)と、メソッドの引数リストに入らない。これ、気づくまでにちょっとかかった。
  • よく使いそうな文法要素(FLOAT とか UCASE_LETTER とか)は予め定義されてて、インポートして使うことができる。ドキュメントには全部は書いてないみたいなので GitHub のリポジトリでソースファイルを見るといい。