【Rust】関数の「事前パース」で電卓を高速化!AST管理とデバッグ出力の実装 #5

前回、四則演算ができる Parser を実装しました。しかし、今のままでは TOML に書かれた関数はただの「文字列」でしかありません。計算のたびにパースするのは非効率ですし、文法エラーも実行するまで分かりません。

前回 四則演算できるようにする: 【Rust】数式を「計算の木」へ:ASTと再帰下降構文解析で電卓エンジンの心臓部を作る #4

今回は、電卓の起動時にすべての関数をあらかじめ解析(Pre-parse)し、「いつでも計算できる状態(AST)」で保持する仕組みを実装しました。

計算機構築までのワークフロー。現在地は式構造を構造木二階席するParser

1. 関数のパースの実装

1.1 「文字列」から「解析済みデータ」へ

これまでは TOML の中身をそのまま持っていましたが、新しく ParsedFunction 構造体を定義し、解析済みの木構造(AST)を保持するように改良しました。

#[derive(Debug, Clone)]
pub struct ParsedFunction {
    pub args: Vec<String>,     // 引数名リスト
    pub ast: Expr,             // 解析済みの式(ここは ast、以前はtomlのままだった)
    pub memoize: bool,         // メモ化フラグ(キャッシュ用)
    pub max_depth: usize,      // 再帰制限 (スタックオーバーフロー対策)
}

#[derive(Debug, Clone)]
pub struct Definitions {
    pub constants: HashMap<String, f64>,
    pub functions: HashMap<String, FunctionDef>,      // 生の定義
    pub parsed_functions: HashMap<String, ParsedFunction>, // 新規追加:解析済みAST
}

1.2. 実装:事前パース・パイプライン

ディレクトリから TOML をロードした直後に、すべての関数を Lexer → Parser に通して、parsed_functions に格納します。

fn pre_parse_functions(&mut self) {
    let mut parsed_map = std::collections::HashMap::new();

    for (name, def) in &self.definitions.functions {
        let (expr_str, args, memoize, max_depth) = match def {
            defs::FunctionDef::Simple(s) => (s.clone(), vec![], false, 32),
            defs::FunctionDef::Complex { args, expr, memoize, max_depth } => {
                (expr.clone(), args.clone(), *memoize, *max_depth)
            }
        };

        // ここで前回作った Parser を利用
        let mut lexer = lexer::Lexer::new(&expr_str);
        if let Ok(tokens) = lexer.tokenize() {
            let mut parser = parser::Parser::new(tokens);
            if let Ok(ast) = parser.parse() {
                parsed_map.insert(name.clone(), defs::ParsedFunction {
                    args, ast, memoize, max_depth,
                });
            }
        }
    }
    self.definitions.parsed_functions = parsed_map;
}

動作の流れは以下のような感じです
起動、またはホットリロードボタン → tomlをロード→pre_parse_functionsに通す→解析成功したら defsに渡す。
これで、実行時に文字列を気にする必要はなくなりました。計算エンジンはただ「木を辿る」ことに専念できます。

1.3. デバッグ機能:木の中身を覗き見る

実装はしたものの、内部でどんな「木」が作られたかを確認できないので、AST を人間が読める形式で出力する evaluate_simple も実装しました。前回作った四則演算の計算+関数は未定義というのを返す関数を置き換えています。

pub fn evaluate_simple(expr: &Expr) -> String {
    match expr {
        Expr::Number(n) => n.to_string(),
        Expr::BinaryOp { left, op, right } => {
            format!("({} {:?} {})", evaluate_simple(left), op, evaluate_simple(right))
        }
        Expr::FunctionCall { name, args } => {
            let parsed_args: Vec<String> = args.iter().map(|a| evaluate_simple(a)).collect();
            format!("{}({})", name, parsed_args.join(", "))
        }
        // ...
    }
}

これを使うと、例えば 1 + 2 * 3(1 Add (2 Mul 3)) のように表示され、優先順位が正しく解釈されているか一目でわかります。

1.4 動作チェック

evaluate_simpleでうまくパース出来ているか確認してみました。

まずプレロード完了したかどうか確認

まずは、toml読み込みと関数ロード完了

基本計算と関数のパース

次に、基本関数のパースチェック

これを見れば、優先度順に塊にできていることが確認できます。

 1 + 1      → (1 Add 1)
 1 + 2*2    → (1 Add (2 Mul 2))
 3/2 + 4/5  → ((3 Div 2) Add (4 Div 5))
 2^3^2      → (2 Pow (3 Pow 2))    //右結合
conv(32,3)  → OK :conv(32,3) (Defined)

fib関数は定義されているが打ち込むと、Undifined Function!

# toml内でのfibの定義
fib = { args = ["n"], expr = "if(n < 2, n, fib(n-1) + fib(n-2))", memoize = true, max_depth = 20 }
定義はできているが、中身のパースができていない fib関数のパース失敗

ロードはエラーにならなかったのは、構文的なエラーがなかったためです。関数の中身を見ると”>”や”=”などの演算子がそもそも定義されていないため、ASTの解析でエラーがでて、結果関数定義なしとなってしまっています。

次回は演算子の追加予定

今回の実装で、関数を事前ロードして木構造にパースする仕組みができました。今の実装だと四則演算とPowを使った関数(最終的な分解後)しか使えないので、次回は今回使えなかったfib関数を使えるように、比較演算子やif文などの条件分岐構文を、Definitionsに追加してもう少し複雑な演算に対応できるようにしていこうと思います。

次回、演算子の優先順位テーブルを大幅に拡張し、比較演算子と if による条件分岐を実装します。

条件分岐のDefinitions : 関連記事は、2026年5月1日に公開予定 (あと19時間)