0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Ast.pmを読む:数式からASTを構築して計算するまで

0
Last updated at Posted at 2026-10-20

Ast.pm 最初のAST構築から計算

数式を計算するプログラムは、文字列を左から順番に計算すればよいわけではありません。
例えば 2*(3+4)-5 では、括弧の中を先に計算し、次に乗算、最後に減算を行う必要があります。
今回は、Perlで書かれた Ast.pm を題材に、数式からAST(抽象構文木)を構築し、その木をたどって計算する仕組みを追いかけてみます。

概要

Tool::mmt::Controller::Ast は、文字列で受け取った数式を二分木(AST)に変換し、その木をたどって計算する Mojolicious コントローラです。対象ファイルは Ast.pm(コミット 59dd332) です。

  • 入力: リクエストパラメータ calc(例: 2*(3+4)-5)
  • 出力: stash の anser(計算結果)と tree(Data::Dumper による木のダンプ)
  • 方式: 式を「最も優先順位の低い演算子」で左右に分割することを再帰的に繰り返して木を作り、葉から順に計算する

eval で文字列をそのまま実行しないため、入力がコードとして実行される心配はありません。

演算子テーブル

演算子は %$op に [処理のコードリファレンス, 優先順位, 結合性] の形で定義されています。数字が小さいほど結合が弱く、木の上(根に近い側)に来ます。

my $op = +{ '-' => [sub {$_[0] - $_[1]},1,'L'],         # オペレータ定義
           '+' => [sub {$_[0] + $_[1]},1,'L'],
           '*' => [sub {$_[0] * $_[1]},2,'L'],
           '/' => [sub {$_[0] / $_[1]},2,'L'],
           '%' => [sub {$_[0] % $_[1]},2,'L'],
           'NGE' => [sub { -$_[0]},4,'R'],
           '**' => [sub {$_[0] ** $_[1]},4,'R'],
           '(' => [sub { },9,'L'],
           ')' => [sub { },10,'L'],
        };
演算子 優先順位 結合性 処理
+ - 1 左 加算・減算
* / % 2 左 乗算・除算・剰余
** 4 右 べき乗
NGE 4 右 単項マイナス(解析時に - から置き換える内部用の記号)
( ) 9 / 10 左 何もしない。正規表現で分割するためだけに登録

setReOps はこのキーを長い順に並べて正規表現 $s->{ops} を作ります。長い順にするのは、** を * 2つと誤認しないためです。

sub setReOps{                                       # 演算子の正規表現作成
    my $s = shift;
    $s->{ops} = join ('|',map {s/(\W)/\\$1/g;$_;} sort {length $b <=> length $a} keys %$op);
    $s->{ops} = "(".$s->{ops}.")";
    return $s;
}

処理の流れ

リクエストから答えまでは、次の順に一直線で進みます。

  1. ast(アクション): calc パラメータを取り出し、formula として Astnew に渡す。戻り値の anser を stash に入れる。
  2. Astnew: 引数をハッシュにし、setReOps で演算子の正規表現を用意する。formula があれば _ast を呼ぶ。
  3. _ast: 以下の 4〜7 を順に実行し、木のダンプを stash の tree に、結果を $s->{anser} に入れる。
  4. adjust: 演算子の前後に空白を入れる。数字や ) の直後に ( があれば * を補う((1+2)(2-1) → (1+2)*(2-1))。
  5. item_split: 空白で区切ってトークンの配列 $s->{item} にする。
  6. makeTree: トークン列から二分木を組み立てる(次の節で詳しく説明)。
  7. readTree: 木を葉から根へたどって値を計算する。

_ast は 4〜7 を 1 行に連結しています。

sub _ast{
    my $s = shift;
    $s->{root} = $s->makeTree(@{$s->item_split($s->adjust(shift))->{item}});
    $s->stash(tree => Dumper $s->{root});
    $s->{anser} = $s->readTree($s->{root});
}

sub adjust{                                         # 計算式の要素をスペースで分割
    my ($s,$text) = @_;
    $text =~ s/$s->{ops}/ $1 /g;
    $s->{_text} = $text =~ s{([\d\)])\s*\(}{$1 \* \(}g;           #   開き括弧の前が演算子じゃない時に*を補完
    return $text;
}

ノードは newNode が作る {data => 演算子, left => 左, right => 右} のハッシュです。葉は数値そのもの(スカラー)で、readTree は「ハッシュリファレンスかどうか」で葉と演算子を見分けます。

readTree の動き:

  • 葉ならその値を返す
  • NGE なら右の子を計算して符号を反転する(左は使わない)
  • それ以外は左右を再帰的に計算し、その結果をノードに書き戻してから演算子のコードを適用する
sub readTree{                                       # AST計算
    my ($s,$node) = @_;
    return $node if(ref($node) ne 'HASH');
    if($node->{data} eq 'NGE'){
        return $op->{$node->{data}}->[0]($s->readTree($node->{'right'}));
    }
    do{$node->{$_} = $s->readTree($node->{$_}) if(ref($node->{$_}) eq 'HASH')} for ('left','right');
    exists $op->{$node->{data}} ? $op->{$node->{data}}->[0]($node->{left},$node->{right})
                                : $node->{data};
}

AST構築の仕組み(makeTree)

makeTree は「括弧の外にある、優先順位が最も低い演算子」を根にし、その左側と右側のトークン列で自分自身を再帰的に呼びます。

  1. 外側の括弧を外す: 先頭が (、末尾が ) で、その括弧が式全体を包んでいるときだけ外す。((1)) のような重ね括弧は繰り返し外す。(1+2)*(3+4) のように括弧の塊が複数あるときは外さない。
  2. 葉の判定: トークンが 1 つだけならそれを葉として返す。
  3. 分割点を探す: 括弧の深さ 0 のトークンだけを見て、優先順位が最も低い演算子の位置を記録する。比較は juge_priority が行う。
  4. 分割して再帰: 分割点より前を左の子、後ろを右の子として makeTree を呼び、newNode でまとめる。
sub makeTree{                                       # AST組み立て
    my $s = shift;
    while($_[0] eq '(' and $_[-1] eq ')'){
        my ($r,$sw) = (0,0);
        for(@_){                                    # '('の深さを計算
            ++$r if($_ eq '(');
            --$r if($_ eq ')');
            ++$sw if($r == 1 and $_ eq '(');
        }
        if($sw == 1){                               #  一番外側の括弧を外す
            shift; pop;
        }else{
            last;
        }
    }
    return shift() if(@_ <= 1);                     # 要素が一つの時は要素を返す
    my ($prio,$i,$m,$r) = (99,-1,0,0);
    for(@_){                                        # 一番右側の一番プライオリティの低いオペレータを検索
        ++$i;
        my $cur = $_;
        if(/^$s->{ops}$/){
            ++$r if($_ eq '(');
            --$r if($_ eq ')');
            next if($r or $_ eq ')');              #  括弧の間は読み飛ばす
            if($_ eq '-' && ($i == 0 || ($_[$i - 1] =~ /^$s->{ops}$/ && $_[$i - 1] ne ')'))){
                $_[$i] = $cur = 'NGE';
            }
            if($s->juge_priority($cur,$prio)){
                $prio = $op->{$cur}->[1];
                $m = $i;
            }
        }
    }
    return $s->newNode($_[$m],                      # オペレータとオペランド(右と左)を返す
                            $s->makeTree(@_[0 .. $m-1]),
                            $s->makeTree(@_[$m+1 .. $#_])
                );
}

$sw は深さ 1 で開いた ( の数、つまり最上位の括弧の塊の数です。これが 1 のときだけ外側の括弧を外します。( の優先順位 9 と ) の 10 は、括弧が分割点に選ばれないようにするための大きな値です。

同じ優先順位が並んだとき(結合性)

  • 左結合(+ - * / %): 同順位なら後ろのもので上書きするので、一番右で分割する。8-3-2 は (8-3)-2 = 3 になる。
  • 右結合(** NGE): より低いときだけ上書きするので、一番左で分割する。2**3**2 は 2**(3**2) = 512 になる。

結合性の違いは juge_priority の < と <= の 1 文字だけで表現されています。

sub juge_priority {
    my $s = shift;
    $op->{$_[0]}->[2] eq 'R'
        ?$op->{$_[0]}->[1] <  $_[1]
        :$op->{$_[0]}->[1] <= $_[1];
}

juge_priority が真を返したときだけ、現在の演算子の位置を $m に記録します。左結合の演算子は同じ優先順位でも位置を更新するため、最後に見つかった演算子が分割点になります。一方、右結合の演算子は同じ優先順位では更新しないため、最初に見つかった演算子が分割点として残ります。

単項マイナス

- が式の先頭にあるか、) 以外の演算子の直後にあるとき、NGE に置き換えます。NGE ノードは left が空で、right に対象の式を持ちます。優先順位は ** と同じ 4 なので、-2**2 は -(2**2) = -4 になります(数学の一般的な解釈と同じ)。

計算例: 2*(3+4)-5

calc=2*(3+4)-5 を渡すと、答えは 9 になります。

  1. adjust と item_split でトークン列 2 * ( 3 + 4 ) - 5 になる。
  2. 括弧の外にある演算子は *(順位 2)と -(順位 1)。低い - が根になり、左が 2 * ( 3 + 4 )、右が 5。
  3. 左側では * で分割し、左が 2、右が ( 3 + 4 )。
  4. ( 3 + 4 ) は外側の括弧を外して + で分割し、左が 3、右が 4。
  5. readTree が葉から計算する: 3+4 = 7 → 2*7 = 14 → 14-5 = 9。
        -
       / \
      *   5
     / \
    2   +
       / \
      3   4

木は上から作られ、計算は下(葉)から進みます。優先順位の低い演算子ほど根に近く、最後に計算されます。

実行すると、stash の tree には次のダンプが入ります(見やすさのため $Data::Dumper::Indent = 1 と Sortkeys = 1 で出力)。

$VAR1 = {
  'data' => '-',
  'left' => {
    'data' => '*',
    'left' => '2',
    'right' => {
      'data' => '+',
      'left' => '3',
      'right' => '4'
    }
  },
  'right' => '5'
};

注意点・改善点

入力チェックがないため、不正な式でもエラーにならずに何かしらの結果を返す点に注意が必要です。

表の挙動は、次のスクリプトで確認できます(Mojolicious が必要です。toolmmt ディレクトリに置き、perl try_ast.pl '1/0' '(1+2' '1 2' のように実行します)。

try_ast.pl(確認用スクリプト)
#!/usr/bin/env perl
# 使い方: perl try_ast.pl '2*(3+4)-5' '1/0' ...
use strict;
use warnings;
use lib 'lib';
BEGIN {    # 親クラス Tool::mmt::Controller::Mmt の代わり
    package Tool::mmt::Controller::Mmt;
    use Mojo::Base 'Mojolicious::Controller';
    $INC{'Tool/mmt/Controller/Mmt.pm'} = __FILE__;
}
use Tool::mmt::Controller::Ast;
use Mojo::Transaction::HTTP;

for my $calc (@ARGV) {
    my $tx = Mojo::Transaction::HTTP->new;    # controller は tx を弱参照で持つので保持しておく
    my $c  = Tool::mmt::Controller::Ast->new(tx => $tx);
    $c->req->url->query(calc => $calc);
    my $ok = eval { $c->ast; 1 };
    printf "%-12s => %s\n", $calc,
        $ok ? $c->stash('anser') // 'undef' : "die: $@";
    print $c->stash('tree') if $ENV{TREE};
}
入力・箇所 現在の動き 改善案
1/0、1%0 Perl が "Illegal division by zero" で die する eval で囲んでエラーを返す
(1+2(括弧の不一致) ( が根になり、結果が undef 括弧の対応を事前に検査
() undef と未初期化値の警告 同上
1 2(演算子なし) 分割点が先頭になり、結果は 1 演算子が見つからないときはエラー
a + 1(数値でない) a が 0 扱い(警告のみ) 葉が数値か検査
+3(単項プラス) 左が undef の加算として 3 単項マイナスと同様に扱う
7.5 % 2 Perl の % は整数演算のため小数部が切り捨て 必要なら POSIX::fmod
NGE 3(内部記号を入力) 単項マイナスとして扱われ -3 トークン化の段階で NGE を拒否

コード上の細かい点:

  • stash のキー anser は answer、juge_priority は judge_priority の綴り違いと思われる。
  • adjust の $s->{_text} = $text =~ s{...}{...}g は置換後の文字列ではなく置換回数を保存している。_text は使われていないので実害はない。
  • tree は Data::Dumper の出力なので、テンプレートで表示するときはエスケープが必要。テンプレートはこのファイルには含まれていない。

振り返りと今後の改善

学習用・小さな電卓ツールとしては素直な実装ですが、不特定の入力を受けるには入力チェックが足りません。

うまくいった点

  • eval を使わず自前で解析しており、入力がコードとして実行される危険がない
  • 優先順位と結合性が正しい(8-3-2=3、2**3**2=512、-2**2=-4)
  • 演算子がテーブル駆動で、%$op に 1 行足すだけで拡張できる
  • (1+2)(2-1) のような省略された掛け算を補う
  • 木のダンプを出すので、AST の学習教材として分かりやすい

課題

  • エラー処理がない: ゼロ除算で die、不正な式でも undef や誤った値を黙って返す
  • 効率: 分割のたびに配列を切り出して再走査するので最悪 O(n²)。電卓の式の長さなら実用上は問題ない
  • 綴り違い(anser、juge_priority)、_text のバグ、入力に NGE と書ける点
  • このファイルからはテストコードが確認できない

今後の改善(優先順)

  1. _ast を eval { } で囲み、エラー時は anser にメッセージを入れる
  2. トークン化の段階で、括弧の対応と「数値・演算子が交互に並ぶか」を検査する
  3. 計算例や境界ケースを Test::More のテストにする

本格化するなら Shunting-yard 法や再帰下降パーサにすると、検査と速度の両方が良くなる。ただし「AST を作ってから計算する」仕組みを見せる目的なら、現在の作りの方が理解しやすい。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?