Parse::RecDescent (3)

左結合な減法,つまり「1-2-3」は「(1-2)-3」であって「1-(2-3)」ではない,ということを解決する方策をHigher-order Perlの8.4節からもらってきます.

左結合を実現するのに,単純に

expression : expression "-" atom

としてしまうと,expressionを処理するためにどんどん再帰が深くなり,その上,与えられた文字列は一切消費されないのでその再帰は無限ループになってしまいます(左再帰,left-recursive).Parse::RecDescentはそれを検出してエラーを出すのです.これを解決する方法は,当たり前ですが

  • 文字列をきちんと消費する(再帰が終わるようにする)
  • 左から計算させる細工を入れる

ことにつきます.しかし左から計算させるために,上のように左側にexpressionをおいてはいけません.となると,左におけるのは今の場合,atomしかありません.そうすると右におくものは「式の残り」になります.

簡単な例で考えます.「1-2」を「1」と「-2」とし,この「-2」を関数だと思います.つまり,f(x)=x-2という関数を「-2」と表現しているとみなします(Haskellとかの関数型言語の考え方ですね).この考え方だと「1-2=f(1)」ということになります.「-2」を一個の関数とみなすことが重要です.Parse::RecDescentの文法のルールの形で表すと

expression : atom expression_tail
expression_tail : "-" atom

となります.expression_tailは上でのf(x)=x-2に相当する関数を返すことになるので,アクションもつけると

expression : atom expression_tail
expression_tail : "-" atom
    {$result = sub {$_[0] - $item[2]; }; }

となります.$resultには無名サブルーチンが入ります.$item[2]が「1-2」の「2」に,関数fがこの無名サブルーチンに,fの引数xが$_[0]に対応します.このように返ってきた無名サブルーチンを最初のルールのexpression_tailが受け取り,atomを引数として処理すればいいので,最初のルールとそのアクションは次のようになります.

expression : atom expression_tail
    { $result = $item[2]->($item[1]); }
expression_tail : "-" atom
    { $result = sub {$_[0] - $item[2]; }; }

これで単純な「1-2」のような引き算は処理できます.なお,expression_tailのアクションでは,引き算が順番に依存することを考慮して$_[0]-$item[2]としていることにも注意してください.

さらに左結合性を考えるために「1-2-3」を考えてみます.「1-2」を先に計算したいので,先ほどのf(x)=x-2を用いると「f(1)-3」となります.そこで今度はg(x)=x-3とすると,まったく同じく「f(1)」「-3」という構造になるので,g(f(1))と表現できることになります,左側の演算であるfが,右側の演算であるgの中に入り込んでいるので,fから計算されること,つまり「左結合」が関数の合成で表現できています.「1-2-3」から「g(f(1))」まで変換する文法が必要になります.f(1)を作るために文字列「1-2-3」から「1-2」を消費すれば左再帰の問題もおきません.

上の文法では不足なのは明らかですが,文字列は明らかに消費されますので左再帰はおきません.まず最初に,expressionルールでatom="1"とexpression_tail="-2-3"になるのは問題ないとは思いますが,expression_tailルールでは「-2-3」を受け入れてくれません.そこで新しいルールが必要になります.今度は「1-2」から「3を引く」ということになるので,「3を引く関数g」を意識して,上の単純な引き算のexpression_tailを利用することも考えて,次のようなルールを作ります.

expression_tail : "-" atom expression_tail

こうすることで新たなatomは"2",新たなexpression_tailは"-3"です.この"-3"が「3を引く関数g」となります.atomである"2"と"-"と組み合わせて,「2を引く関数f」をつくり,このfにもとのexpressionのatomである"1"を引数として与えてf(1)を作って,このf(1)をgの引数としてg(f(1))をつくれればよいのです.このgをつくるアクションはすでに,単純な引き算「1-2」の考察で作られていることに注意してください.また,expression_tailは「関数を返す」ということにも注意です.ルール中のexpression_tailは$item[3]で,atomは$item[2]です.そして,もとのexpressionのatomである"1"はexpression側でexpression_tailの引数として与えられるので$_[0]となります.つまりf(x)=x-2は$_[0]-$item[2]になり,これがgつまり$item[3]の引数になります.これをParse::RecDescentの文法で書けば

expression_tail : "-" atom expression_tail
    { $return = sub { $item[3] -> ( $_[0] - $item[2]); }; }

となります.以上をまとめると

expression : atom expression_tail
    { $return = $item[2] -> ($item[1]) ;}

expression_tail : "-" atom expression_tail
    { $return = sub { $item[3] -> ($_[0] - $item[2]) ;} ;}

expression_tail : "-" atom 
    { $return = sub { $_[0] - $item[2] ;} ;}

また,加法は可換な演算なので,引き算同様にこのexpression_tailの形で統一できます.そうすることで同じインターフェースにできて加法・減法を統一的,つまり,組合せや再帰が可能になります.実行可能なコードをあげます.

use strict;
use warnings;

use Parse::RecDescent;
use Data::Dumper;

my $grammar=q(
expression : atom expression_tail
    { $return = $item[2] -> ($item[1]) ;}

expression : atom
    { $return = $item[1] ;}

expression_tail : "+" atom expression_tail
    { $return = sub { $item[3] -> ($_[0] + $item[2]) ;} ;}

expression_tail : "+" atom 
    { $return = sub { $_[0] + $item[2] ;} ;}

expression_tail : "-" atom expression_tail
    { $return = sub { $item[3] -> ($_[0] - $item[2]) ;} ;}

expression_tail : "-" atom 
    { $return = sub { $_[0] - $item[2] ;} ;}

atom : /\d+/
    { $return = $item[1] ;}

);

my $parser = new Parse::RecDescent ($grammar) or die "Bad grammar!\n";

my $text = "1-3-4+7";
my $result = $parser->expression($text);
print "$text = $result\n";

これで足し算も引き算もできます.ここで,一つルールを追加しています.

expression : atom
    { $return = $item[1] ;}

がそれです.これは「1」のようなただの数を与えたときも,式とみなすための細工です.expression_tailが空になるときに対応します.このルールは

expression : atom expression_tail
    { $return = $item[2] -> ($item[1]) ;}

のあとにあることがトリックです.順番を変えると正しく動作しません.さらに,まだ演算順序を制御する「括弧」が扱えませんし,したがって「1-(-2)」のような計算もできませんし,「-2+1」のような負の数の計算もできません.

追記

えーっと,まだ途中なので,Parse::RecDescentの他の機能を使った整理とかは考えてないです.以下に「シンプルな部分」だけで頑張れるかです.サブルールを「選言」で書くとかもまだ考えてないです.