Featured image of post コードメカニック【State】キャンセルしたはずの注文が発送された〜状態の判断を散らさず状態自身に持たせる〜

コードメカニック【State】キャンセルしたはずの注文が発送された〜状態の判断を散らさず状態自身に持たせる〜

キャンセル済みの注文が、エラーも出さず発送されていた。状態を文字列で持ち遷移の判断を各所のswitchに散らしたのが原因。状態を型にして次に進める先を状態自身に持たせ、新しい状態の抜けをコンパイルで止める整備記録。

警告灯は、ひとつも点いていなかった

その日は、何も起きていないはずだった。

平日の昼下がり。アラートは鳴っていない。Slackのインシデントチャンネルも静かで、自分はいつもの開発作業をしていた。半年前に辞めた先輩から引き継いだ、注文管理サービス。引き継ぎは口頭と、Wikiの断片だけだった。コードは読める。けれど、注文がどう状態を移っていくのか、その全体像は、正直まだ頭に入りきっていない。

最初の異変は、CSからの一件のエスカレーションだった。「キャンセルして、返金も受け取ったお客様から『商品が届いた』と連絡が来ています」。

最初は配送のミスかと思った。倉庫が取り違えたのだろう、と。だが注文を開いて、手が止まった。status"shipped"。キャンセルの記録も、返金の記録も、ちゃんと残っている。両方が、同じ注文に、ある。返金して、なお商品を送っている。会社は、二重に損をしていた。

DBを調べると、同じ「キャンセル済みなのに発送済み」が、先月から数件あった。調べているそばで、CSの問い合わせがまた一件増えた。

なのに、エラーログは一件も無い。発送バッチは毎晩、正常終了している。panic も、スタックトレースも、どこにもない。「壊れた」形跡が、どこにも無いのだ。

エラーが出ていれば、まだ追える。スタックトレースを辿って、落ちた行まで行ける。でも、何も出ていない。コードは、ちゃんと動いていた。ちゃんと動いて、キャンセル済みの注文を、発送した。

監視は全部緑だ。ロールバックするものすら無い。本番は、最後まで健康に見えていた。気づいたきっかけは、CSに届いた、たった一人のお客様の声だけ。鳴っていないことは、何も起きていないこと、ではなかった。静かに、確実に、損が積み上がっていた。そして自分は、引き継いだこの仕組みのどこが、いつ、こうなったのかを、説明できなかった。

注文管理を引き継いだとき、前任が残したWikiの隅に、「詰まったら、ここに連絡」とだけ書かれた連絡先があった。半信半疑で、かけてみた。小一時間して、物静かな女性が来た。薄いノートPCを一台だけ提げている。世間話はなかった。挨拶をして、隣に座る。

「キャンセルされた注文が、発送されてるんです」と自分は切り出した。「エラーは……一件も出ていません。発送バッチも、毎晩ちゃんと正常終了していて。何が『壊れた』のかも、分からなくて」

その人は、症状ではなく、別のことを聞いた。「注文の status を、どこで変えていますか」

「えっと……PayShipCancel、それぞれの関数で。あと、発送バッチが、夜中に Ship を呼んでます」

「その3つ、全部見せてください」

迷いのない声だった。自分は、いつのまにかこの人を、親方と呼んでいた。開いた画面には、引き継いだ3つの関数が並んでいた。

 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
package order

import (
	"errors"
	"fmt"
)

type Order struct {
	ID     int
	Status string // "new" / "paid" / "shipped" / "cancelled"
}

func (o *Order) Pay() error {
	if o.Status != "new" {
		return fmt.Errorf("pay: %q では支払えない", o.Status)
	}
	o.Status = "paid"
	return nil
}

func (o *Order) Cancel() error {
	switch o.Status {
	case "new", "paid":
		o.Status = "cancelled"
		return nil
	default:
		return fmt.Errorf("cancel: %q ではキャンセルできない", o.Status)
	}
}

// 発送バッチが毎晩呼ぶ。この関数が書かれた当時、"cancelled" はまだ無かった
func (o *Order) Ship() error {
	if o.Status == "shipped" {
		return errors.New("ship: すでに発送済み")
	}
	if o.Status == "new" {
		return errors.New("ship: まだ支払われていない")
	}
	// ここに来るのは "paid" のはず——だが "cancelled" も素通りする
	o.Status = "shipped"
	return nil
}

「これ、半年前に辞めた先輩が書いた部分で……」自分は、少し言い訳がましくなった。「cancelled は、自分が入った後に、別のチームが足したんです。だから、Ship がそれにちゃんと対応してるか、正直、把握してなくて」

親方は Ship の中を、上から指でなぞった。「shippednew は見ている。でも、cancelled が無い」

そうだ。Ship のガードには、shippednew を弾く if はあるのに、cancelled を弾く行が、無い。「でも」と自分は食い下がった。「なんで、エラーも出ずに、発送できちゃうんですか。status がキャンセル済みなら、せめて何か……」

Status が、ただの文字列だからです」と親方は言った。「"cancelled" でも、o.Status = "shipped" は書ける。文字列に、それを止める理由が、ひとつも無い」


一行足せば、火は消える

親方は何も言わず、自分のキーボードを借りた。そして、たった数行のコードを書いた。注文を new から paid、そして cancel まで動かして、最後に Ship() を呼ぶ。errStatus を、そのまま表示する。

1
2
3
4
5
o := &Order{ID: 1, Status: "new"}
o.Pay()
o.Cancel()
err := o.Ship()
fmt.Println(err, o.Status)

走らせた。出力は、これだった。

1
<nil> shipped

「見てください」と親方は画面を指した。「エラーは nil。なのに、キャンセル済みが、発送済みになった」

ぞっとした。「ほんとだ……。何も、止めてくれない。これが、先週の注文で起きてたことだ」。抽象的な「事故」だったものが、いま目の前で、<nil> shipped という一行になって出ていた。

それから親方は、Ship に一行だけ足した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func (o *Order) Ship() error {
	if o.Status == "shipped" {
		return errors.New("ship: すでに発送済み")
	}
	if o.Status == "new" {
		return errors.New("ship: まだ支払われていない")
	}
	if o.Status == "cancelled" { // 応急: キャンセル済みは発送しない
		return errors.New("ship: キャンセル済みは発送できない")
	}
	o.Status = "shipped"
	return nil
}

さっきの数行を、もう一度走らせる。

1
ship: キャンセル済みは発送できない cancelled

「直りましたね」自分は、少し息をついた。「エラーで、止まるようになった。じゃあ、これで……」

「火は消えました」と親方は言った。「でも、また起きます」

そして、PayCancelShip の三つを、順番に指でさした。「この3つ、それぞれが『どの状態から動いてよいか』を、自分のやり方で書いている。Pay!= "new" で見る。Cancelswitchnewpaid を見る。Shipif を並べる。同じ『遷移していいか』の判断が、3箇所に、バラバラの書き方で、散っている」

「今日の事故は、その散らばった1箇所——Ship——が、後から足した cancelled を知らなかったから起きた。あなたは Ship を直した。でも、次に状態を一つ足すとき、3箇所ぜんぶを、漏れなく見直せますか」

言葉に詰まった。痛いところだった。それでも、まだ腑に落ちていなかった。「でも、状態が変わるんだから、状態を変える関数にガードを書くのは、自然じゃないんですか」

「ガードを書くのは自然です」と親方は言った。「おかしいのは、『どの状態から、どこへ行ってよいか』というルールが、どこにも一箇所に、まとまっていないことです。3つの関数に、それぞれの解釈で散っている。だから、足し忘れる」


その遷移図は、コードのどこにある

親方は、手元のメモ用紙に、注文の状態の移り変わりを描いた。

1
2
3
4
5
6
7
8
           Pay              Ship
  new ───────────▶ paid ───────────▶ shipped
   │                │
   │ Cancel         │ Cancel
   ▼                ▼
        cancelled

 (shipped と cancelled からは、もう動けない)

「あなたの頭の中には、たぶん、この図がある」と親方は言った。「注文は new から始まって、払えば paid、発送すれば shipped。途中まではキャンセルもできる。でも shippedcancelled は終わりで、そこからはもう動けない」

「はい」と自分はうなずいた。「だいたい、そういう認識です」

「この図は、コードのどこに、書いてありますか」

コードを見返した。Pay を見る。new のことしか知らない。Ship を見る。paid から発送することしか書いていない。Cancel は、newpaid のことだけ。どの関数も、図の一部分しか、持っていなかった。

「……どこにも、無いです」と自分は言った。「Paynew のことしか知らない。Shippaid のことしか。Cancelnewpaid だけ。ぜんぶバラバラで——この図ぜんぶを持ってる場所が、無い」

「これは、型コード(Type Code)です」と親方は言った。型コード。状態や種別を、専用の型ではなく、文字列や数値の定数で持って、各所で見分けるやり方のことだ。

Statusstring だから、コンパイラは『この文字列は、この関数に来ていいのか』を、何も確かめない。"cancelled""shipped" に書き換える代入も、ただの文字の置き換えとして、素通りさせる」

引き継いだとき、status が文字列なのは、当たり前だと思っていた。new とか paid とか、見れば意味が分かる。分かりやすいと思っていた。でも、分かりやすいのは、人間が読むときだけだった。コンパイラにとっては、ただの文字の並びでしかない。試しに "banana" を入れても、たぶん、何も言わずに通してしまう。

stringstatus は、コンパイラにとって意味を持たない。"shipped""cancelled" も、同じ string だ。だから「この状態で、この操作をしていいか」を,型のレベルで問えない。問えるのは、実行時に、人間が手で書いた if が、たまたま拾ったときだけ。拾い忘れれば、素通りする。先週のあれは、まさに、それだった。

「見てごらん」と親方は、錆びたボルトが転がるスチールの作業台に、一枚の配線図を広げた。「注文(Order)という大きな筐体の中に、ただの文字情報として Status が転がっている。そして、それを取り巻くスイッチ(メソッド)たちが、各自の都合でその文字を書き換えているんだ」

「Type Code」による状態管理を示す図。左側の「SHIPPING BATCH」から「ORDER」筐体に「Triggers Ship()」と配線が伸びており、「ORDER」筐体の中では「Status: string」が右側の「LOOSE STRINGS」コンテナ(“new”, “paid”, “shipped”, “cancelled"などのバラバラの文字列タグが入っている)と「Reads/Writes bare strings」として繋がっています。各メソッド(Pay/Ship/Cancel)に条件分岐が散らばり、状態遷移の統一ルールが存在しない問題が警告インジケータと共に描かれています。

描かれた図は、単純に見えて、あちこちにバイパスが通っていた。PayShip というスイッチが、それぞれ個別に Status というバルブを覗き込んで、開閉を判断している。どこか一つのスイッチで判断を誤れば、あるいはバルブに予想外の泥が詰まれば、全体の流れは容易に破綻する。

「各パーツが自分勝手にバルブの状態を判断しているから、全体を制御するバルブの動作規程がどこにもない」と親方は言った。「だから、新しく cancelled という泥が混入したとき、Ship というスイッチだけがそれを素通りさせてしまったんだ」


状態を、文字列から型へ

「状態を、文字列じゃなくて、型にします」と親方は言った。「状態ごとに型を作って、『その状態が次にどこへ行けるか』を、状態自身に、持たせる」

まず、状態が答えるべきことを、interface に宣言した。

1
2
3
4
5
6
7
// 各状態が答える操作。許可された遷移は次の状態を返し、不許可は error を返す
type State interface {
	Pay() (State, error)
	Ship() (State, error)
	Cancel() (State, error)
	Name() string
}

「状態が……関数を持つんですか」と自分は聞き返した。「new とか paid が」

「そう。new という状態に、『お前は Pay されたら、次どうなる』と聞ける形にする。new は『paid になる』と答える。cancelled は『発送はできない』と答える。答えるのは、状態自身です」

interface(インターフェース)は、「この一覧のメソッドさえ持っていれば、どんな型でも同じものとして扱える」という約束だ。ここでは PayShipCancelName の4つに答えられることを、State という名前の約束にした。そして、それぞれのメソッドが返すのは、値が二つ。次の状態と、エラーだ。Goの関数は、こうして値を二つ並べて返せる。許可された遷移なら「次の状態」と nil、許可されない遷移なら nil と「エラー」を返す——その約束を、(State, error) という戻り値の形が表していた。

次に、状態ごとに型を作った。Goでは、interface を満たすのに implements とは書かない。宣言された4つのメソッドを、その形で持っている型なら、自動的に State として扱われる。

 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
var ErrInvalidTransition = errors.New("order: その状態ではできない操作です")

// 状態固有のデータが無ければ、中身(フィールド)は空でいい
type newState struct{}

func (newState) Pay() (State, error)    { return paidState{}, nil }          // 払えば paid へ
func (newState) Ship() (State, error)   { return nil, ErrInvalidTransition } // 未払いは発送不可
func (newState) Cancel() (State, error) { return cancelledState{}, nil }     // キャンセルできる
func (newState) Name() string           { return "new" }

type paidState struct{}

func (paidState) Pay() (State, error)    { return nil, ErrInvalidTransition } // 二重支払い不可
func (paidState) Ship() (State, error)   { return shippedState{}, nil }
func (paidState) Cancel() (State, error) { return cancelledState{}, nil }
func (paidState) Name() string           { return "paid" }

type shippedState struct{}

func (shippedState) Pay() (State, error)    { return nil, ErrInvalidTransition }
func (shippedState) Ship() (State, error)   { return nil, ErrInvalidTransition }
func (shippedState) Cancel() (State, error) { return nil, ErrInvalidTransition }
func (shippedState) Name() string           { return "shipped" }

type cancelledState struct{}

func (cancelledState) Pay() (State, error)    { return nil, ErrInvalidTransition }
func (cancelledState) Ship() (State, error)   { return nil, ErrInvalidTransition } // 事故の答えが、ここ1箇所に集約された
func (cancelledState) Cancel() (State, error) { return nil, ErrInvalidTransition }
func (cancelledState) Name() string           { return "cancelled" }

struct{} は、中身(フィールド)のない型だ。状態には newpaid のような名前(型)だけが要って、持ち回るデータが無いから、中身は空でいい。メソッドの受け手が func (newState) Pay(...) と名前すら無いのも、同じ理由だ。状態は中身を書き換えないので、受け手を変数で受け取る必要がなく、名前を省ける(使わないものは書かない)。

自分は、cancelledStateShip の行を見た。「cancelled が『発送はできない』って、自分で言ってる。さっき Ship のガードに足した、あの cancelled チェックが……cancelled っていう状態の、中に入った」

「そう」と親方は言った。

そして、注文そのもの——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
// Order はコンテキスト。状態を「型」で持ち、操作を現在の状態に委譲する
type Order struct {
	ID    int
	state State // 文字列ではなく、状態の「型」を持つ
}

func NewOrder(id int) *Order {
	return &Order{ID: id, state: newState{}} // 最初は new
}

// 今の状態に聞いて、許可されたら(err==nil なら)その次の状態に入れ替わる
func (o *Order) Pay() error {
	next, err := o.state.Pay()
	if err != nil {
		return err // 不正遷移。状態はそのまま
	}
	o.state = next
	return nil
}

func (o *Order) Ship() error {
	next, err := o.state.Ship()
	if err != nil {
		return err
	}
	o.state = next
	return nil
}

func (o *Order) Cancel() error {
	next, err := o.state.Cancel()
	if err != nil {
		return err
	}
	o.state = next
	return nil
}

func (o *Order) Status() string { return o.state.Name() }

3つのメソッドは、同じ形をしている。今の状態に、その操作を聞く。エラーが返れば、状態を変えずにそのまま返す。次の状態が返れば、それに差し替える。共通処理を一つにまとめることもできるけれど、ここでは、何が起きているかが読めるように、あえて素直に並べた。状態側と違って、受け手が (o *Order) とポインタ(* の付いた形)になっているのは、Order が自分の state を書き換えるからだ。中身を書き換えるものは、ポインタで受け取る。

Order は、もう自分で『どの状態から動いてよいか』を判断しない」と親方は言った。「今の状態に聞いて、返ってきた次の状態に、入れ替わるだけ」

判断が、Order から、状態の型に移ったんだ。自分は、頭の中で言い換えた。Order は、聞いて、差し替えるだけ。

「今日の事故——キャンセル済みの発送——の答えは、いま cancelledState.Ship() の、ただ1箇所にあります」と親方は続けた。「Pay にも、Ship の本体にも、Cancel にも、散っていない。『キャンセル済みは発送できるか』を知りたければ、cancelled の型を見れば済む」

「散らばってたのが、1箇所に集まったんですね」と自分は言った。それから、ふと引っかかった。「……でも、集めただけなら、また別の状態を足すとき、同じように、書き忘れませんか」

「やってみましょう」と親方は言った。「次に『返品中(returning)』という状態を足してください」

自分は returningState を作り始めた。Name() を書き、PayCancel も埋めた。残るは Ship だ。返品中の注文を、発送していいんだっけ——そこで、手が止まった。とりあえず後で、と飛ばした、そのとき。親方が、一行を見せた。

1
2
// この1行は「returningState は State を全部満たす」というコンパイラへの約束
var _ State = returningState{}

ビルドすると、止まった。

1
2
3
./order.go:9:15: cannot use returningState{} (value of struct type returningState)
	as State value in variable declaration: returningState does not implement State
	(missing method Ship)

var _ State = returningState{} は、空き地に『ここは State だ』と立て札を打つ行です」と親方は言った。_(アンダースコア)は「この値は使わない、名前は要らない」というしるしだ。ここでは、型が State の約束を満たすかを確かめたいだけで、変数として持っておく必要はない。「returningStatePayShipCancelName を全部持っていなければ、ここでビルドが止まる。新しい状態は、自分が次に何をできるかを全部答えるまで、コンパイルが通らない」

コンパイラが指したのは、まさに、さっき飛ばした Ship だった。返品中の商品を、また発送する? ……いや、しない。迷って手を止めた問いに、答えるしかなくなった。

1
func (returningState) Ship() (State, error) { return nil, ErrInvalidTransition }

書きながら、気づいた。さっきは、Ship のガードに cancelled を足し忘れても、誰も何も言わなかった。コンパイラも、テストも、本番も、緑のままだった。今度は、返品中を足したら、答えていない遷移があるかぎり、コンパイラが——いまの Ship のように——名指しで止めてくる。全部の遷移を埋めるまで、ビルドは、通らない。

ただし、と自分は思い直した。コンパイラが聞いてくるのは、「返品中は、次に何ができるか」——出ていく先だけだ。逆に、「どの状態から返品中へ来られるか」——たとえば shipped から返品中へ繋ぐ——は、shipped の側を自分で書き直さないと繋がらない。そこは、コンパイラは促してくれない。

それに、もし「返品中は発送できる」と間違って書いても、コンパイルは通ってしまう。コンパイラが見るのは「答えたかどうか」であって、「答えが正しいかどうか」ではない。

それでも、と自分は思った。今日の事故——答えること自体を、まるごと忘れる——は、もう起きない。聞かれれば、少なくとも、考える。

ふと、似たものを思い出した。「これ、Strategy ってやつに似てません? 種類ごとに型を分けて、interface でやる……名前だけは、聞いたことがあって」

「名前は近いです」と親方は言った。「でも Strategy は『やり方を選ぶ』話。種類は、注文ごとに最初に決まって、途中でクレジットがコンビニ払いに化けたりしない。これは『状態が移り変わる』話です。注文は new から paidshipped へと、時間とともに動く。そして、今の状態が、次にどこへ行けるかまで持っている。そこが、違う」

いま親方がやったこれには、名前があった。State(ステート)——状態ごとに型を作り、その状態でできること(振る舞い)と、次に進める状態を、状態自身に持たせるデザインパターン(振る舞いを扱うパターンの一つ)だ。

ひとつ、現実的な心配が残っていた。「でも、発送バッチは、DBから注文を読みますよね。DBに入ってるのは、"paid" みたいな文字列です。型じゃなくなったら、バッチは、どうやってその型の Order を手に入れるんですか」

「文字列は、端っこだけに残します」と親方は言った。「DBから読んだときに一度だけ、文字列を状態の型に戻す。保存するときは Name() で文字列に書き戻す。中で持ち回るのは、ずっと型のままです」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 端(DB読み込み)でだけ、文字列を状態の型に戻す
func parseState(s string) (State, error) {
	switch s {
	case "new":
		return newState{}, nil
	case "paid":
		return paidState{}, nil
	case "shipped":
		return shippedState{}, nil
	case "cancelled":
		return cancelledState{}, nil
	default:
		return nil, fmt.Errorf("order: 未知の状態 %q", s)
	}
}

ただ、と自分は気づいた。この parseStateswitch だけは、新しい状態を足したとき、コンパイラが case の追加を強制してくれない。returningState を足しても、ここに case "returning" を書き忘れれば、読み込み時に default の error に落ちる。気づくのは、実行時だ。型の世界に入ってからは、各状態が全部の操作に答えているか——メソッドの抜け——を、コンパイラが見てくれる。でも、文字列から型へ入る、この1箇所だけは、人が手で保つしかない。だから、ここは default で必ず error にして、テストで守る。端を一箇所に絞ったぶん、守る場所も、ここ一箇所に絞れた。

「今度は、こうだ」 親方は、作業台の上の配線図を、新しい設計図へと差し替えた。

「State」パターンによる解決策を示す図。中央の「STATE (Interface)」ソケット(Pay, Ship, Cancel, Nameメソッドが定義されている)に対して、左側の「ORDER (Context)」筐体が「Delegates operations & updates pointer」と配線されています。右側には「newState」「paidState」「shippedState」「cancelledState」の4つの状態バルブプレートが並び、それぞれがソケットに実装(接続)される様子と、個々のバルブが自身で遷移先を知っている構造が、コンパイルの安全性に関する解説と共に描かれています。

「さっきは一つの筐体の中でスイッチが散らばっていたが、今度は違う。状態(State)そのものを独立した油圧バルブ(型)として独立させたんだ。注文(Order)は、単にその時接続されているバルブに油圧を伝えるだけの『ソケット』にすぎない」

図を見ると、Order は中央の State というソケットを介して、newStatepaidState といった専用のバルブプレートと結ばれていた。それぞれのバルブプレートには、次の遷移先への配線(メソッドの戻り値)が厳密に刻まれている。

「これなら」と自分は図をなぞった。「新しいバルブを繋ぐとき、すべての配線ポート(メソッド)を正しく接続しなければ、ソケットにはまらない(コンパイルエラーになる)んですね」

「その通りだ」親方は小さく頷いた。「余計なバイパスは存在しない。バルブ自体が、自らの進路を知っている」


走り出す前に、コンパイラが訊く

直したコードに、テストを書いた。先週の事故を、二度と起こさないための回帰テストだ。new から paid、そして cancel。その後で Ship を呼ぶ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func TestCancelledOrder_CannotShip(t *testing.T) {
	o := NewOrder(2)
	if err := o.Pay(); err != nil {
		t.Fatalf("Pay: %v", err)
	}
	if err := o.Cancel(); err != nil {
		t.Fatalf("Cancel: %v", err)
	}

	if err := o.Ship(); !errors.Is(err, ErrInvalidTransition) {
		t.Fatalf("キャンセル済みの発送は ErrInvalidTransition のはず: got %v", err)
	}
	if got := o.Status(); got != "cancelled" {
		t.Fatalf("不正遷移で状態は変わってはいけない: got %q, want %q", got, "cancelled")
	}
}

ここで使った errors.Is は、返ってきたエラーが ErrInvalidTransition と同じ目印かを確かめる関数だ。Before のときは、ここが err == nil で、Status"shipped" に化けていた。After では、ErrInvalidTransition が返り、しかも弾かれたときは、状態は "cancelled" のまま、変わらない。

状態ごとの遷移も、型ごとに、単体で確かめられる。paidState{}.Ship()shippedState を返すか。cancelledState{}.Ship()ErrInvalidTransition を返すか。巨大な注文オブジェクトを組み立てなくても、状態の型を一つ作って、聞くだけでいい。

走らせた。

1
2
3
$ go test ./...
ok  	code-mechanic/type-code-state/after   0.005s
ok  	code-mechanic/type-code-state/before  0.007s

ひとつ、確かめておきたいことがあった。あの応急処置——Shipif o.Status == "cancelled" を一行足したやつ——も、キャンセル済みの発送は、ちゃんと弾く。型にした After も、弾く。だから、「キャンセル済みは発送しない」という結果だけ見れば、二つは同じだ。

違うのは、そのルールが、どこに、どう書かれているか、だった。応急のほうは、Ship という一つの関数の中に、また一行、判断が増えただけ。PayCancel に散った仲間は、そのまま。次に状態が増えれば、また三箇所を、手で見て回ることになる。型にしたほうは、ルールが状態ごとに宿って、新しい状態は、コンパイラが「次に何ができる」と全部訊いてくる。直したのは、同じ一つのバグ。変えたのは、次のバグが、黙って通れるかどうか、だった。

「状態は、これから何個でも足せます」と親方は言った。「足すたびに、コンパイラが、その状態にできることを全部訊いてくる。あなたが今日、訊き忘れたことを」

親方は、薄いノートPCを閉じて、来たときと同じ身軽さで立ち上がった。

引き継いだとき、いちばん怖かったのは、自分の知らない状態遷移が、どこかに隠れていることだった。今は、状態が型になっている。新しい状態を足せば、コンパイラが、抜けを教えてくれる。引き継いだコードが、初めて、自分に話しかけてくれた気がした。

「次に状態を足すとき、約束してください」と親方は言った。「文字列を増やして、あちこちの if を探し回るんじゃなく——型を一つ書いて、var _ State = の立て札を打つこと。あとは、コンパイラに、訊かせればいい」

if は、探しません」と自分は言った。「型を足して、コンパイラに、訊きます」

親方が描いていったメモ用紙の、あの状態遷移図を、もう一度見た。頭の中にしかなかったあの図が、いまは型になって、コードの中に、ある。


整備記録簿

こんな異音・症状が出たら入れるべき整備(State)まだ様子見でいい
状態を文字列や定数で持ち、複数の関数がそれぞれ switch status / if で遷移を手書きしている
状態を一つ足すたびに、複数箇所の分岐を漏れなく直さないと事故る(今日の足し忘れがまさにそれ)
「キャンセル済みを発送」のような不正な状態遷移を、型レベルで防げず、実行時にすら素通りする
状態が2つだけで遷移も単純(フラグ一つで足りる)/状態ごとの振る舞いがほぼ無い✓(boolで十分。State は過剰)

整備手順

  1. 状態を表す string/定数フィールド(Status)と、それを switchif で見分けている関数(PayShipCancel)を洗い出す。
  2. 状態が答えるべき操作を State インターフェースに宣言する。遷移メソッドは (State, error) を返す——許可されれば次の状態、不許可なら ErrInvalidTransition
  3. 状態ごとに型(newState などの struct)を作り、許可された遷移は次の状態を返し、不許可は ErrInvalidTransition を返す。散っていたガードを、状態の型に集める。
  4. コンテキスト(Order)は State を一つ持ち、操作を現在の状態に委譲して、返ってきた次の状態に差し替えるだけにする。
  5. 各状態に var _ State = xxxState{} の立て札を置く。新しい状態は、全ての遷移を答えるまで、ビルドが通らなくなる。文字列に戻す境界(DB読み込み等)は一箇所に絞り、default を error にしてテストで守る。

親方より

文字列の状態は、人間には読みやすい。だが、コンパイラには、ただの文字だ。「この状態で、それをしていいか」を、文字は何も知らない。だから、知っているつもりの人間が、あちこちの if に判断を散らし、いつか一箇所を忘れる。今日のは、それだ。

状態を、型にしろ。「次にどこへ行けるか」を、状態自身に持たせろ。そうすれば、判断は一箇所に宿る。新しい状態を足すときは、コンパイラが「お前は次に何ができる」と必ず訊いてくる。答えるまで、走り出せない——それは、足枷じゃない。お前が忘れる前に、訊いてくれているんだ。

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