All Articles

Phantom Typeを使って状態変化を安全に実装

はじめに

以前、飲食系の PJ の仕事をしておりました。システムを運用していく中で、状態変化に起因したいくつかの不具合を生み出してしまいました。(リリース前に気づいたのですが、データを壊すようなバグは肝が冷えます。)

状態変化を正しく制御するのはシステムが複雑になるほど、エンジニアが実装時に気をつける or すべての状態遷移のテストコードを書く or Pull Request レビューで担保するなどの施策は結構厳しいと考えています。

今回は状態変化によるミスを軽減させる救世主かもしれない(大袈裟) Phantom Type についてまとめていきます。余談ですが、Haskell のデザインパターンの 1 つのようです。

過去の反省の意味でも、飲食店のバックエンドシステムを簡略化した状態変化を例にしています。

飲食店のバックエンドシステム

まず予約が入って退店まで + キャンセルでざっくり以下のような状態が存在します。

  • 予約受付(返金可能な日)  ← お店によりますが 2 日前までが多いですね。
  • 来店待ち(返金不可)
  • 来店中
  • 退店
  • キャンセル

来店待ち(返金不可)来店待ち(返金不可) の時に、チェックアウトすることは出来ません。

また、キャンセル から 来店中 に状態は変化させられません。

チェックアウトは、現在の予約が 来店中 以外は機能しないようにするには、以下のように書くことも可能ですが、モヤモヤします。← 今までこのように実装していました。^^;

func checkout() {
 guard case .inStore = status else {
   return
 }
  // checkout 処理
}
  • 予約受付 からは、 来店待ち または キャンセル
  • 来店待ち からは、 来店中 または キャンセル
  • 来店中 からは、 退店 または 来店待ち ※1

※1 誤って行ったオペレーションを考慮のため来店待ちに戻せるようにする

アクション

これらの状態変化は以下のアクションにまとめられます。実装を持たないこの型が重要!

// 返金できる
protocol Refundable {}

// キャンセルできる
protocol Cancelable {}

// 来店待ちにできる
protocol Waittable {}

// オーダーを受付られる
protocol Orderable {}

// 会計できる
protocol Checkable {}

予約状態

予約状態は以下のように整理できます。

// 予約状態
protocol ReserveState {}

// 返金可能な日
enum RefundableDaysAgo: ReserveState, Refundable, Waittable {}

// 来店待ち
enum WaitingVisit: ReserveState, Cancelable, Orderable {}

// 来店中
enum InStore: ReserveState, Checkable, Waittable {}

// 退店
enum Closed: ReserveState {}

// キャンセル
enum Canceled: ReserveState {}

enum で Protocol をグルーピングできるのは面白いですね。

concrete type するために typealias ではなく enum を使用しています。

どの予約状態も ReserveState に準拠させるような定義になっているのがポイントです!

予約

予約を以下のような Struct で定義します。

Type を指定するようにはなっていますが、Struct の内部では使用されていません。 この Generics の T が今回の Phantom Type となります。

【Swift】型を使うという意味を考える (Phantom Type を通して)

によると、

型としては出てくるものの 実装としては登場してこない型を指します。 見えないけど存在しているまさに幽霊のような存在です。

struct Reserve<T: ReserveState> {
    var type: ReserveType
    var name: String
    var discount: Bool // インターネット申し込み時のみ有効
    var amount: Int? // 合計金額
    var refund: Bool
    init(type: ReserveType, name: String, discount: Bool, refund: Bool = false, amount: Int?) {
        self.type = type
        self.name = name
        self.discount = discount
        self.refund = refund
        self.amount = amount
    }
}

<T: ReserveState> 上の予約状態はすべて、 ReserveStateに準拠していることが保証されるのでこのように書けます。

予約状態に対する Extension 拡張

予約は、電話やインターネット または、 直接来店が考えられますので、コンストラクタはこの 2 つ状態に定義します。

// 返金可能な日
extension Reserve where T == RefundableDaysAgo {
    init(name: String, type: ReserveType) {
        self.type = type
        self.name = name
        self.discount = type == .internet ? true : false
        self.refund = false
    }
}

// 来店中
extension Reserve where T == InStore {
    init(name: String, type: ReserveType) {
        self.type = type
        self.name = name
        self.discount = false
        self.refund = false
    }
}

where 句が T == RefundableDaysAgo つまり、返金可能な日 の制約になっていますね。

アクションに対する Extension 拡張

次に、アクション毎に Extension で関数を定義しています。

各予約状態の Enum でアクションと紐付けていますので、アクション毎に関数を定義しています。 ← 実にスマートですね。

// キャンセル済み状態へ(返金)
extension Reserve where T: Refundable {
    func refundCancel() -> Reserve<Canceled> {
        return Reserve<Canceled>(type: self.type, name: self.name, discount: self.discount, refund: true, amount: self.amount)
    }
}

// キャンセル済み状態へ
extension Reserve where T: Cancelable {
    func cancel() -> Reserve<Canceled> {
        return Reserve<Canceled>(type: self.type, name: self.name, discount: self.discount, refund: false, amount: self.amount)
    }
}

// 来店待ち状態へ
extension Reserve where T: Waittable {
    func wait() -> Reserve<WaitingVisit> {
        return Reserve<WaitingVisit>(type: self.type, name: self.name, discount: self.discount, refund: true, amount: self.amount)
    }
}

// 来店済状態へ
extension Reserve where T: Orderable {
    func startMeal() -> Reserve<InStore> {
        return Reserve<InStore>(type: self.type, name: self.name, discount: self.discount, refund: true, amount: self.amount)
    }
}

// 会計済み状態へ
extension Reserve where T: Checkable {
    func checkout(amount: Int) -> Reserve<Closed> {
        return Reserve<Closed>(type: self.type, name: self.name, discount: self.discount, refund: false, amount: amount)
    }
}

状態変化のテスト

// 3日前 → 来店待ち → 来店済 → 退店
Reserve<RefundableDaysAgo>(name: "田中", type: .internet).wait().startMeal().checkout(amount: 2999)

// 直接来店 → 退店
Reserve<InStore>(name: "田中", type: .walkIn).checkout

// 予約受付(返金可能な日) → 来店済 ←このようなフローは出来ない。コンパイルエラーになります。
//Reserve<RefundableDaysAgo>(name: "田中", type: .internet).startMeal()

感想

型安全により不適切な状態変化を、コンパイラーにチェックしてもらえるのは非常に心強いですね。 Enum 定義にフォーカスして Pull Request レビューするのであれば、現実的です。

実装を持たない型を使うこの Phantom Type の恩恵は非常に大きいものに感じます。そして実に興味深い技術です。

本当に勉強になりました。

参考サイトは Computed Property を使って実装されておりましたが、状態変化するときは外から色々パラメータ指定がされる事が予想されるため、func の形にしてみました。

サンプルなのでチェックアウト時 func checkout(amount: Int) のように、金額を外から指定できるだけですが、本来は、時間割や、雨の日特定など来店時などにパラメータとして受け取ったりとより複雑だとは思います。

参考: 【Swift】型を使うという意味を考える (Phantom Type を通して)