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-09-24

はい。これはかなり面白い「初期型AST」です。
今の chabo-dsl の原型につながる部分がかなり見えます。

ざっくり言うと、このコードは

文字列の計算式 → 演算子と数値に分解 → 優先順位を見て二分木を作る → 木を再帰的に計算

という流れです。

例えば、

1 + 2 * 3

を

    +
   / \
  1   *
     / \
    2   3

という木にしてから、

1 + (2 * 3)

として計算しています。


1. 全体の流れ

このコードの中心的な流れはこれです。

ast()
  ↓
Astnew()
  ↓
setReOps()
  ↓
_ast()
  ↓
adjust()
  ↓
item_split()
  ↓
makeTree()
  ↓
AST完成
  ↓
readTree()
  ↓
計算結果

例えば、

$s->Astnew('formula' => '1 + 2 * 3');

とすると、

"1 + 2 * 3"
       ↓
["1", "+", "2", "*", "3"]
       ↓
       +
      / \
     1   *
        / \
       2   3
       ↓
       7

となります。


2. $op がこのASTの心臓部

まず非常に重要なのがこれです。

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'],
};

ここには演算子について、

演算処理
優先順位
結合方向

の3つが入っています。

例えば、

'+' => [sub {$_[0] + $_[1]},1,'L']

は、

+ 
├─ 計算方法 : $a + $b
├─ 優先順位 : 1
└─ 結合方向 : L

という意味です。

* は、

'*' => [sub {$_[0] * $_[1]},2,'L']

なので、

+  → priority 1
*  → priority 2

となります。

この「数字が大きいほど強い」という設計です。


3. NGE が面白い

ここは初期ASTの中でも特に面白いところです。

'NGE' => [sub { -$_[0]},4,'R'],

NGE は普通の数学演算子ではありません。

これは、

-1

の先頭の - を、

NGE

という内部演算子に変換するためのものです。

つまり、

1 - 2

の - と、

-2

の - を区別しています。

例えば、

1 - -2

なら、

1
-
NGE
  2

というASTにできます。

これは今の言語処理系でも非常に重要な考え方です。


4. ast() は入口

sub ast{
    my $s = shift;
    my $x = $s->Astnew('formula'=>$s->param('calc'));
    $s->stash(anser => $x->{anser});
}

Web画面から、

calc=1+2*3

のように渡されたものを、

$s->param('calc')

で取得しています。

そして、

Astnew(...)

を呼び出しています。

最後に、

$s->stash(anser => $x->{anser});

で計算結果をテンプレートへ渡します。

ちなみに anser はおそらく answer のタイプミスですね。


5. Astnew() がAST生成を開始する

sub Astnew {
    my $s = shift;
    my $x = {@_};

    $s->setReOps();

    $s->_ast($x->{formula}) if (exists $x->{formula});

    return $s;
}

ここでは、

$s->setReOps();

で演算子を正規表現として準備します。

その後、

$s->_ast($x->{formula})

を呼びます。

つまり Astnew() は実質、

「ASTを作り始めて!」

という初期化処理です。


6. setReOps() ― 演算子を正規表現にする

ここ、Perlらしいです。

$s->{ops} = join ('|',
    map {
        s/(\W)/\\$1/g;
        $_;
    }
    sort {length $b <=> length $a} keys %$op
);
$s->{ops} = "(".$s->{ops}.")";

例えば演算子が、

-
+
*
/
%
NGE
**
(
)

なら、最終的に概ね、

(\*\*|\+|\-|\*|\/|%|NGE|\(|\))

のような正規表現を作ります。

そして、

adjust()

でこの正規表現を使います。


7. なぜ長い演算子を先にするのか

ここも重要です。

sort {length $b <=> length $a}

つまり、

長い演算子から並べる

という処理です。

例えば、

**
*

があったとします。

正規表現を、

(\*|\*\*)

としてしまうと、

**

を最初の * でマッチしてしまう可能性があります。

だから、

(\*\*|\*)

のように ** を先にします。

これは今の chabo-dsl にも通じる、かなり基本的なTokenizerテクニックです。


8. _ast() が実際のAST構築を開始

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});
}

ここが一本のパイプラインになっています。

adjust
 ↓
item_split
 ↓
makeTree
 ↓
readTree

つまり、

文字列
 ↓
演算子を分離
 ↓
トークン配列
 ↓
AST
 ↓
評価

です。


9. adjust() ― 式をトークン化しやすくする

sub adjust{
    my ($s,$text) = @_;

    $text =~ s/$s->{ops}/ $1 /g;

    $s->{_text} =
        $text =~ s{([\d\)])\s*\(}{$1 \* \(}g;

    return $text;
}

例えば、

1+2*3

に対して、

$text =~ s/$s->{ops}/ $1 /g;

を実行すると、

1 + 2 * 3

のようになります。

つまり、演算子の前後に空白を入れています。


10. さらに暗黙の * を入れている

この部分です。

$text =~ s{([\d\)])\s*\(}{$1 \* \(}g;

これはかなり面白いです。

例えば、

(1+2)(2-1)

を、

(1+2) * (2-1)

に変換します。

つまり、

左側が数字または )
+
右側が (

なら、

*

を自動挿入しています。

数学でいう、

2(3+4)

のような暗黙の掛け算をサポートしようとしているわけですね。

ただし、この初期版では対象が

[\d\)]

なので、例えば変数などを考えるとまだ限定的です。


11. item_split() ― トークン配列にする

sub item_split{
    my $s = shift;
    my $text = shift || $s->{_text};

    $s->{item} = [split ' ',$text];

    return $s;
}

adjust() 後、

1 + 2 * 3

を、

[
    "1",
    "+",
    "2",
    "*",
    "3"
]

にします。

この段階ではまだASTではありません。

単なる配列です。


12. makeTree() が一番重要

ASTを実際に作っているのはここです。

考え方は、

式の中から、一番外側にある「最も優先順位の低い演算子」を探して、そこを根にする

です。

例えば、

1 + 2 * 3

なら、

+ priority 1
* priority 2

なので、

+

を根にします。

結果、

       +
      / \
     1   *
        / \
       2   3

となります。


13. 最初に外側の括弧を外す

while($_[0] eq '(' and $_[-1] eq ')'){

例えば、

(1 + 2)

なら、最初と最後が、

(
)

です。

そこで、

shift;
pop;

して、

1 + 2

として考えます。

ただし、

(1 + 2) * 3

の場合は、最初が (、最後が 3 なので、この処理はしません。


14. 括弧の中は演算子検索から除外

ここです。

$r = 0;

for(@_){
    ++$r if($_ eq '(');
    --$r if($_ eq ')');

    next if($r or $_ eq ')');

$r は括弧の深さです。

例えば、

1 + (2 * 3)

なら、

1 + ( 2 * 3 )
      ↑
      depth = 1

となります。

depth != 0 の間は、

2
*
3

の * を無視します。

これによって、

1 + (2 * 3)

で、外側の + を根にできます。


15. 最も優先順位の低い演算子を探す

この部分です。

if($s->juge_priority($cur,$prio)){
    $prio = $op->{$cur}->[1];
    $m = $i;
}

最終的に $m に、

ASTの根にすべき演算子の位置

が入ります。

例えば、

1 + 2 * 3

なら、

index

0  1  2  3  4
1  +  2  *  3
   ↑     ↑
   +     *

+ の優先順位は1。

* は2。

なので + が根になります。


16. なぜ「右側の演算子」を選ぶのか

ここが、

$op->{$cur}->[2]

の L/R と関係しています。

sub juge_priority {
    my $s = shift;

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

左結合なら、

<=

右結合なら、

<

になっています。

例えば、

1 - 2 - 3

は通常、

(1 - 2) - 3

です。

つまり、

       -
      / \
     -   3
    / \
   1   2

にしたい。

一方、

2 ** 3 ** 4

は右結合なので、

2 ** (3 ** 4)

にしたい。

そのために L/R が存在しています。


17. - を NGE に変換するところ

ここです。

if($_ eq '-' &&
   ($i == 0 ||
    ($_[$i - 1] =~ /^$s->{ops}$/ &&
     $_[$i - 1] ne ')'))){
    $_[$i] = $cur = 'NGE';
}

例えば、

-3

なら、

-
3

の - は減算ではありません。

だから、

NGE
3

に変換します。

また、

1 * -2

も、

1 * NGE 2

になります。

これによって、

二項演算子 -

と

単項演算子 -

をAST上で明確に分離できます。


18. newNode() がASTノードを作る

sub newNode{
    my $s = shift;

    return {
        data  => shift(),
        left  => shift(),
        right => shift()
    };
}

つまりASTの1ノードは、

{
    data  => '+',
    left  => ...,
    right => ...
}

という構造です。

例えば、

1 + 2

なら、

{
    data => '+',
    left => '1',
    right => '2'
}

です。


19. 再帰によってASTが完成する

makeTree() の最後です。

return $s->newNode(
    $_[$m],
    $s->makeTree(@_[0 .. $m-1]),
    $s->makeTree(@_[$m+1 .. $#_])
);

これがAST生成の核心です。

例えば、

1 + 2 * 3

で + が $m だった場合、

makeTree(1)

と、

makeTree(2, '*', 3)

を呼びます。

後者はさらに、

*
├── 2
└── 3

を作ります。

最終的に、

        +
       / \
      1   *
         / \
        2   3

になります。


20. readTree() はASTを計算する

ASTが完成したら、

$s->readTree($s->{root});

です。

ここも再帰です。

sub readTree{
    my ($s,$node) = @_;

    return $node if(ref($node) ne 'HASH');

まず、

数値

ならそのまま返します。


21. NGE は特別扱い

if($node->{data} eq 'NGE'){
    return $op->{$node->{data}}->[0](
        $s->readTree($node->{'right'})
    );
}

例えば、

NGE
 |
 3

なら、

readTree(3)

で 3 を得て、

sub { -$_[0] }

に渡します。

結果、

-3

です。


22. 通常の二項演算

次に、

do {
    $node->{$_} = $s->readTree($node->{$_})
        if(ref($node->{$_}) eq 'HASH')
} for ('left','right');

で、

左の子
右の子

を再帰的に計算します。

例えば、

       +
      / \
     1   *
        / \
       2   3

なら、

*
 ↓
2 * 3
 ↓
6

になり、

+
 ↓
1 + 6
 ↓
7

となります。


23. 最後の1行

exists $op->{$node->{data}}
    ? $op->{$node->{data}}->[0](
        $node->{left},
        $node->{right}
      )
    : $node->{data};

ここで、

演算子なら演算する
演算子でなければ値を返す

としています。

つまり、

+
-
*
/
%
**

なら $op に登録された関数を呼びます。

一方、

1
2
3

などなら、そのまま返します。


24. 実際に 1 + 2 * 3 を追ってみる

これが一番分かりやすいです。

入力:

1+2*3

① adjust

1 + 2 * 3

② item_split

["1", "+", "2", "*", "3"]

③ makeTree

最も外側で優先順位が低いのは +。

       +
      / \
     1   *
        / \
       2   3

④ readTree

まず右側。

2 * 3

↓

6

次に、

1 + 6

↓

7

結果:

7

25. このASTの特徴

この実装、今見るとかなり「素朴」なんですが、実は非常にきれいです。

特に重要なのは、

Tokenizer
    ↓
Parser
    ↓
AST
    ↓
Evaluator

という現在の言語処理系にも通じる構造が、すでにあります。

この初期版では、

adjust()
item_split()

がTokenizer的な役割。

makeTree()

がParser。

newNode()

がAST生成。

readTree()

がEvaluator。

になっています。


26. 今の chabo-dsl との違い

以前の chabo-dsl の構造と比較すると、かなり興味深いです。

初期版:

Astnew
 ├─ setReOps
 ├─ adjust
 ├─ item_split
 ├─ makeTree
 └─ readTree

現在の発展版では、これがもっと明確に、

入力
 ↓
Sugar
 ↓
Tokenizer / Parser
 ↓
AST Builder
 ↓
Evaluator

という形に整理されています。

つまり、今の chabo-dsl は突然生まれたものではなくて、

この小さな Tool::mmt::Controller::Ast の発想を育てていったもの

と見ることができます。

特に、

$text =~ s/$s->{ops}/ $1 /g;

や、

$s->makeTree(...)

のように、**「正規表現で式をほぐして、再帰で木を作る」**という思想は、現在の実装につながっています。

そして個人的に一番面白いのは、初期版ですでに

'**' => [ ..., 4, 'R']

と

'NGE' => [ ..., 4, 'R']

を使って、演算子の優先順位と結合方向をデータとして持たせていることです。

これは単なる「四則演算プログラム」から一歩進んで、すでにちゃんと小さな言語処理系のParserになっています。

このコードを資料化するなら、次は 1 - 2 - 3、-2**2、(1+2)*3 の3例を実際に makeTree() の再帰を1ステップずつ追うと、この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?