Featured image of post コード探偵ロックの事件簿【State】姿なき真犯人〜状態が移り変わる密室〜

コード探偵ロックの事件簿【State】姿なき真犯人〜状態が移り変わる密室〜

巨大な if-elsif によって管理される複雑な状態遷移(Spaghetti State)を、State パターンを用いて解決する過程をコード探偵ロックが解説します。

「どこを変更しても、必ず別の場所が壊れるんです……!」

私は、排熱でサウナのように暑い雑居ビルの一室で、プリントアウトしたソースコードを握りしめながら叫んでいた。「レガシー・コード・インベスティゲーション(LCI)」という胡散臭い看板を掲げたこの事務所の主、ロックは、長さをミリ数に換算したバーボン……ではなく、今回は毒々しい色をした強烈なエナジードリンクを喉に流し込んでいた。

私は中規模ECサイト「NextCart」のバックエンドエンジニアだ。最近、決済・配送のステータス管理システムを引き継いだのだが、これがまさに地獄だった。未払い、支払い済み、出荷準備中、発送済み、キャンセル……これら複雑な状態遷移が、すべて1つの巨大な if-elsif 文で制御されているのだ。先日、「返品処理中」という新しいステータスを追加しようとしただけで、まったく無関係な「出荷」の処理でバグが続発し、私は3日連続で徹夜する羽目になった。

「なるほど。初歩的な匂いだよ、ワトソン君」

彼は勝手に私を助手と呼んだ。私の名前はワトソンではないのだが、訂正する気力も残っていない。

「勝手に助手にしないでください……。とにかく、このコードを見てください」

コードの指紋、容疑者の浮上

ロックは私のノートPCを奪い取り、問題の Order クラスを開いた。

 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
37
38
39
40
41
42
43
44
package Order;
use Moo;
use strict;
use warnings;

has status => (
    is      => 'rw',
    default => 'unpaid', # 取扱状態: unpaid, paid, shipped, cancelled
);

sub process_payment {
    my ($self) = @_;
    if ($self->status eq 'unpaid') {
        $self->status('paid');
        return "Payment processed successfully.";
    } elsif ($self->status eq 'paid') {
        die "Order is already paid.";
    } elsif ($self->status eq 'shipped') {
        die "Cannot pay for a shipped order.";
    } elsif ($self->status eq 'cancelled') {
        die "Cannot pay for a cancelled order.";
    } else {
        die "Unknown status.";
    }
}

sub ship_item {
    my ($self) = @_;
    if ($self->status eq 'unpaid') {
        die "Cannot ship an unpaid order.";
    } elsif ($self->status eq 'paid') {
        $self->status('shipped');
        return "Item shipped successfully.";
    } elsif ($self->status eq 'shipped') {
        die "Order is already shipped.";
    } elsif ($self->status eq 'cancelled') {
        die "Cannot ship a cancelled order.";
    } else {
        die "Unknown status.";
    }
}

# (cancel メソッドも同様の巨大な分岐が続く)
1;

ロックはエナジードリンクの空き缶を机に叩きつけた。

「典型的な『スパゲッティ状態遷移(Spaghetti State)』だね。状態の変化という密室の中で、無数のフラグが暴走しているんだ。状態が増えるたびに、すべてのメソッドに条件分岐を追加しなければならない。これでは、犯人がどこに潜んでいるか分かったものではない」

「そうなんです。新しいステータスを足すたびに、抜け漏れがないかすべてのメソッドをチェックしなければならず……限界です」

「安心したまえ。すべての不吉な if 構文を排除して残ったものが、いかにオブジェクト指向的でなくとも、それが真実なんだ」

彼の言葉の意味はよくわからなかったが、妙な自信だけは伝わってきた。

鮮やかなリファクタリング、真実の開示

「この事件の真犯人は、Order クラスが『すべての状態の振る舞い』を一人で抱え込んでいることにある。これでは多重人格もいいところだ」

ロックはそう言うと、おもむろにキーボードを叩き始めた。

「推理の切り札、 State パターン だ。各状態をそれぞれ独立したクラスとして切り出すことで、巨大な if 構文を解体するのさ」

彼はまず、状態クラスが持つべき振る舞いを定義したRole(インターフェース)を作成した。

1
2
3
4
5
6
7
8
9
package OrderState;
use Moo::Role;

requires 'process_payment';
requires 'ship_item';
requires 'cancel';
requires 'status_name';

1;

「そして、各状態(Unpaid, Paid, Shipped など)をこの Role を消費する独立したクラスとして定義する。例えば、未払い(Unpaid)状態ならこうだ」

 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
package OrderState::Unpaid;
use Moo;
with 'OrderState';
use OrderState::Paid;
use OrderState::Cancelled;

sub process_payment {
    my ($self, $order) = @_;
    # 状態の遷移は次の状態オブジェクトをセットすることで行う
    $order->state(OrderState::Paid->new);
    return "Payment processed successfully.";
}

sub ship_item {
    my ($self, $order) = @_;
    die "Cannot ship an unpaid order.";
}

sub cancel {
    my ($self, $order) = @_;
    $order->state(OrderState::Cancelled->new);
    return "Order cancelled.";
}

sub status_name { 'unpaid' }

1;

「支払い済み(Paid)状態はこうなる」

 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
package OrderState::Paid;
use Moo;
with 'OrderState';
use OrderState::Shipped;
use OrderState::Cancelled;

sub process_payment {
    my ($self, $order) = @_;
    die "Order is already paid.";
}

sub ship_item {
    my ($self, $order) = @_;
    $order->state(OrderState::Shipped->new);
    return "Item shipped successfully.";
}

sub cancel {
    my ($self, $order) = @_;
    $order->state(OrderState::Cancelled->new);
    return "Order cancelled.";
}

sub status_name { 'paid' }

1;

「なるほど……」私は目を見張った。「それぞれのクラスが、自分自身の状態のときの振る舞いだけを知っていればいいんですね。でも、それだと大元の Order クラスはどうなるんですか?」

「ふふっ、そこがこのトリックの美しいところさ。Order クラスは現在の状態オブジェクトを保持し、操作を委譲するだけの『器』になる」

 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
package Order;
use Moo;
use strict;
use warnings;
use OrderState::Unpaid;

has state => (
    is      => 'rw',
    default => sub { OrderState::Unpaid->new },
);

sub process_payment {
    my ($self) = @_;
    return $self->state->process_payment($self); # 現在の状態に丸投げ
}

sub ship_item {
    my ($self) = @_;
    return $self->state->ship_item($self);
}

sub cancel {
    my ($self) = @_;
    return $self->state->cancel($self);
}

# 互換性のためのメソッド
sub status {
    my ($self) = @_;
    return $self->state->status_name;
}

1;

「これですべての if 文が消え去った。振る舞いと状態遷移の責任は、各状態クラスが自律的に持つことになったんだ」

事件の終わり、平和なビルド

「信じられません……。これなら、新しく『返品処理中』という状態を追加したい場合も、既存の Order クラスや他の状態クラスを書き換える必要がないんですね」

「その通り。新しい状態クラスを追加し、必要な遷移元クラスだけを少し修正すればいい。完全なオープン・クローズドの原則(OCP)だよ、ワトソン君」

ロックがテストコマンドを実行すると、ターミナルには美しい緑色の文字が並んだ。

1
2
3
$ prove test.t
test.t .. ok   
All tests successful.

テスト(ビルド)が緑色に点灯し、無数のフラグが暴走していたシステムに秩序が戻った瞬間だった。

「これで事件解決だ。さて、今回の報酬だが……」ロックはニヤリと笑った。「この消し去った if 文の数と同じ本数のエナジードリンクを要求しようかな」

私はため息をつきながら、近くのコンビニへ走る準備をした。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
スパゲッティ状態遷移(Spaghetti State)。すべての状態遷移ロジックが単一クラス内の巨大な if-elsif 文で管理されており、新しい状態を追加するたびに既存のすべてのメソッドを修正しなければならない状態。State パターン。各状態を独立したクラスとして分離し、それぞれの状態が自身の振る舞いと遷移先を自律的に管理する設計方式。巨大な条件分岐が完全に排除され、状態ごとの振る舞いがカプセル化された。新しい状態の追加時に既存クラスの変更が最小限で済み、Open-Closed Principle(開放閉鎖の原則)に準拠した拡張が可能になった。

推理のステップ

  1. 状態の特定と Role の定義: システムに存在する「状態」を洗い出し、それらが共通して持つべき振る舞い(メソッド)を Role (Interface) として定義する。
  2. 状態クラスの分離: 各状態を独立したクラスとして実装する。それぞれのクラスは、自分自身が現在の状態であるときの振る舞いだけを定義する。
  3. 状態遷移の移譲: 状態が変わるロジック(次の状態クラスのインスタンスをセットする処理)も、各状態クラス内に記述する。
  4. コンテキストの単純化: メインとなるクラス(今回なら Order)は、現在の状態オブジェクトをプロパティとして保持し、すべての操作をその状態オブジェクトに委譲する。

ロックより

フラグ変数の海で溺れそうになったとき、多くのプログラマは if 文という名の浮き輪にすがりつこうとする。だが、それはやがて首を絞める鎖になるだけだ。 状態が自らの振る舞いを知っていれば、愚かな条件分岐など不要なのだよ。次もまた、初歩的なにおいがする事件を待っているよ。

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