RustにおけるTypestateパターンの完全習得:実行時バグをコンパイル時に根絶する

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

アプローチ比較:動的ステート vs. Typestateパターン

大半のビジネスロジックは厳格なライフサイクルに従います。例えば、ECサイトの注文はDraft(下書き)から始まり、Paid(支払い済み)へと進み、最終的にShipped(発送済み)となります。ネットワークソケットであれば、Disconnected(未接続)からConnected(接続済み)へと遷移します。こうしたライフサイクルをモデル化する際、開発者は通常2つのアプローチのいずれかを選択します。

1. 従来の実行時フラグによるアプローチ

多くのコードベースでは、単一の巨大な構造体(モノリシック構造体)内にenum型のフィールドとしてステートを持たせ、実行時チェックでガードしています。

enum OrderStatus {
    Draft,
    Paid,
    Shipped,
}

struct Order {
    id: u64,
    status: OrderStatus,
    items: Vec<String>,
}

impl Order {
    fn ship(&mut self) -> Result<(), String> {
        match self.status {
            OrderStatus::Paid => {
                self.status = OrderStatus::Shipped;
                Ok(())
            }
            _ => Err("未払いの注文、またはすでに発送済みの注文は発送できません".into()),
        }
    }
}

もし誰かが下書き状態(draft)の注文に対してship()を呼び出したらどうなるでしょうか?コンパイラは何も警告してくれません。コードは何事もなくビルドされ、本番環境でユーザーが予期せぬ500エラーを踏むまで、その不具合は静かに潜み続けることになります。

2. Typestateパターン

Typestateパターンは、動的なライフサイクルの各ステージを個別の「コンパイル時型」へと変換します。可変なenumフラグを保持する代わりに、構造体は現在のステージを表すサイズゼロのマーカー型を保持します。そして遷移メソッドはselfを値として消費(ムーブ)し、完全に新しい型を返します。

// ship() メソッドを実装しているのは Order<Paid> のみ
let paid_order: Order<Paid> = draft_order.pay();
let shipped_order: Order<Shipped> = paid_order.ship("DHL-8841");

この状態で、draft_orderに対してship()を呼び出してみてください。即座にコンパイルエラーが発生します。Order<Draft>にはそもそもスコープ内にship()メソッドが存在しないため、コンパイラがバイナリの生成を完全に拒絶するのです。

メリット・デメリット:Typestateの適用を検討する

不変条件(インバリアント)を実行時のガードからコンパイル時の保証へと移行することで、絶大な安心感が得られます。しかし、他のあらゆるデザインパターンと同様に、実践的なエンジニアリング上のトレードオフも存在します。

主なメリット

  • 不正な状態の排除:ビジネスルールが物理的な型の制約となります。キャンセルされた注文に誤って課金したり、未払いの請求書を発送処理したりすることは不可能です。
  • ゼロオーバーヘッド:64ビット環境において、std::mem::size_of::<Order<Draft>>()はちょうど32バイト(u64のIDが8バイト+Vecが24バイト)になります。Rustのゼロサイズ型(ZST)やPhantomDataは、LLVMの最適化によって完全に消去されます。余分なCPUサイクルを消費したりヒープ領域を割り当てたりすることなく、鉄壁の安全性を手に入れることができます。
  • 所有権による古い参照の防止:状態遷移メソッドがselfを値として受け取るため、Rustは古い状態の値を消費します。古い状態に対してメソッドを呼び出そうとすると、コンパイルエラーE0382: use of moved valueが発生します。
  • IDEの補完が直感的に:エンジニアには、オブジェクトの現在の状態に対して有効なメソッドのみが補完候補として表示されます。これにより、新規メンバーのキャッチアップコストが低減し、コードレビュー前の設定ミスも防止できます。

考慮すべきトレードオフ

  • ボイラープレートの増加:マーカートレイト、ジェネリック構造体、状態ごとの実装を定義する必要があるため、単純にenumのバリアントを1つ追加するよりも初期コード量が増えます。
  • 永続化層との境界:リレーショナルデータベースでは、ステータスカラムはフラットな文字列や整数(例:status = "PAID")として保存されます。境界部分でそれらのレコードを具体的なTypestateに復元するためのファクトリやパーサーを明示的に記述する必要があります。
  • 異種コレクションの扱い:通常のVec<Order<S>>内にOrder<Draft>とOrder<Paid>を混在させて格納することはできません。外側をラップするenumを用意するか、トレイトオブジェクトを動的に利用する必要があります。

アーキテクチャ:3つの基本構成要素

Rustにおいてイディオマティックかつ本番環境に耐えうるTypestateを実装するには、以下の3つの原則をベースにします。

  1. ゼロサイズ構造体(ZST):メモリ上のサイズが0バイトであるpub struct Draft;のような空のマーカー構造体を定義します。
  2. シールドマーカートレイト(Sealed Marker Trait):非公開モジュールで保護されたトレイトに各状態をバインドします。これにより、外部クレートがドメインパイプラインに無許可の状態を勝手に追加するのを防ぎます。
  3. 値を消費する遷移(self):&mut selfによる借用ではなく、必ずselfの所有権を受け取ります。これにより、次の状態へ遷移した際に確実に前の状態が破棄されることが保証されます。

私たちは、1日に約4万件の決済イベントを処理する決済サービスにこのパターンを導入しました。本番稼働してからの2年間、これらクリティカルなパイプラインにおける不正な状態遷移のリグレッションは一件も発生していません。

実装ガイド:信頼性の高い注文パイプラインの構築

ここでは、堅牢な注文処理ワークフローをステップバイステップで構築する方法を解説します。

ステップ1:マーカー型とシールドトレイトの定義

まずは、各状態を表す空の型を作成します。外部コードが任意のステートを実装できないよう、シールドトレイトパターンを採用します。

use std::marker::PhantomData;

// 外部クレートによるこのトレイトの実装を防止
mod private {
    pub trait Sealed {}
}

pub struct Draft;
pub struct Paid;
pub struct Shipped;

impl private::Sealed for Draft {}
impl private::Sealed for Paid {}
impl private::Sealed for Shipped {}

pub trait OrderState: private::Sealed {}
impl OrderState for Draft {}
impl OrderState for Paid {}
impl OrderState for Shipped {}

// 状態 S によってパラメータ化された中核構造体
pub struct Order<S: OrderState> {
    pub id: u64,
    pub items: Vec<String>,
    _state: PhantomData<S>,
}

ステップ2:共通の振る舞いを実装する

注文IDの取得やアイテム一覧の参照など、すべての状態に共通するプロパティは、制約なしのジェネリックブロックを使用して実装します。

impl<S: OrderState> Order<S> {
    pub fn id(&self) -> u64 {
        self.id
    }

    pub fn items(&self) -> &[String] {
        &self.items
    }
}

ステップ3:状態固有のメソッドと遷移の実装

続いて、特定の状態に限定された操作を実装します。所有権をきれいに引き渡すため、各遷移メソッドがselfを消費している点に注目してください。

impl Order<Draft> {
    pub fn new(id: u64) -> Self {
        Self {
            id,
            items: Vec::new(),
            _state: PhantomData,
        }
    }

    pub fn add_item(&mut self, item: String) {
        self.items.push(item);
    }

    // 状態遷移: Draft -> Paid
    pub fn pay(self) -> Order<Paid> {
        Order {
            id: self.id,
            items: self.items,
            _state: PhantomData,
        }
    }
}

impl Order<Paid> {
    // 状態遷移: Paid -> Shipped
    pub fn ship(self, tracking_number: &str) -> (Order<Shipped>, String) {
        let receipt = format!("注文番号 {} は {} 経由で発送されました", self.id, tracking_number);
        let shipped = Order {
            id: self.id,
            items: self.items,
            _state: PhantomData,
        };
        (shipped, receipt)
    }
}

impl Order<Shipped> {
    pub fn tracking_status(&self) -> &'static str {
        "配送業者による輸送中"
    }
}

ステップ4:動作テスト

コンパイラがどのようにこのパイプラインを強制するか、実際の挙動を確認してみましょう。

fn main() {
    let mut draft = Order::new(101);
    draft.add_item("Ergonomic Keyboard".to_string());
    draft.add_item("USB-C Cable".to_string());

    // 正しい遷移: Draft が消費され、Order<Paid> が返される
    let paid = draft.pay();

    // コンパイルエラー [E0382]: ムーブされた値 `draft` の使用
    // draft.add_item("Desk Mat".to_string());

    // コンパイルエラー: 型 `Order<Draft>` にメソッド `ship` は見つかりません
    // draft.ship("DHL-9921");

    let (shipped, receipt) = paid.ship("DHL-9921");
    println!("{}", receipt);
    println!("現在のステータス: {}", shipped.tracking_status());
}

実環境における永続化の取り扱い

PostgreSQLやSQLiteからデータを読み出す際は、リポジトリ層の境界でTypestateをラップするドメインenumを用意します。

pub enum AnyOrder {
    Draft(Order<Draft>),
    Paid(Order<Paid>),
    Shipped(Order<Shipped>),
}

impl AnyOrder {
    pub fn from_db_record(id: u64, status: &str, items: Vec<String>) -> Result<Self, String> {
        match status {
            "DRAFT" => Ok(AnyOrder::Draft(Order { id, items, _state: PhantomData })),
            "PAID" => Ok(AnyOrder::Paid(Order { id, items, _state: PhantomData })),
            "SHIPPED" => Ok(AnyOrder::Shipped(Order { id, items, _state: PhantomData })),
            other => Err(format!("データベース内の不明なステータス: {}", other)),
        }
    }
}

バリデーションロジックはI/O境界で隔離しておきます。一度データがドメイン層のコアに入ってしまえば、Typestateの働きにより不正な状態遷移はコンパイル自体が通らなくなります。

Share: