Featured image of post コードシェフの仕込み帳【Interpreter】絡み合うレシピの呪文〜if文の泥沼を抜け出す式ツリーの仕込み〜

コードシェフの仕込み帳【Interpreter】絡み合うレシピの呪文〜if文の泥沼を抜け出す式ツリーの仕込み〜

レシピ指示の追加で if/elsif 泥沼化した文字列解析コードを、Interpreter パターンで文法をオブジェクト化し、既存コード無修正で拡張可能な設計に仕込み直します。

キーボードを叩く規則的な音に混じって、低く機械的な動作音がラボのフロアに響いていた。 ガラス越しに見えるのは、ステンレスの光沢も眩しい最新型のスマートオーブンや真空調理マシン。

「ミライ・フーズ」の自動ミールキット実証テストラボ。 私たちは試食モニターとして招かれていた。ラボの中は真空調理器の隙間から漏れ出る、ハーブとチキンの豊潤な香りで満ちていた。

「いやあ、この加熱ファンの対流制御は素晴らしい仕込みだ。熱が均一に回って、食材の水分を逃さない」

シェフは、スマートオーブンのハードウェア設計にすっかり職人としての興味を惹かれたようで、同僚の解説員エンジニアと別の機材の前で熱心に話し込んでいる。

私は一人、PCの画面とにらめっこしながら頭を抱えている一人の若いエンジニアにそっと近づいた。 彼の名前は大輝(ダイキ)さん(28)。几帳面そうな黒縁メガネをかけ、真剣な眼差しで画面を睨んでいるが、その表情には強い焦りと疲労がにじみ出ていた。

「誰でも自宅で、プロの味を完璧に再現できる調理器を届けたいんです。でも、レシピを機械に解釈させるコードが完全に詰まってしまって……」

画面を覗き込むと、そこには if と elsif の巨大な分岐が、迷路のように複雑に入り組んだコードが広がっていた。


本日の持ち込み素材:文字列解析の if/elsif 泥沼

ダイキさんが開発しているのは、自動調理マシンの動きを制御する「レシピ文字列解析器」だった。 「80度で 鶏肉を 蒸す」といった口頭レシピ風のテキストを入力すると、それを解析して加熱温度を設定し、食材を識別し、対応する調理動作を実行する。

しかし、ミールキットのメニューが増えるにつれて、「中火で」「1200秒で」といった新しい条件の追加や、「蒸した後に炒める」といったネストした調理ステップの要望が次々と発生。そのたびに解析コード(Parser)が肥大化していったという。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# agents/code-chef/tests/interpreter-pattern/before.t より抜粋
package MyRecipe::Parser;
use Moo;
use v5.36;

sub run_recipe ($self, $recipe_str) {
    my @tokens = split /\s+/, $recipe_str;
    my $context = {
        ingredients => [],
        actions     => [],
        temp        => 0,
        log         => [],
    };

    my $i = 0;
    while ($i < @tokens) {
        my $token = $tokens[$i];
        if ($token =~ /^(\d+)度で$/) {
            $context->{temp} = $1;
            push @{$context->{log}}, "温度を ${1}度 に設定";
        }
        elsif ($token eq '鶏肉を') {
            push @{$context->{ingredients}}, '鶏肉';
            push @{$context->{log}}, "鶏肉 を準備";
        }
        elsif ($token eq '蒸す') {
            my $ings = join('と', @{$context->{ingredients}});
            push @{$context->{log}}, "${ings}を " . $context->{temp} . "度で 蒸します";
        }
        # 【問題点】: 新しい調理法(例:「炒める」)や、順序命令(「Aの後にBする」)を
        # 追加したい場合、この Parser のループ処理と if/elsif 条件分岐を直接書き換える必要がある。
        # これは「開閉原則(OCP)」に違反し、規模が大きくなるとパーサーがメンテナンス不能の泥沼と化す。
        $i++;
    }
    return $context;
}

ダイキさんは額の汗を拭いながら嘆いた。 「動いてはいるんです。でも『調理時間を秒単位で指定したい』とか『蒸した後に炒める』などのネスト命令をサポートするたびに、この巨大な if/elsif を全部修正してテストし直すのは限界です。新しい命令を追加するだけで、なぜ解析機を直さなくてよくなる設計なんてあるんでしょうか?」

私はその画面をじっと見つめていた。 前回の Singleton で「グローバルな共通化がもたらす密結合の罠」を痛感していたからこそ、このコードが抱える「結合度」の高さが、私の目にも明らかに見えてきたのだ。

「ダイキさん。これって、まるで新しいミールキットのおかずを追加するたびに、オーブンを分解してメインの電子基板自体をはんだ付けし直して回路を作り直しているようなものじゃないですか?」

「え……?」 ダイキさんが驚いたように顔を上げた。

「おかずや調理法を『カード(部品)』のように独立させておいて、オーブンはただカードをスロットに挿して順番に読み込むだけにすればいいんです。はんだ付けを毎回やり直す必要なんてないはずです!」

「電子基板のはんだ付け……。そうか、レシピのパースロジックと、レシピの構成要素が完全に密結合になってしまっているのか!」 ダイキさんの目が輝き始めた。

私は、シェフが昔教えてくれた「結合度」と「拡張性」の意味が、自分の頭の中でしっかりと繋がったのを感じて、誇らしい気持ちになっていた。


品質確認:仕込みの失敗を嗅ぐ

「いい線いってるな」

いつの間にか後ろに戻ってきていたシェフが、小さく頷いた。

「その通りだ。レシピの文法規則が増えるたびに、それを解析する厨房(Parser)自体を壊して改築している。これでは厨房がいくつあっても足りん」

シェフは画面のコードを指差し、診断を下した。

「これは『文字列解析の if/elsif 泥沼 (unmaintainable-parser)』というアンチパターンだ。文法規則をコードの中に直接書き込んでしまった結果、文法の拡張が解析器そのものの破壊的変更になってしまうアンチパターン」

この問題を解決するために、シェフはひとつのデザインパターンを提示した。

本日のプロの調理技法:Interpreter

パターン名ひとことで言うと(ジュニア向け定義)
Interpreterパターン独自の簡易的な言語(文法)を作り、それをオブジェクトの木構造で表現して再帰的に実行する設計技法

「レシピをただの文字列として扱うのをやめるんだ。レシピの文法要素(食材、アクション、条件、順序)を、それぞれ自立したオブジェクトとして具現化する。そして、それらを組み合わせた木構造を再帰的に評価するんだ」

ジュニア向けの用語スキャフォールディングとして、以下の概念を整理しておこう。

  • 抽象構文木(AST): プログラムの文法規則を、親子関係を持つ木のようなオブジェクトのつながりで表現したデータ構造
  • 終端表現 (Terminal): これ以上分解できない、文法上の最小単位(例: 具体的な食材カードや調理アクション)
  • 非終端表現 (Nonterminal): 他の表現を組み合わせて作る、複雑な命令規則(例: 「Aの後にBする」という順序、温度の修飾条件)

たとえば、今回のように「80度で鶏肉を蒸し、その後に皿に盛り付ける」というレシピをオブジェクトの木構造(AST)で表現すると、以下のような親子関係のつながりになります。

Interpreter Recipe AST Tree


包丁を入れ直す:プロの仕事

「よし、仕込みカード(Expression)の概念をコードで実演して見せよう」 シェフはスマートオーブンの横で、使い慣れた包丁を取り出して実演を始めた。

「調理工程を『鶏肉(食材)』『蒸す(アクション)』『低温で(修飾)』という、それぞれが自分の仕事を『実行(interpret)』できるカードとして定義する。お玉を振る動き、食材、火加減。それぞれが『自分の仕事』を知っていて、それらを繋ぎ合わせるんだ」

リファクタリング後の after.t のコード構造を見てみよう。

1. 抽象 Expression (Moo::Role) と Context の定義

まず、すべての文法オブジェクトが共有するインターフェース Cook::Expression をロールで定義し、調理状態を保持する Context を用意する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package Cook::Expression;
use Moo::Role;
use v5.36;
requires 'interpret'; # コンテキストを受け取って自身の文法を実行する

package Cook::Context;
use Moo;
use v5.36;

has current_temp => (is => 'rw', default => 0);
has ingredients  => (is => 'rw', default => sub { [] });
has log          => (is => 'ro', default => sub { [] });

sub add_log ($self, $message) {
    push @{$self->log}, $message;
}

2. TerminalExpression(終端表現)の実装

食材(Ingredient)や調理アクション(Action)など、文法の最小単位を定義する。これらは末端のリーフノードとなる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 食材カード
package Cook::Expression::Ingredient;
use Moo;
use v5.36;
with 'Cook::Expression';

has name => (is => 'ro', required => 1);

sub interpret ($self, $context) {
    push @{$context->ingredients}, $self->name;
    $context->add_log($self->name . " を準備しました");
}

# 調理アクションカード
package Cook::Expression::Action;
use Moo;
use v5.36;
with 'Cook::Expression';

has type => (is => 'ro', required => 1); # '蒸し', '炒め' など

sub interpret ($self, $context) {
    my $ing_list = join('と', @{$context->ingredients});
    $context->add_log("${ing_list}を " . $self->type . "ます");
}

3. NonterminalExpression(非終端表現)の実装

温度修飾(Modifier)や順序実行(Sequence)など、他の式を包み込んでネスト制御するノードを実装する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# 温度設定修飾
package Cook::Expression::Modifier;
use Moo;
use v5.36;
with 'Cook::Expression';

has temp => (is => 'ro', required => 1);
has expr => (is => 'ro', required => 1); # 内包する Expression

sub interpret ($self, $context) {
    my $old_temp = $context->current_temp;
    $context->current_temp($self->temp);
    $context->add_log("温度を " . $self->temp . "度 に設定(旧: ${old_temp}度)");
    
    $self->expr->interpret($context); # 内側に包まれた処理を実行
    
    $context->current_temp($old_temp); # 温度を元に戻す(状態のクリーンアップ)
}

[!NOTE] Modifier 内で現在の温度を設定する前に元の温度($old_temp)を退避させ、内包する Expression を評価し終わった後に元に戻しています。これにより、ネストされた別の Expression が完了した際に全体の状態が汚染されず、後続の処理に影響を与えないように状態のクリーンアップを行っています。

順序実行(A の後に B をする)

package Cook::Expression::Sequence; use Moo; use v5.36; with ‘Cook::Expression’;

has first => (is => ‘ro’, required => 1); has second => (is => ‘ro’, required => 1);

sub interpret ($self, $context) { $self->first->interpret($context); $context->add_log("— 次の工程へ —"); $self->second->interpret($context); }

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

---

### ダイキさんの鋭い問いParser の中が分散しただけでは

コードを見つめていたダイキさんが疑問を投げかけてきた

これ結局クラスがたくさん増えただけで本質的には同じじゃないですか 文字列解析の if/elsif 泥沼がExpression クラス群に散らばっただけのように見えます

シェフはフッと笑い包丁を置いた

では新しく盛り付けるという動作を追加したくなったらどうする?」

BeforeコードならParserの巨大な if/elsif を探し出して正規表現を書き換えバグを恐れながら改修します

そうだだがこの Afterコードならどうだ 既存の Expression  ContextAST評価器は一切触る必要がないただ新しい Expression クラスをひとつ追加するだけで済む

#### 新しいアクション盛り付けるの追加既存コード無修正
```perl
package Cook::Expression::Action::Plate;
use Moo;
use v5.36;
with 'Cook::Expression';

sub interpret ($self, $context) {
    my $ing_list = join('と', @{$context->ingredients});
    $context->add_log("${ing_list}を 皿に盛り付けます");
}

「既存のコードは1文字も触らない。新しいクラスを追加し、ASTの末尾に Sequence で連結するだけだ。これをオブジェクト指向設計における 『開閉原則(Open/Closed Principle)』 と呼ぶ。拡張に対して開いており、修正に対して閉じている。解析器の脳みそを外科手術する必要は二度とないんだ」


完成:試食合格

ダイキさんは、リファクタリング後の Expression 群を組み立て、テストスクリプトを実行した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
my $ast = Cook::Expression::Sequence->new(
    first => Cook::Expression::Modifier->new(
        temp => 80,
        expr => Cook::Expression::Sequence->new(
            first  => Cook::Expression::Ingredient->new(name => '鶏肉'),
            second => Cook::Expression::Action->new(type => '蒸す'),
        )
    ),
    second => Cook::Expression::Action::Plate->new,
);

my $context = Cook::Context->new;
$ast->interpret($context);

コンソール画面に、グリーンに輝くテスト通過の文字が並ぶ。

1
2
3
4
5
6
7
8
ok 1 - 処理完了後、温度はデフォルトに戻っている
ok 2 - 食材に鶏肉が準備されている
ok 3 - ログ1: 温度設定
ok 4 - ログ2: 食材準備
ok 5 - ログ3: シーケンス区切り1
ok 6 - ログ4: 蒸すアクション
ok 7 - ログ5: シーケンス区切り2
ok 8 - ログ6: 追加された盛り付けアクション(既存コード無修正で実現)

「動いた……! テストが完璧に通りました! 既存の解析器を一切壊すことなく、新設した盛り付けアクションが正しく実行されています!」

ダイキさんは椅子から飛び上がり、ガッツポーズをした。

その瞬間、スマートオーブンから「ピー」と軽快な音が鳴った。 扉を開けると、ハーブの香りがふわりと広がる。80度でじっくり蒸し上げられ、旨味が中に閉じ込められた鶏肉料理が、完璧な仕上がりで現れた。

三者で試食する。鶏肉は驚くほどふっくらと柔らかく、スマートオーブンの正確な熱対流と、それを制御する Expression の「正しい文脈」が見事に味になって現れていた。

「機械がこの味を正確に再現できるのは、レシピという言葉を壊さない設計にしたからだ」 シェフはダイキさんの肩を叩いた。

「レシピという言葉を大切に仕込めば、機械だって美味い料理を作る。お前の仕込み、上出来だ。自信を持って、全国の家庭にこの美味しさを届けてこい!」

「はい!」 ダイキさんは満面の笑みで頷いた。 ラボを出る私たちの背後では、スマートオーブンが新たなExpressionのツリーを読み込み、次の完璧な料理を仕込み始めていた。


シェフの仕込み工程表

問題(調理ミス)技法(パターン)効果(仕上がり)
文字列解析の if/elsif 泥沼
文法や条件の追加(ネストなど)のたびに、解析ロジック(Parser)の巨大な if/elsif を外科手術のように直接書き換える必要があり、コードが破綻していた。
Interpreterパターン
文法規則(食材、アクション、条件、順序)をそれぞれ Expression オブジェクトとして独立させ、それらを組み立てた木構造(AST)を再帰的に評価する設計に変える。
開閉原則(OCP)の獲得
新しい Expression を追加する際、既存の Expression や解析コアを一切修正することなく、新クラスを追加してツリーに挿すだけで機能拡張が可能に。

工程

  1. 文法規則の識別: 入力されるレシピ文字列の構成要素を、これ以上分解できない終端記号(食材、動作)と、それらを修飾・連結する非終端記号(温度指定、順序)に分類する。
  2. 抽象 Expression の宣言: Cook::Expression ロールを定義し、共通の評価メソッドである interpret($context) を宣言する。
  3. 文法クラスの具現化: 終端表現および非終端表現をそれぞれ Moo クラスで実装する。非終端表現は、内包する別の Expression をアトリビュートで保持し、interpret 内で再帰的に評価する。
  4. 木構造(AST)の構築と実行: クライアント側で Expression オブジェクトを組み合わせた木構造(抽象構文木)を構築し、ルートノードの interpret($context) を呼び出すことでレシピ全体を実行する。

シェフより

言葉は生き物だ。レシピの呪文をそのまま parsing しようとして、巨大な if/elsif の鍋に何でもかんでも放り込むな。味の境界線がぐちゃぐちゃになり、最後には何がなんだか分からなくなる。

レシピという言葉そのものを、小さな『カード(Expression)』として独立した器に盛り付けるんだ。それらを木構造に繋ぎ合わせていくだけで、どんな複雑なフルコースの呪文だって、元のコードを1文字も汚さずに解釈できるようになる。

言葉を大切に仕込むこと。それが、機械と人との美味しい約束を繋ぐプロの仕事だ。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。