Common LispでLALR(1)のparserを作る2
前回:Common LispでLALR(1)のparserを作る1 - nptclのブログ
【変更】first-parse
を修正しました。
1. 続きです
Common Lispでひたすらparserを作っていきます。
前回は、shift, reduceについて解説しました。
今回はLR(1)をひたすら作っていきます。
ここらへんから、本当にひたすら作るだけになります。
作って作って作る作業ばかりなので、プログラミング持久走みたいになります。
飽きますし疲れますので注意。
2. アナフォリックマクロ
すみません、アナフォリックマクロを使わせて下さい。
これ本当に便利なんです。
基本的にはCommon Lisp標準の機能しか使わないようにしてるんですが、
こいつは使いたいです。
アナフォリックマクロってなんだよという前に、 まずは定義を示します。
(defmacro aif (expr then &optional else) `(let ((it ,expr)) (if it ,then ,else))) (defmacro awhen (expr &body body) `(aif ,expr (progn ,@body))) (defmacro aprog1 (expr &body body) `(let ((it ,expr)) (prog1 it ,@body))) (defmacro aif2 (expr then &optional else) (let ((g (gensym))) `(multiple-value-bind (it ,g) ,expr (if ,g ,then ,else)))) (defmacro awhen2 (expr &body body) `(aif2 ,expr (progn ,@body)))
aif
, awhen
, aprog1
は、普通のアナフォリックマクロです。
if
文で、条件判定に使われた値をit
という変数で参照できるというもの。
つまり、
(if expr then)
みたいな文が
(let ((it expr)) (if it then))
のように展開されます。
aif2
とawhen2
はちょっと複雑です。
if
文がtrue
と判定されるのは、条件式の第一返却値ではなく、
第二返却値がtrue
のときです。
これはいろんな流儀があります。
有名なものだと、if
の判定は、(or 第一返却 第二返却)
で行うものがあります。
今回は第二返却だけに注目しています。
そしてit
に束縛されるのは、第一返却値です。
it
の束縛については、多分どのaif2
においても第一返却値で共通だと思います。
つまり、
(aif2 expr then)
という文は、
(multiple-value-bind (it #:check) expr (if #:check then))
になります。
ちょっとややこしいかもしれませんが、今後ずっと使っていきます。
3. CLOSURE
さあLR(1)を作りましょう!
まずはCLOSURE
を作ります。
こいつが何なのか良くわかりませんが、難しいことは後で考えます。
;; CLOSURE(I) ;; repeat ;; foreach ([A -> alpha . B beta, term-a] in I) ;; foreach ([B -> gamma] in *rule*) ;; foreach (term-b in FIRST(beta term-a)) ;; add [B -> . gamma, term-b] to I; ;; until no more items are added to I; ;; return I;
Common Lispで作ります。
結構大変ですががんばりましょう。
まず、繰り返しが4つもあることに注目します。
repeat
, foreach
, foreach
, foreach
の4つです。
repeat
には、until
が対応しており、
「I
に何も追加されなかったらループ終了」とのこと。
3つのforeach
のあとにはadd
という命令があるのですが、
この処理は結構複雑です。
まずはrepeat
を処理したいので、
追加が生じたかどうかはadd
の返却値によって判定したいと思います。
最初はこんな感じで作成してみます。
(defun closure-parse (list) (aif2 (closure-foreach list) (closure-parse it) list))
closure-parse
関数がCLOSURE
そのものになります。
CLOSURE
のrepeat/until
を実現するために、再帰呼出のつくりになっています。
これから作成するclosure-foreach
という関数の
第二返却値がt
ならば、自分自身であるclosure-parse
関数を呼び出すというものです。
closure-foreach
を示します。
(defmacro dobind (bind expr &body body) (let ((g (gensym))) `(dolist (,g ,expr) (destructuring-bind ,bind ,g ,@body)))) (defun closure-foreach (list) (let (update) (dobind (b beta term-a) (closure-filter list) (dolist (gamma (closure-grammar b)) (dolist (term-b (closure-first beta term-a)) (awhen2 (closure-add b gamma term-b list) (setq list it update t))))) (values list update)))
元の文にあるforeach
, foreach
, foreach
が、
dobind
, dolist
, dolist
に対応します。
見た目をそろえるためだけに、dobind
というマクロを作成しました。
ここで呼ばれている下記の関数を一つずつ説明していきます。
closure-filter
closure-grammar
closure-first
closure-add
やっていることは、とにかくlist
に何かを追加しようとしています。
新しい要素が追加できたらupdate
をt
にして、
呼び出し元であるclosure-parse
にもう一度実行してほしいとお願いします。
それでは、最初に関数closure-filter
から見ていきましょう。
元の文は次の個所に対応します。
;; foreach ([A -> alpha . B beta, term-a] in I) (dobind (b beta term-a) (closure-filter list) ...)
ここはかなり丁寧に説明する必要があります。
まず引数のI
というのは何らかの集合を表します。
集合とは、順不同の要素が集まったものです。
その集合I
の中に、
[A -> alpha . B beta, term-a]
に対応するような何かが入っているということです。
表記法に独特なルールがあります。
alpha
, beta
(あとgamma
も)というのは0個以上の記号の列を意味します。
A
, B
は、たった1つの非終端記号を意味します。
普通の規則であれば、
left -> right
みたいな形になりますが、LR(1)では、right
を二つの部分に分けます。
つまり、
left -> alpha . beta
みたいな感じになります。
alpha
とbeta
は記号の列なので、Common Lispではリストとして表現します。
ドット.
は、現在処理中の場所を意味しています。
さらに、LR(1)の、(1)という部分は1つの先読みを意味しているわけですが、
先読みした終端記号をa
とした場合、
left -> alpha . beta, a
みたいな感じに表します。
この[left -> alpha . beta, a]
みたいな形のやつが、
集合I
にいっぱい入っているわけです。
上記のようにsymbolの列をそのまま格納してもいいのですが、
構造体を作って管理しましょう。
(defstruct grammar left alpha beta ahead) (defun grammar-instance (left alpha beta ahead) (make-grammar :left left :alpha alpha :beta beta :ahead ahead)) (defun grammar-instance-rest (left alpha beta &rest ahead) (grammar-instance left alpha beta ahead))
grammar
が構造体です。
grammar-instance
とgrammar-instance-rest
は、構造体を作成するための関数です。
left
は左辺、alpha
とbeta
は右辺、
そしてahead
は先読みの終端記号を格納した集合です。
つまりは次のようにあらわされます。
left -> alpha . beta, ahead
ahead
は、本来たった1つの終端記号なのですが、
例えば下記のように
left -> alpha . beta, a left -> alpha . beta, b
left
, alpha
, beta
が同じものの場合は、
まとめて下記のようにあらわすと便利です。
left -> alpha . beta, a/b
ということで、ahead
はリストで管理します。
例えば、
expr -> expr + . term, a/b
のような文を表す場合は、
(grammar-instance-rest 'expr '(expr +) '(term) 'a 'b)
のようになります。
ではforeach
文に戻りましょう。
[A -> alpha . B beta, term-a]
つまりは、集合I
の中にある文のうち、ドット右側が、
(grammar-beta x) == (B . beta)
の形になっているものを全て抜き出せという意味です。
今回、A
, alpha
に該当する部分は使いませんので、
B
, beta
, term-a
の3つの値を
foreach
で割り当ててループします。
それでは作成してみましょう。
(defun closure-filter (list) (let (root) (dolist (inst list) (awhen (grammar-beta inst) (destructuring-bind (b . beta) it (when (non-terminal-p b) (dolist (ahead (grammar-ahead inst)) (push (list b beta ahead) root)))))) root))
わりとそのまま作成しています。
まず集合I
であるlist
をdolist
で回します。
grammar-beta
が(B . beta)
の形なら、(B beta ahead)
をpush
します。
返却値は集合なので、順番は適当で問題ありません。
それでは2番目のforeach
を見てみます。
;; foreach ([B -> gamma] in *rule*) (dolist (gamma (closure-grammar b)) ...)
これは全然難しくありません。
いまB
は分かっているので、対応する規則の右辺right
を求めるだけです。
(defun closure-grammar (b) (let (root) (dolist (inst *rule*) (when (eql (rule-left inst) b) (push (rule-right inst) root))) root))
最後のforeach
を見てみます。
;; foreach (term-b in FIRST(beta term-a)) (dolist (term-b (closure-first beta term-a)) ...)
ただ単にFIRST
という関数を呼んでいるだけです。
FIRST
はちょっと難しかったので詳しく見ていきます。
FIRST(X)
は、最初に遭遇する終端記号を返却します。
a
が終端記号のとき、FIRST(a)
はそのままa
が返却されるわけですが、
返却は集合であることを注意してください。
つまりFIRST(a) -> {a}
です。
なぜ集合になるのかというと、
X -> a X -> b
のようなときは、FIRST(X)
に、a
とb
のどちらにも遭遇する可能性があるからです。
最初に遭遇した終端記号の集合ということなので、 例えば下記のような場合、
X -> A B C
FIRST(X)
は、もしFIRST(A)
に返却値がある場合は
FIRST(X) -> FIRST(A)
です。
しかし、FIRST(A)
が空の場合は、次にFIRST(B)
を同様に調べます。
FIRST(B)
に返却値があればそれが返却になりますが、
無かったらFIRST(C)
を調べるという動作になります。
これってどうやって作ればいいと思いますか。
普通に再帰で実装することを考えていたのですが、
例えば
expr -> expr + term
みたいな場合って、FIRST(expr)
を調べると
すぐにFIRST(expr)
に遭遇するので無限ループになってしまうんですよね。
【変更】first-parse
を修正しました。
もし自分自身に遭遇した場合は、その規則は無視して次の規則を調べることにします。
あと、今回の実装ではFIRST
の引数がリストなので、
まずはリストの処理から見ていきましょう。
(defun first-parse (list) (when list (destructuring-bind (car . cdr) list (or (first-symbol car) (first-parse cdr)))))
引数のリストを順番に処理していき、
もしfirst-symbol
関数に返却値があったら全体を終了させます。
次にfirst-symbol
関数を見てみます。
(defun first-symbol (x) (cond ((terminalp x) (list x)) ((non-terminal-p x) (first-nonterm x)) (t (error "Invalid symbol, ~S." x))))
もし引数が終端記号なら、それを返却して終わりです。
非終端記号なら、first-nonterm
を呼び出します。
余談ですが、ここのエラーは誤字脱字を指摘してくれるので便利です。
first-nonterm
を見てみます。
(defun first-nonterm (x) (let (root) (dolist (right (first-rule x)) (dolist (y (first-right x right)) (pushnew y root))) root))
二つのdolist
があります。
最初のfirst-rule
は、非終端記号x
から該当する規則の集合を求めます。
二つ目のfirst-right
はFIRST(right)
の結果を返却します。
y
は終端記号であり、pushnew
で重複がないように収集します。
first-rule
とfirst-right
を順番に見ていきます。
(defun first-rule (x) (let (list) (dolist (y *rule*) (when (eql x (rule-left y)) (push (rule-right y) list))) list))
first-rule
は難しくありません。
引数の非終端記号x
と、規則のleft
が等しいものの
right
をリストとして返却するだけです。
(declaim (ftype function first-symbol)) (defun first-right (x right) (dolist (y right) (when (eql x y) (return nil)) (aif (first-symbol y) (return it))))
やっていることはFIRST(right)
を求めるだけなのですが、
もし自分自身の非終端記号に遭遇した場合はnil
を返却します。
つまりは
expr -> expr + term
のような場合です。
nil
を返却するため、次の規則を調べるように指示します。
これでfirst-parse
関数は完成しましたので、元の文に戻ります。
;; (dolist (term-b (closure-first beta term-a)) (defun closure-first (beta term) (or (first-parse beta) (list term)))
foreach
で呼ばれていたclosure-first
です。
もともとの文はFIRST(beta term-a)
なので、
まずfirst-parse
でFIRST(beta)
を求め、
もし値が無かったら(list term)
を返却しています。
ここまでで、repeat
と3つのforeach
の説明は完了しました。
次はclosure-add
です。
;; add [B -> . gamma, term-b] to I; (awhen2 (closure-add b gamma term-b list) (setq list it update t))
もしclosure-add
関数で値が追加された場合は、
list
を更新し、update
にt
を代入します。
第二返却値がnil
の場合は何もしません。
closure-add
はlist
に追加するだけなのですが、
無条件に追加するのではなく
pushnew
のように既に追加されているかどうかを調べる必要があります。
また、単にlist
にpush
するのではなく、
left
, alpha
, beta
が同一の要素が存在する場合は、
ahead
の集合にpushnew
しなければなりません。
その辺を全部考慮して作っていきます。
(defun closure-add (left beta term list) (aif (closure-add-find left beta list) (closure-add-ahead it term list) (closure-add-list left beta term list)))
まずはclosure-add-find
で要素が存在するかどうかを調べます。
存在する場合はclosure-add-ahead
を呼びます。
存在しない場合はclosure-add-list
を呼びます。
順番にclosure-add-find
から見てきます。
(defun closure-add-find (left beta list) (dolist (inst list) (and (equal (grammar-left inst) left) (equal (grammar-alpha inst) nil) (equal (grammar-beta inst) beta) (return inst))))
ただ検索しているだけですが、
left
とbeta
だけをequal
判定し、alpha
はnil
かどうかを調べています。
どうしてかというと、追加する内容が次のようなものだからです。
[B -> . gamma, term-b]
alpha
が存在しないのです。
ahead
を無視しているのは次の処理で分岐させるためです。
ahead
以外のleft
, alpha
, beta
の3つが一致することを、
「コアが等しい」というらしいです。
この辺の話はLALR(1)のときによく出てきます。
それでは、コアが等しい場合のclosure-add-ahead
を見ていきます。
(defun closure-add-ahead (inst term list) (unless (member term (grammar-ahead inst)) (push term (grammar-ahead inst)) (values list t)))
やっていることはgrammar-ahead
にpushnew
するだけなのですが、
もしpush
に成功した場合は、
(values list t)
を返却しないと正しく更新されません。
unless
やらmember
やらで存在確認しているのはそのためです。
次は等しいコアが見つからなかった場合のclosure-add-list
です。
(defun closure-add-list (left beta term list) (let ((v (grammar-instance-rest left nil beta term))) (values (cons v list) t)))
こちらは単純にpush
するだけです。
ずいぶん長くなりましたが、以上でCLOSURE
の作成は終わりです。
4. 試しに実行する
そもそもCLOSURE
ってなんなのって話ですが、
集合I
のドットの位置から非終端記号を経由して、
たどれるところ全部たどって行った場合の
新しい集合を返却する機能です。
正規表現をやったことある人なら
NFAからDFAに変換するときに、
eps
を全部たどるみたいなことをしたことがあるかもしれません。
つまりそれです。
これからCLOSURE
を使って、構文解析のDFAを作ろうとしています。
試しに実行してみましょう。
まずは初期状態の集合を作ります。
最初の文は下記の通り。
#:start -> . expr, #:end
#:start
は、開始を表す値*start-symbol*
です。
#:end
は、EOF
を表す値*end*
です。
expr
は、parse-start
で設定した値*start*
です。
「. expr
」で分かるように、初期状態なのでalpha
はnil
です。
実行してみましょう。
(parse-terminal + * [ ] int) (parse-start expr) (parse-rule expr -> expr + term) (parse-rule expr -> term) (parse-rule term -> term * fact) (parse-rule term -> fact) (parse-rule fact -> [ expr ]) (parse-rule fact -> int) (let* ((left *start-symbol*) (beta (list *start*)) (ahead *end*) (x (grammar-instance-rest left nil beta ahead))) (format t "~S~%" x) (format t "~S~%" (closure-parse (list x))))
結果は下記の通り。
#S(GRAMMAR :LEFT #:START :ALPHA NIL :BETA (EXPR) :AHEAD (#:END)) (#S(GRAMMAR :LEFT FACT :ALPHA NIL :BETA (INT) :AHEAD (* + #:END)) #S(GRAMMAR :LEFT FACT :ALPHA NIL :BETA ([ EXPR ]) :AHEAD (* + #:END)) #S(GRAMMAR :LEFT TERM :ALPHA NIL :BETA (FACT) :AHEAD (* + #:END)) #S(GRAMMAR :LEFT TERM :ALPHA NIL :BETA (TERM * FACT) :AHEAD (* + #:END)) #S(GRAMMAR :LEFT EXPR :ALPHA NIL :BETA (TERM) :AHEAD (+ #:END)) #S(GRAMMAR :LEFT EXPR :ALPHA NIL :BETA (EXPR + TERM) :AHEAD (+ #:END)) #S(GRAMMAR :LEFT #:START :ALPHA NIL :BETA (EXPR) :AHEAD (#:END)))
次は状態遷移表をつくります。