RustでPhantom Typeを利用し、状態毎に振る舞いを持たせる
ドメインを設計する際、ドメインの状態によって振る舞いを変えたい時がある。例えば、TodoアプリでユーザーがTaskを操作(新規作成、編集、削除など)できるとすると、そのアプリケーションではTaskという構造体を定義すると良さそうに思える
struct Task {
id: TaskId,
title: TaskTitle,
description: TaskDescription,
status: TaskStatus
}
次にTaskに編集する振る舞いを持たせたいとする。条件として、Taskは未完了の時に編集が可能だが、完了時には編集ができないとする。そういった編集という振る舞いをTaskに定義すると下記のようになる
impl Task {
pub fn update_title(&self, title: String) -> Task {
if self.status != TaskStatus::Completed {
Task {
id: self.id.clone(),
title: TaskTitle(title),
description: self.description.clone(),
status: self.status.clone(),
}
}
}
}
update_title関数内ではstatusの確認が必要となる。あるいは、Task自体を状態により別々に定義することもできる
struct IncompleteTask {
id: TaskId,
title: TaskTitle,
description: TaskDescription,
}
struct CompletedTask {
id: TaskId,
title: TaskTitle,
description: TaskDescription,
}
impl IncompleteTask {
pub fn update_title(&self, title: String) -> IncompleteTask {
IncompleteTask {
id: self.id.clone(),
title: TaskTitle(title),
description: self.description.clone(),
status: self.status.clone(),
}
}
}
どちらでもやりたいことはできるが、前者の方法ではロジックをコードを読むことで理解する必要があり、後者ではTaskにfieldが増えた時など変更時点での修正範囲が広がる可能性がある。そこで、別の方法としてPhantom Typeを利用し、Taskの状態を型で表現し状態毎に振る舞いを持たせる方法を考える
Phantom Typeとは
幽霊型(Phantom Type)とは実行時には存在しないけれども、コンパイル時に静的に型チェックされるような型のことです。構造体などのデータ型は、ジェネリック型パラメータを一つ余分に持ち、それをマーカーとして使ったりコンパイル時の型検査に使ったりすることができます。このマーカーは実際の値を何も持たず、したがって実行時の挙動そのものにはいかなる影響ももたらしません。
TaskにStateを持たせる
Taskが持つ状態をstructで作成する
#[derive(Debug, Clone,PartialEq)]
pub struct Incomplete;
#[derive(Debug, Clone,PartialEq)]
pub struct Complete;
次にtraitを作成する。traitは他のプログラム言語でいうinterfaceのようなもの。
pub trait TaskState {}
先程作成した3つのstructにTaskStateの実装をする。TaskStateは何もメソッドを持たせず、IncompleteやCompleteがTaskStateに属していることを示すためのtraitとして利用する
impl TaskState for Incomplete {}
impl TaskState for Complete {}
次にGenericを使いTaskを状態を型パラメータとして持つ構造体に修正する
struct Task<State: TaskState> {
id: TaskId,
title: TaskTitle,
description: TaskDescription,
_state: phantomData<State>,
}
このように定義することで、Taskのインスタンスを作成する際に状態を付与することができる
// このようにTaskの状態を表すインスタンスが作成可能となる
Task::<Incomplete> {
...
}
今後Taskの状態を増やす場合は、TaskStateを実装したStructを追加すれば良い。
IcompleteのTaskに振る舞いを追加する
update_titleメソッドをTask::<Incomplete>に作成する
impl Task<Incomplete> {
pub fn update_title(&self, title: String) -> Task<Incomplete> {
Task::<Incomplete> {
id: self.id.clone(),
title: TaskTitle(title),
description: self.description.clone(),
_status: PhantomData,
}
}
}
Task<Incomplete>の状態ではupdate_titleが実行できるが、その他の状態ではupdate_titleは不可となる。また、Taskの状態遷移としてIncompleteからCompleteに遷移するcompletedメソッドを追加すると、Taskの状態遷移を定義することができる
impl Task<Incomplete> {
pub fn completed(&self) -> Task<Complete> {
Task::<Complete> {
id: self.id.clone(),
title: self.title.clone(),
description: self.description.clone(),
_status: PhantomData,
}
}
}
Editable traitの追加
TaskはIncompleteの状態であれば編集可能な状態になった。ただ、今後Taskの状態が増えていった時に編集可能な状態を持つTaskが増えていく可能性はある。例えば、Incompleteがより詳細にPendingやInProgressといった状態で定義されると、編集可能な振る舞いを持たせたい状態が増える。それぞれで細かく挙動が変わる、例えば、Pendingでは全ての項目を変えられるがInProgressでは一部になるなどならばそれぞれの状態に別のメソッドを定義する方が良い。ただ、そういうことではなく同じものであるなら共通の振る舞いとして定義したい。
そこで、更新可能であるということを共通の振る舞いとして持たせるためにもう1つtraitを作成する。
pub trait Editable: TaskState {}
impl Editable for Pending {}
impl Editable for Inprogress {}
TaskStateがPending,InprogressのTaskに対してupdate_titleが可能であるということではなく、TaskStateかつEditableを実装したものはupdated_titleが可能であるように変更する。こうするとTaskStateかつEditableを実装したものはupdate_titleが可能となる
// impl Task<Created> を下記のように修正する
impl <S: Editable> Task<S> {
pub fn update_title(&self, title: String) -> Task<S> {
...
}
}
まとめ
Rustのstructで状態を持つドメインを定義する際に、Phantom Typeを利用することで状態毎に振る舞いを持たせる方法を紹介した。Phantom Typeを利用することで、状態による条件を型で守ることが可能になる。phantomDataの使い方によってはより複雑になってしまうかもしれないが、上手く利用できれば型で表現できる範囲が広がり便利なのではと思う