Featured image of post コードシェフの仕込み帳【State】空席のはずが会計待ち〜boolフラグの台帳を、席に仕事をさせて直す〜

コードシェフの仕込み帳【State】空席のはずが会計待ち〜boolフラグの台帳を、席に仕事をさせて直す〜

複数のbooleanフラグで席を管理し「空席なのに会計待ち」の矛盾に陥るコードを、Stateパターンで整理します。PerlとMooで状態自身に次の遷移を持たせ、ありえない組合せを作れなくする設計へ。仕組みから丁寧に解説します。

「空席のはずの席が、画面では『会計待ち』になってるんです」

昼の営業を終えた静かな店内に、そう言いながらお客さんが入ってきました。私はこの「コード食堂」で見習いとして働く、駆け出しのエンジニアです。さっきまで客席のテーブルを拭いていた私は、布巾を持ったまま振り返りました。今日はシェフが、旗(フラグ)を立てすぎて矛盾だらけになった席の管理を、どう仕立て直すのかを見せてもらいます。

この記事で学ぶこと

この記事は、is_occupiedis_billing のような真偽値のフラグが増えすぎて、ありえない組み合わせが生まれてしまった状態管理を、Stateパターンで整理する話です。題材はある居酒屋さんの「席の管理」。Perlのコードを少しずつ仕立て直していきます。

学ぶことひとことで言うと
Stateパターン状態ごとの振る舞いを部品にし、状態自身に「次の遷移」を持たせる技法
ConcreteState と Context個別の状態(空席・着席中…)と、それを持つ席
boolフラグの乱立状態を複数の真偽値で表し、ありえない組み合わせが生まれる問題
開放閉鎖原則(OCP)既存をいじらず、追加だけで機能を増やせる状態
StateとStrategyの違い外から選ぶ(Strategy)か、状態が自分で遷移するか(State)

対象読者は、次のような人を想定しています。

  • PerlとMooの基本(hasnewwith)がなんとなく分かる
  • is_xxx のようなフラグが増えて、状態の管理がこんがらがった経験がある
  • 第1作のStrategyパターンを読んで、似ているけれど何が違うのか気になっている

技術スタックは Perl / Moo / Types::Standard です。コードはすべて手元で動かし、テストが通ることを確認しています。なお本文中のモジュールは要点を抜き出して示しているため、実際にファイルへ保存するときは Perl の作法として末尾に 1; を加えてください。

食堂への来客

私が客席のテーブルを拭き、椅子を一脚ずつ逆さに上げていたところでした。同僚の紹介で来た、というそのお客さんは、椅子に座ると落ち着いた口ぶりで、しかしどこか困りきった様子で事情を話し始めました。

聞けば、知り合いが営む居酒屋に頼まれて、席の管理を手伝う小さなPerlプログラムを書いたのだそうです。最初は「その席が空いているか、埋まっているか」という真偽値ひとつだけの、簡単なものでした。それが好評で、「会計中の席も区別したい」「清掃中の席も分けたい」と頼まれるたびに、is_billingis_cleaning とフラグを足していった。そして先日、会計後に席を片付けへ回す処理で、清掃中を表すフラグを立て忘れる版を出してしまった。片付いていない席が画面上は「空席」に見え、次のお客さんを同じ席に案内してしまった——というのです。

「論理的には、合ってるはずなんです。なのに、空席のはずの席が『会計待ち』になっていたり、片付け前の席にお客さんを通してしまったり。フラグが、増えすぎて」

お客さんが見せてくれたのは、こういうコードでした。

 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
package Table;
use Moo;
use Types::Standard qw(Int Bool);

has number      => (is => 'ro', isa => Int, required => 1);
has is_occupied => (is => 'rw', isa => Bool, default => 0);  # 客がいる
has is_billing  => (is => 'rw', isa => Bool, default => 0);  # 会計中
has is_cleaning => (is => 'rw', isa => Bool, default => 0);  # 清掃中

sub seat {        # 客を案内する
    my $self = shift;
    die "片付いていません" if $self->is_cleaning;
    die "すでに客がいます" if $self->is_occupied;
    $self->is_occupied(1);
}
sub bill {        # 会計に入る
    my $self = shift;
    die "客がいません" unless $self->is_occupied;
    $self->is_billing(1);
}
sub finish {      # 会計を終えて片付けに回す
    my $self = shift;
    $self->is_occupied(0);
    $self->is_billing(0);
    $self->is_cleaning(1);   # この1行を忘れると「空席のはずが…」のバグ
}
sub clean {       # 清掃完了
    my $self = shift;
    $self->is_cleaning(0);
}

私にはコードのことはよく分かりません。でも「片付いていない席に、間違えて次のお客さんを通してしまった」と聞いて、思わずさっき自分が拭いていたテーブルを見ました。お皿を下げて、布巾をかける前の席に、もしお客さんを案内してしまったら——と想像すると、少しひやりとしました。

シェフは、伝票挟みから何かを取るような手つきで、黙って画面に目をやりました。

旗の立てすぎ

シェフは画面を上から下まで、一度だけ読みました。そして、伝票挟みに溜まった札を一枚ずつ捲るような手つきで、フラグの並んだ部分を指でなぞりました。

「動くには動くんだな、これは」

シェフはまず、そう認めました。客の出した皿をいきなりけなさないのと同じで、この人は壊れたコードでもまず一度は受け止めます。それから、is_occupiedis_billingis_cleaning と並んだ三つのフラグを順に指して、続けました。

「フラグってのは、立てるか倒すかの、小さな旗だ。旗が三本ありゃ、組み合わせは八通り。だがな、そのうち現実にゃありえん席が、半分も混じってる」

私には意味が分かりませんでした。お客さんも「ありえない席、ですか」と聞き返します。シェフはこう言いました。

「空席の旗が降りてるのに、会計の旗だけ立ってる。そんな席、現実のどこにある? 客もいないのに、会計だけ進んでる席だぞ。お前の言う『空席のはずが会計待ち』ってのは、それだ」

これが、プログラミングでいう boolフラグの乱立という状態でした。ひとつの「席の状態」を、複数の真偽値(true/false の旗)の組み合わせで表そうとすると、本来ありえない組み合わせまで作れてしまうのです。三つの旗なら八通り。そのうち「正しい席」は、空席・着席中・会計中・清掃中の四つだけ。残りの四通りは、どこにも存在しないはずの席です。

「最初は、旗ひとつで十分だったんです」とお客さんはうつむきました。「空いてるか、埋まってるか。それだけで。でも増やすたびに、立て忘れが怖くなって……どの旗とどの旗を一緒に立てちゃいけないか、紙に書いて確かめてたんですけど、もう手に負えなくて」

私は、前の仕込みを思い出しながら、おそるおそる口を挟みました。「旗の上げ下げを、ひとつでも間違えると……お店に存在しないはずの席が、画面の中にできちゃう、ってことですか?」

シェフは「分かってきたじゃないか」と短く言って、伝票挟みの札をそろえました。

札は一枚でいい

ここからが、シェフの仕事でした。

シェフは、テーブルの上に貼られた何枚もの付箋を剥がすような手つきをしてみせました。「占有中」「会計中」「清掃中」——ごちゃごちゃと貼られた旗を、一枚ずつ剥がしていく。そして、代わりに一枚の札をテーブルに掛けました。

「席に貼る旗は、何枚もいらん。札を一枚だけ掛ける。今この席がどういう状態か、その札を見りゃ一目で分かる」

それだけではありませんでした。シェフはその札を裏返して、何かを書き込む仕草をしました。

「この札の裏に、次にどの札へ掛け替えられるかを書いておく。『清掃中』の札の裏には、『次は空席だけ』とな。そうすりゃ、清掃中からいきなり着席中へは飛べん」

コードの上では、こういう順番で進みました。

まず、「席の状態とはこういうものだ」という共通の型紙を決めます。Perlでは Moo::Role という仕組みで、「この約束を必ず持っていろ」という型紙を作れます。ここがこのパターンの肝で、どの操作も、最初は『できない』を既定にしておくのです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 状態の共通の型紙(State 役)。既定は「その操作はできない」=拒否。
package Table::State;
use Moo::Role;
requires 'name';                       # 状態の名前(空席・着席中…)

sub seat   { my ($self, $table) = @_; $self->_reject('案内') }
sub bill   { my ($self, $table) = @_; $self->_reject('会計') }
sub finish { my ($self, $table) = @_; $self->_reject('片付け') }
sub clean  { my ($self, $table) = @_; $self->_reject('清掃') }

sub _reject {
    my ($self, $op) = @_;
    die sprintf('%sの席に「%s」はできません', $self->name, $op);
}

この Table::State が、パターンの名前のもとになっている State(状態) です。Stateパターンとは、ひとことで言えば「状態ごとの振る舞いを部品にして、状態自身に“次にどの状態へ進むか”を持たせる技法」のこと。

ポイントは、seat(案内)も bill(会計)も、型紙の段階ではすべて _reject、つまり「できません」と断るようにしてあることです。各状態は、このうち自分に許された操作だけを上書きします。

1
2
3
4
5
6
# 空席(ConcreteState)
package Table::State::Empty;
use Moo;
with 'Table::State';
sub name { '空席' }
sub seat { my ($self, $table) = @_; $table->set_state(Table::State::Seated->new) }

空席の席にできるのは「案内(seat)」だけ。だから Emptyseat だけを上書きし、その中で自分で次の状態(着席中)へ掛け替えますbillfinish は上書きしないので、型紙の「できません」がそのまま効きます。同じように、残りの状態も「自分に許された操作」だけを書きます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 着席中
package Table::State::Seated;
use Moo;
with 'Table::State';
sub name { '着席中' }
sub bill { my ($self, $table) = @_; $table->set_state(Table::State::Billing->new) }

# 会計中
package Table::State::Billing;
use Moo;
with 'Table::State';
sub name { '会計中' }
sub finish { my ($self, $table) = @_; $table->set_state(Table::State::Cleaning->new) }

# 清掃中(ここで「案内」を許さないのが、今回いちばん大事なところ)
package Table::State::Cleaning;
use Moo;
with 'Table::State';
sub name { '清掃中' }
sub clean { my ($self, $table) = @_; $table->set_state(Table::State::Empty->new) }

Cleaning(清掃中)が上書きしているのは clean(清掃完了)だけ。seat(案内)は上書きしていません。だから清掃中の席に案内しようとすると、型紙の「できません」が断ってくれます。これが「片付け前の席に、次のお客さんを通してしまう」バグを、構造として防ぎます。

こうして個別になった状態を、ConcreteState(具体的な状態) と呼びます。一枚一枚の札です。

そして、お店の席そのもの。これがパターンでいう Context(文脈・状態を持つ側) にあたります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# 席(Context)— 操作を現在の状態に委譲するだけ。中身を知らない。
package Table;
use Moo;
use Types::Standard qw(Int ConsumerOf);

has number => (is => 'ro', isa => Int, required => 1);
has state  => (
    is      => 'rw',
    isa     => ConsumerOf['Table::State'],
    default => sub { Table::State::Empty->new },
);

sub set_state { my ($self, $s) = @_; $self->state($s) }
sub seat   { my $self = shift; $self->state->seat($self) }
sub bill   { my $self = shift; $self->state->bill($self) }
sub finish { my $self = shift; $self->state->finish($self) }
sub clean  { my $self = shift; $self->state->clean($self) }

Table を見てください。さっきまで is_occupied だの is_billing だの、いくつもの旗を立て倒ししていた席が、今は state という札を一枚持つだけになりました。seat を呼ばれたら、今の札(状態)に「案内、頼む」と取り次ぐだけ。どう振る舞うか、次にどの状態になるかを、席はもう知りません。

state の型に書いた ConsumerOf['Table::State'] は、「Table::State の型紙を守った札しか持たない」という意味です。関係ないものをうっかり渡すと、その場でエラーになって教えてくれます。

クラスの関係を図にすると、こうなります。

	classDiagram
    class TableState {
        <<role>>
        +name()
        +seat(table)
        +bill(table)
        +finish(table)
        +clean(table)
    }
    class Table {
        +number
        +state
        +seat()
        +bill()
    }
    class Empty {
        +seat(table)
    }
    class Seated {
        +bill(table)
    }
    class Billing {
        +finish(table)
    }
    class Cleaning {
        +clean(table)
    }
    TableState <|.. Empty
    TableState <|.. Seated
    TableState <|.. Billing
    TableState <|.. Cleaning
    Table --> TableState

(図の TableStateTable::StateEmptyTable::State::Empty のことです。)

ここで、お客さんが顔を上げて、こう尋ねました。

「ひとつ、引っかかるんですが。状態を変えるのは、外から『次はこれ』って指定すればいいんじゃないですか? なんでわざわざ、席自身に次を選ばせるんです?」

理屈で詰めようとする、このお客さんらしい質問でした。私も、言われてみればそうかもしれない、と思いました。set_state を外から呼べば、好きな状態にできるはずです。

シェフは、札を一枚手に持ったまま、お客さんに向き直りました。私はこの答えを、メモに書き留めました。

「外から『次は着席中』と指定できるようにしてみろ。お前、また清掃中の席を、いきなり“着席中”にできちまうぞ。それじゃ、旗を立て間違えてたのと同じだ。元の木阿弥よ」

そしてこう続けました。

「だから、席自身に決めさせる。『今が清掃中なら、次に行けるのは空席だけ』とな。札の裏に書いた、あの行き先だ。そうすりゃ、飛び越えられん。外から選べるのは“操作”——座らせる、会計する——だけ。その操作が今できるかどうかは、席が握ってる」

ここでシェフが言っていたのが、開放閉鎖原則にもつながる考え方であり、同時に、同じ操作名でも札(状態)が違えば振る舞いが変わる仕組み——ポリモーフィズムでした。seat という同じ呼び名でも、空席の札なら着席中へ進み、清掃中の札なら断る。呼び分ける if は、もうどこにもありません。

シェフは、少し間を置いてから、こう付け加えました。

「前に、割引をやったろう」

それは、第1作のStrategyパターンのことでした。

「あれは、“どれを選ぶか”の判断が、最後まで残った。ランチか、会員か、ってな。今度は違う。操作のたびに『この席は今こうだから、こう振る舞え』っていう判断が、まるごと消えた。席が、状態そのものになったからだ」

私は、この違いをうまく飲み込めずにいました。でも、拭き終わったテーブルを思い出して、こう言ってみました。

「席が……自分の番を知ってる、ってことですか。お皿を下げて、拭き終わってからじゃないと、次のお客さんを案内できない——みたいに。札を見れば、次に何をしていいかが分かる」

お客さんが、はっと顔を上げました。「……ああ、そうか。順番が、席の側に書いてあるんですね。僕が外から覚えておかなくても」

シェフは小さくうなずいたように見えました。

試食合格、そして予約という新しい札

仕立て直したコードが、ちゃんと正しく動くか。シェフは「通してみるぞ」と言って、テストを走らせました。席が、操作のたびに正しく次の札へ移っていくかを、順に確かめていきます。

  • 空席の席に「案内」→ 着席中へ
  • 着席中の席に「会計」→ 会計中へ
  • 会計中の席に「片付け」→ 清掃中へ
  • 清掃中の席に「清掃完了」→ 空席へ

席は、操作を呼ばれるたびに、自分で次の札へ掛け替わっていきました。そして、いちばん大事なところ。

  • 清掃中の席に「案内」→ 「清掃中の席に『案内』はできません」ときっぱり断られる

片付け前の席に、次のお客さんが座ることは、もうできません。is_cleaning の立て忘れに怯える必要も、ありません。そういう席は、そもそも作れないからです。

それから、シェフはお客さんにこう言いました。「予約を足してみろ」。

居酒屋なら、予約は当然ほしい機能です。お客さんは少し身構えてから、新しい札を一枚、作りました。

1
2
3
4
5
6
7
# 予約済み(新しく足す状態)
package Table::State::Reserved;
use Moo;
with 'Table::State';
sub name   { '予約済み' }
sub seat   { my ($self, $table) = @_; $table->set_state(Table::State::Seated->new) }  # 予約客が来店
sub cancel { my ($self, $table) = @_; $table->set_state(Table::State::Empty->new) }   # 取消

あとは、空席から予約へ入る入口を、Empty に一つ足します。

1
2
3
# 空席の札に「予約を受ける」という行き先を一つ加えるだけ
package Table::State::Empty;
sub reserve { my ($self, $table) = @_; $table->set_state(Table::State::Reserved->new) }

そして、席に「予約する」「取り消す」という操作の入口を足します。

1
2
3
package Table;
sub reserve { my $self = shift; $self->state->reserve($self) }
sub cancel  { my $self = shift; $self->state->cancel($self) }

足したのは、これだけです。予約済みという札が一枚、空席の札に行き先が一つ、席の入口に二つ。着席中・会計中・清掃中の札には、指一本触れていません。それでも、予約客が来れば着席中へ進み、取り消せば空席へ戻る。ちゃんと動きました。

これが、開放閉鎖原則(OCP) ——「既存のコードをいじらず、追加だけで機能を増やせる状態」——の姿です。もしフラグでこれをやっていたら、is_reserved という旗をもう一本増やし、案内・会計・片付けのすべての処理で「予約中はどうするか」を見直す羽目になっていたはずです。組み合わせは、さらに倍に膨らみます。

お客さんは、しばらく画面を見つめてから、ふっと肩の力を抜いたように見えました。「……なるほど。ありえない席は、そもそも作れないんですね。だから、立て忘れに怯えなくていい」。納得すると、この人は理屈で言い直すようでした。

シェフは、洗い終えた札を布巾で拭くみたいに、静かにこう言いました。

「どの席も、もう”ありえん顔”はしなくなった。札は、いつでも一枚きりだ。次に進む先は、札自身が知ってる」


シェフの仕込み工程表

今日の「料理」を振り返ります。あなたのコードに同じ匂いがしたら、同じ手順で仕立て直せます。

問題(調理ミス)技法(パターン)効果(仕上がり)
状態を複数の真偽値フラグで表し、ありえない組み合わせ(空席なのに会計待ち)が作れてしまう(boolean-flags)Stateパターン状態は常に一枚の札(オブジェクト)。矛盾した組み合わせが、そもそも表現できなくなる
フラグの立て忘れで、片付け前の席が空席に見えて二重案内状態ごとに「許す操作」と「次の状態」を持たせる清掃中の席への案内を、状態自身がきっぱり断る
操作のたびに「今どの状態か」をフラグで判定する if が散らばる同じ操作名でも状態ごとに振る舞いが変わる(ポリモーフィズム)状態を見分ける if/elsif が消え、各状態の札に振る舞いが収まる
新しい状態(予約)の追加が怖い開放閉鎖原則(OCP)札を一枚足し、入口を作るだけ。既存の状態は無傷

工程

  1. 共通の型紙を作り、既定を「拒否」にするMoo::Role で各操作を定義し、最初はすべて「できません」と断るようにしておく
  2. 状態ごとに札を分ける:状態ごとに with 'Table::State' した小さなクラスを作り、その状態に許された操作だけを上書きする
  3. 遷移は札の中に書く:許した操作の中で $table->set_state(次の状態->new) と、自分で次の札へ掛け替える
  4. 席は委譲するだけにするTable(Context) は操作を現在の state に取り次ぐだけ。状態の中身は知らない
  5. 足して確かめる:新しい状態は札を一枚足すだけ。既存の状態を変えずに動くことを、テストで確認する

シェフより

旗を何本も立てて状態を表すのは、やめておけ。旗が増えるたびに、ありえん組み合わせが倍になる。立て忘れた一本が、お前を裏切る。札は一枚でいい。今がどの状態かは、その一枚で言い切れ。そして、次にどこへ進めるかは、札の裏に書いておく。外の人間に覚えさせるな。席自身に持たせろ。そうすりゃ、飛び越えも、立て忘れも、起きようがない。

ひとつだけ、正直に言っておく。席の状態をデータベースに保存して、後で読み直すときだけは、保存した名前——'cleaning' のような文字列——から札を選び直す係が、店の外に要る。そこだけは対応表が残る。だが、それは店の入り口の仕事だ。席の中の話じゃない。

それと、状態が増えて、どの札からどこへ行けるかが入り組んできたら、その道順を一枚の表にして持つ手もある。だが、それはまた別の日の仕込みだ。

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