Yabutan 技術ブログ > Rust タイプステートパターンによるAPI設計

Rust タイプステートパターンによるAPI設計

#Rust#設計#Stateパターン#デザインパターン#Typestatesパターン#PhantomData#Generics

2023-03-19

Rust は、安全性とパフォーマンスに重点をおいた言語で、強い型定義でコンパイル時にプログラムの正確性を強制する事ができます。
この記事では、Generics と ゼロサイズのタイプ(PhantomDataマーカー)を活用する事で、ステートフルなロジックコードを、タイプステートパターンを用いて実装できることを紹介していきます。

Typestates パターン

タイプステートパターンは、
オブジェクトの状態(ステート)に合わせて動作パターンを変えていくようなデザインパターンで、
Rustではステートに応じて型を変化させて表現します。

型が変わればなんでも良いわけですが、
Rustでは、GenericsパラメータとゼロサイズタイプであるPhantomDataマーカーを活用してより簡素に表現する事ができます。

Generics パラメーター

Generics パラメーターは、
struct に対して型パラメータ<T>を設けて実装バリエーションを増やす事が出来ます。

struct Pair<T> {
  first: T,
  second: T,
}

コンパイル時には、実際に使用されるT型パラメーターごとにランタイムコードが生成されます。
例えば、Pair<u32>, Pair<String> として2種の型パラメーターで使う場合、
コンパイル時にはそれぞれの型パラメーターに応じた区別されたPair型が生成されます。

Pair<u32>, Pair<String> は、
元々は同じPair型ですがコンパイル時には区別された別物の型になっている。
というところがポイント。

PhantomData マーカー

std::marker::PhantomData はゼロサイズの型であり、
「ファントム型パラメータ」であることを示すために使用されます。
これは、構造体が特定の型のメンバー変数を所有していることをコンパイル時に伝えるだけで、ランタイム時には存在させません。

何のためにあるかというと、
先ほどのGenerics と組み合わす際に構造体にひとつもT型のメンバー変数がないとコンパイルエラー(unused parameter)になってしまうため、 Generics として構造体は定義するけどT型のメンバー変数を持たせる必要はない。という時に使います。

その為、PhantomDataマーカーをつけたメンバー変数は、ランタイム時には実態を持たない変数となります。(ゼロサイズ)

use std::marker::PhantomData;

// Wrapper type with a phantom type parameter T
struct Wrapper<T> {
    _phantom: PhantomData<T>,
}

impl<T> Wrapper<T> {
    // Creates a new Wrapper instance
    pub fn new() -> Self {
        Wrapper {
            _phantom: PhantomData,
        }
    }
}

fn main() {
    // Creates Wrapper instances with different phantom types
    let int_wrapper = Wrapper::<i32>::new();
    let string_wrapper = Wrapper::<String>::new();
}

int_wrapperは Wrapper<i32>、string_wrapperは Wrapper<String> として区別されていますが、
ランタイム実行時にはT型のメンバー変数を持ってる訳ではない。

これをタイプステートパターンに活用することで、
基は同じ構造体に対して、ゼロサイズで状態ごとに型を区別させる事が出来るというわけです。

しかも、impl Wrapper<String> { ... } などと書けば、
特定のT型にだけメソッドを定義する事が出来ますし impl <T> Wrapper<T> { ... } と書けば、
全てのT型に対してメソッド定義することが可能です。

Typestates パターンの実装例

簡単な例として、データベースコネクションの操作 API を実装します。

初期状態は未接続から始まり。
接続された状態でのみクエリ実行メソッドがコールできるものです。

use std::marker::PhantomData;

// Connection ステート
struct Disconnected;
struct Connected;

// ステートを Generics パラメータとして指定する。
struct Connection<State> {
    data: ConnectionData,
    _state: PhantomData<State>,
}

未接続状態と接続状態を、unit structを使って定義します。
Connectionの型パラメーターとして扱う為のもので状態を表しています。

_state メンバー変数はPhantomDataでマークすることで、
ライタイム時には実際にはサイズを持ちません。(ゼロサイズのデータ)

// 未接続状態の実装
impl Connection<Disconnected> {
    fn new(host: &str, port: u16, user: &str, pass: &str) -> Self {
        Self {
            data: ConnectionData {
                host: host.to_string(),
                port,
                user: user.to_string(),
                pass: pass.to_string(),
            },
            _state: PhantomData,
        }
    }

    fn connect(&self) -> Result<Connection<Connected>, DatabaseError> {
        // ... connect to the database ...

        // If failed:
        // return Err(DatabaseError::FailedToConnect);

        // If successful:
        Ok(Connection {
            data: self.data.clone(),
            _state: PhantomData,
        })
    }
}

初期でインスタンス化したときは未接続(Disconnnected)の状態から始まります。
未接続状態から接続状態に移行するために、connectメソッドが用意されています。

// 接続状態の実装
impl Connection<Connected> {
    fn disconnect(self) -> Connection<Disconnected> {
        // ... disconnect from the database ...
        Connection {
            data: self.data,
            _state: PhantomData,
        }
    }

    fn query(&self, query: &str) -> Result<QueryResult, DatabaseError> {
        // ... execute the query ...

        // If failed:
        // return Err(DatabaseError::FailedToExecuteQuery);

        // If successful:
        Ok(QueryResult {})
    }
}

接続状態でのみクエリ実行メソッドがコール出来るように、
Connection<Connected>に対して、queryメソッドを実装しています。

fn main() -> anyhow::Result<()> {
    // 未接続状態でインスタンス化
    let conn: Connection<Disconnected> = Connection::new("localhost", 3306, "user01", "1234");

    // 接続状態
    let conn: Connection<Connected> = conn.connect()?;

    // クエリ実行
    let result: QueryResult = conn.query("SELECT * FROM users")?;

    // 未接続状態
    let conn: Connection<Disconnected> = conn.disconnect();

    Ok(())
}

上記では、変数の型は明示しなくでも推論してくれますが、
状態型の変化がわかりやすいように明記しています。

実際、使用時のコードで分かるように、
同じConnection型ですが状態に応じてGenerics パラメータが変化しています。

型を変化させているので接続状態から再度connectメソッドを間違ってコールすることも出来ないですし、
未接続状態からクエリ実行やdisconnectメソッドが間違ってコールされることもありません。

状態を型で表現する事でプログラム上の間違いをコンパイル時にチェックでき、
ランタイム実行時のエラーを少なくする事ができています。

まとめ

Stateパターンは、
状態に合わせてオブジェクトの型を変化させる事で振る舞いを変えていくデザインパターン。

RustではTypestatesパターンと表現されていて、
GenericsとPhantomDataを組み合わせることで簡単に表現ができる。

PhantomDataに指定したメンバー変数は、
ランタイム時には実態を持たないのでゼロコストで実現可能。

ステートフルなロジックが不可欠な多くのシナリオにTypestatesパターンを適用できる。
コンパイル時にステートフルなロジックを型で強制する事ができるので、 より堅牢で実行時エラーのないコードが書ける。