Andrew Hobdenの記事「Rustのプリティステートマシンパターン」に翻訳されています。 最後にオリジナルにリンクします。
最近、私はデザインパターンとプログラミングで使用する手法について多くのことを考えてきました。 プロジェクトの調査を開始し、既に見たことがあるおなじみのパターンやスタイルを見るのは本当に素晴らしいことです。 これにより、プロジェクトの理解が容易になり、作業をスピードアップできます。
新しいプロジェクトに取り組み、以前のプロジェクトと同じ方法で何かをする必要があることに気付くことがあります。 機能やライブラリの一部ではなく、エレガントなマクロや小さなコンテナでラップできないものである可能性があります。 問題をうまく解決するのは、単なる設計パターンまたは構造概念です。
このような問題によく適用される興味深いパターンの1つは、ステートマシンです。 このフレーズが何を意味するのか、そしてなぜそれがとても興味深いのかを理解するために少し時間をかけることをお勧めします。
記事全体を通して、 Rust Playgroundですべての例を実行できます。私は通常、Nightlyバージョンを習慣的に使用しています。
アイデアを正当化する
インターネット上のステートマシンに関する多くのリソースと機能の記事があります。 さらに、それらの多くの実現があります。
このページにアクセスするためだけにそれらの1つを使用しました。 状態マシンを使用してTCPプロトコルをシミュレートできます。 また、それを使用してHTTP要求をシミュレートすることもできます。 正規表現言語(REGEX)など、任意の正規言語を状態マシンとしてモデル化できます。 それらはどこにでもあり、私たちが毎日使うものの中に隠されています。
したがって、ステートマシンとは、それらの間に「状態」と「遷移」のセットを持つ「マシン」です。
オートマトンについて話すとき、私たちは何かをする抽象的な概念を意味します 。 たとえば、Hello World!関数 -自動。 オンになり、最終的には期待どおりの結果が得られます。 データベースと対話するモデルも動作します。 基本的なオートマトンは、作成および破棄できる通常の構造と見なします。
struct Machine; fn main() { let my_machine = Machine; // . // `my_machine` , . }
状態は、プロセスが状態マシンのどこにあるかを説明する方法です。 たとえば、ボトルを充填する機械を想像できます。 このマシンは、新しいボトルを待っているときは「待機」状態です。 彼がボトルを発見するとすぐに、彼は「満杯」状態になります。 ボトルに適切な量の水を満たした直後に、機械は「完了」状態になります。 彼はボトルが取られるとすぐに「待機」状態に戻ります。
これによる主な結論は、他の州に関連する情報を持っている州はないということです。 「充填」状態は、マシンが「スタンバイ」状態になっていた時間を気にしません。 「完了」のステータスは、ボトルの充満度を気にしません。 各州には、 責任と問題が厳密に定義されています 。 これらのオプションを見る自然な方法はenum
です。
enum BottleFillerState { Waiting { waiting_time: std::time::Duration }, Filling { rate: usize }, Done } struct BottleFiller { state: BottleFillerState }
この方法でenum
を使用することは、状態が相互に排他的であることを意味し、特定の時点では1つの状態にしかなれません。 Rustの「脂肪列挙」により、各状態に必要な情報を保存できます。 この方法で定義が宣言されている限り、すべてが順序どおりです。
しかし、小さな問題が1つあります。 上記のオートマトンを説明したときに、状態間の3つの遷移、 →
、 →
および →
。 →
または →
を考慮しませんでしたが、それらは単に意味をなしません!
これにより、遷移のアイデアがもたらされます。 真のステートマシンの最も快適な機能の1つは、 ->
などの遷移を心配する必要がないことです。 有限状態マシンの設計パターンは、このような移行が不可能であることを保証する必要があります。 理想的には、これはマシンを起動する前であっても、プログラムのコンパイル時に起こります。
ダイアグラム内の遷移をもう一度見てみましょう。
+----------+ +------------+ +-----------+ | | | | | | | +-->+ +-->+ | | | | | | | +----+-----+ +------------+ +--+--------+ ^ | +-----------------------------+
ご覧のとおり、状態の有限数と状態間の遷移の有限数があります。 現時点では、各状態間で完全に合法的に他の状態に移行できますが、ほとんどの場合、これは正しくありません。
これは、保留状態と充填状態の間の遷移が特定のルールを満たさなければならないことを意味します。 この例では、このルールは「ボトルは所定の位置にあります」のように見えます。 TCPストリームの場合、これは「FINパケットを受信しました」になります。つまり、ストリームを閉じて転送を完了する必要があります。
欲しいものを決める
ステートマシンが何であるかがわかったので、Rustでどのように実装しますか? 最初に、何が欲しいか考えてみましょう。
理想的には、次の特性を見たいと思います。
- 一度に1つの状態にしかできません。
- 必要に応じて、各状態には独自のデータが必要です。
- 状態間の遷移には、特定のセマンティクスが必要です。
- いくつかの一般的な条件を持つことが可能であるべきです。
- 明示的に定義された遷移のみを許可する必要があります。
- ある状態を別の状態に変更すると、その状態が使用できなくなった場合にその状態を吸収する必要があります。
- すべての状態にメモリを割り当てるべきではありません。 メモリ消費量は、少なくとも最大の状態のサイズ以下でなければなりません。
- 各エラーメッセージは理解しやすいものでなければなりません。
- ヒープの使用に頼るべきではありません。 すべてをスタックに配置する必要があります。
- 型システムは、私たちの最大の利点と考えるべきです。
- コンパイル段階で、できるだけ多くのエラーを検出する必要があります。
したがって、これらのすべての要件を満たすテンプレートがあれば、それは本当に素晴らしいでしょう。 まあ、要件の一部にのみ適しているテンプレートも良いでしょう。
可能な実装を探る
Rustのような強力で柔軟な型システムでは、これを実装できる必要があります。 真実はこれです。いくつかの方法があり、それぞれが特定の利点を提供し、レッスンを教えてくれます。
Enumを使用した2回目の試行
既に知っているように、 enum
最も自然な方法ですが、この場合、遷移を禁止できないことにすでに気付いています。 しかし、それらを構造でラップすることはできますか? もちろんできます! ご覧ください:
enum State { Waiting { waiting_time: std::time::Duration }, Filling { rate: usize }, Done } struct StateMachine { state: State } impl StateMachine { fn new() -> Self { StateMachine { state: State::Waiting { waiting_time: std::time::Duration::new(0, 0)} } } fn to_filling(&mut self) { self.state = match self.state { // "" -> "" State::Waiting { .. } => State::Filling { rate: 1}, // _ => panic!("Invalid state transition!") } } // ... } fn main() { let mut state_machine = StateMachine::new(); state_machine.to_filling(); }
一見、すべてが正常です。 しかし、いくつかの問題に気づきましたか?
- 実行時に禁止された遷移によるエラーが発生しますが、これは恐ろしいことです!
- これにより、モジュール外部からの不正な遷移のみが防止されます。プライベートフィールドはモジュール内部から自由に変更できるためです。 たとえば、
state_machine.state = State::Done
確かにモジュール内で動作します。 - 状態を操作する各関数には、
match
式が必要です。
ただし、このアプローチにはいくつかの利点があります。
- 状態マシンを表すために必要なメモリは、最大の状態のサイズです。 これは、サイズが最大オプションのサイズと一致する
fat enum
を使用した結果です。 - すべてのメモリはスタックに割り当てられ、ヒープは関係しません。
- 状態間の遷移には特定のルールがあります。 動作するか、エラーが発生します!
「すみません、ホバーベア、 to_filling()
出力をResult<T,E>
ラップするか、 InvalidState
オプションをenum
追加できます!」 しかし、それに直面してみましょう:それは、状況をあまり改善しません。 実行時のクラッシュをなくしても、厄介なパターンマッチング式を処理する必要があり、プログラムの起動後にのみエラーが検出されます。 ふう! もっとうまくやれると約束します。
それでは、検索を続けましょう!
遷移を持つ構造
構造体のセットを使用する場合はどうなりますか? それらのそれぞれに対して、各状態に共通する特性のセットを定義できます。 あるタイプを別のタイプに変える特別な関数を使用できます! それはどのように見えますか?
// trait SharedFunctionality { fn get_shared_value(&self) -> usize; } struct Waiting { waiting_time: std::time::Duration, // , shared_value: usize } impl Waiting { fn new() -> Self { Waiting { waiting_time: std::time::Duration::new(0,0), shared_value: 0 } } // ! fn to_filling(self) -> Filling { Filling { rate: 1, shared_value: 0 } } } impl SharedFunctionality for Waiting { fn get_shared_value(&self) -> usize { self.shared_value } } struct Filling { rate: usize, // shared_value: usize, } impl SharedFunctionality for Filling { fn get_shared_value(&self) -> usize { self.shared_value } } // ... fn main() { let in_waiting_state = Waiting::new(); let in_filling_state = in_waiting_state.to_filling(); }
まあ、どのくらいのコード! したがって、アイデアは、すべての州が独自のデータだけでなく、すべての州に共通のデータを持っているというものでした。 ご覧のとおり、 to_filling()
関数to_filling()
「Waiting」状態to_filling()
吸収し、「Filling」状態に移行します。 すべてをまとめましょう:
- 遷移エラーはコンパイル時に決定されます! たとえば、最初にPending状態を作成しないと、偶然にFill状態を作成することさえできません。 (実際にはできますが、これは関係ありません)
- 状態間の遷移が必要です。
- 状態間の遷移中、単純な変更ではなく、古い値が吸収されます。
確かに、最初の試みからenum
も同じことができました。 - 常に
match
する必要はありません。 - メモリ消費量はまだ十分ではありません。 現在のサイズだけが必要です
状態。
いくつかの欠点があります。
- 重複コードがたくさん。 構造ごとに同じ関数とタイプを定義する必要があります。
- どの値が状態に共通であり、どの値が1つだけに属するかは必ずしも明確ではありません。 将来コードをアップグレードすると、多大な費用がかかります。
- 状態のサイズは一定ではないため、以前のように
enum
ラップする必要があります。これにより、より複雑なシステムのコンポーネントの1つとして状態マシンを使用できます。 これは次のようなものです。
enum State { Waiting(Waiting), Filling(Filling), Done(Done) } fn main() { let in_waiting_state = State::Waiting(Waiting::new()); // , `Waiting` `enum`! // `match` let in_filling_state = State::Filling(in_waiting_state.to_filling()); }
ご覧のとおり、これはあまり便利ではありません。 私たちは私たちが望むものに近づいています。 特定のタイプを切り替えるというアイデアは、大きな前進のようです! 完全に異なるものを試す前に、サンプルを変更する方法について説明しましょう。
Rust標準ライブラリは、2つの非常に重要な特性、 From
とInto
提供します。これらは非常に便利で言及する価値があります。 どちらか一方を実装すると自動的に他方が実装されることに注意することが重要です。 一般に、 From
実装がより柔軟であるため、望ましいです。 前の例では、これらを非常に簡単に実装できます。
// ... impl From<Waiting> for Filling { fn from(val: Waiting) -> Filling { Filling { rate: 1, shared_value: val.shared_value, } } } // ...
これは、一般的な遷移関数を提供するだけでなく、ソースコードでこれに遭遇したときに読みやすくなります。 これにより、心理的な負担が軽減され、読者の認識が容易になります。 独自の 機能 を実装する代わりに 、既存のテンプレートを使用します。 既存のテンプレートに基づいたテンプレートのベースは、優れたソリューションです。
だからこれはクールです、私たちはどのように迷惑なコードの繰り返しとshared_value
どこで処理しますか? さらに探検しましょう!
ほぼ完璧
次に、最初の2つの方法からのレッスンとアイデアをまとめ、新しいアイデアを追加して、より楽しいものを取得します。 この方法の本質は、一般化された型の力を使用することです。 かなり基本的な構造を見てみましょう。
struct BottleFillingMachine<S> { shared_value: usize, state: S } // `S` StateMachine<S> struct Waiting { waiting_time: std::time::Duration } struct Filling { rate: usize } struct Done;
そのため、実際には、 BottleFillingMachine
シグネチャにステートマシンの状態を埋め込みます。 Fill状態のステートマシンはBottleStateMachine<Filling>
になります。これは、このタイプをエラーメッセージまたはそのようなものの一部として見ると、すぐにマシンの現在の状態がわかるためです。
次のような特定のオプションに対して、 From<T>
を継続して実装できます。
impl From<BottleFillingMachine<Waiting>> for BottleFillingMachine<Filling> { fn from(val: BottleFillingMachine<Waiting>) -> BottleFillingMachine<Filling> { BottleFillingMachine { shared_value: val.shared_value, state: Filling { rate: 1 } } } } impl From<BottleFillingMachine<Filling>> for BottleFillingMachine<Done> { fn from(val: BottleFillingMachine<Filling>) -> BottleFillingMachine<Done> { BottleFillingMachine { shared_value: val.shared_value, state: Done } } }
オートマトンの初期状態の定義は次のようになります。
impl BottleFillingMachine<Waiting> { fn new(shared_value: usize) -> Self { BottleFillingMachine { shared_value: shared_value, state: Waiting { waiting_time: std::time::Duration::new(0, 0) } } } }
しかし、状態の変化はどのように見えますか? このように:
fn main() { let in_waiting = BottleFillingMachine::<Waiting>::new(0); let in_filling = BottleFillingMachine::<Filling>::from(in_waiting); }
署名が出力タイプを制限する関数内でこれを行う場合:
fn transition_the_states(val: BottleFillingMachine<Waiting>) -> BottleFillingMachine<Filling> { val.into() // , ? }
コンパイル時のエラーメッセージのタイプはどうですか?
error[E0277]: the trait bound `BottleFillingMachine<Done>: std::convert::From<BottleFillingMachine<Waiting>>` is not satisfied --> <anon>:50:22 | 50 | let in_filling = BottleFillingMachine::<Done>::from(in_waiting); | ^^^^^^^^^^^^^^^^^^^^^^^^^^ | = help: the following implementations were found: = help: <BottleFillingMachine<Filling> as std::convert::From<BottleFillingMachine<Waiting>>> = help: <BottleFillingMachine<Done> as std::convert::From<BottleFillingMachine<Filling>>> = note: required by `std::convert::From::from`
ここで何が間違っているかは明らかです。 エラーメッセージは、適切な遷移を示しています。
それで、このアプローチは私たちに何を与えますか?
- 遷移はコンパイル時に検証されます。
- エラーメッセージは非常に明確であり、可能な修正のリストを提供します。
- 子タイプで繰り返す必要のない特性とデータを関連付けることができる「親」構造があります。
- 移行が行われるとすぐに、古い状態はもはや存在せず、「吸収」されました。 実際、構造全体が消失するため、移行中に副作用がある場合(たとえば、平均待機時間の変更など)、古い値を取得できません。
- 低メモリ消費、スタックのみが関係します。
まだ欠点があります:
-
From<T>
実装には、いくつかのタイプの混乱があります。 ただし、これはやや不便です。 - 各
BottleFillingMachine<S>
サイズは異なるため、引き続きenum
使用する必要があります。 それでも、これは私たちの構造による深刻な欠陥ではありません。
親との汚い関係
翻訳者注:このタイトルの翻訳は、Google Translatorが親切に提供してくれたのでとても素晴らしいので、そのままにしておきました。
ひどい相互作用の問題なしに、ステートマシンの状態を保存するための親構造をどのように整理できますか? まあ、それはenum
最初のアイデアに戻ります。
覚えている場合、最初のアプローチの主な問題は、トランジションを提供する機能がなく、実行時にすべてのエラーが表示されることでした。
enum BottleFillingMachineWrapper { Waiting(BottleFillingMachine<Waiting>), Filling(BottleFillingMachine<Filling>), Done(BottleFillingMachine<Done>) } struct Factory { bottle_filling_machine: BottleFillingMachineWrapper } impl Factory { fn new() -> Self { Factory { bottle_filling_machine: BottleFillingMachineWrapper::Waiting(BottleFillingMachine::new(0)) } } }
この時点で、あなたの最初の反応は、「地獄、ホバーベア、これらの長くひどい型宣言を見てください」です。 あなたは絶対に正しいです! 正直なところ、それらは本当に長いですが、私は最も理解しやすいタイプ名を選びました! コードでは、お気に入りの略語とエイリアスをすべて使用できます。
見て!
impl BottleFillingMachineWrapper { fn step(&mut self) -> Self { match self { BottleFillingMachineWrapper::Waiting(val) => BottleFillingMachineWrapper::Filling(val.into()), BottleFillingMachineWrapper::Filling(val) => BottleFillingMachineWrapper::Done(val.into()), BottleFillingMachineWrapper::Done(val) => BottleFillingMachineWrapper::Waiting(val.into()) } } } fn main() { let mut the_factory = Factory::new(); the_factory.bottle_filling_machine = the_factory.bottle_filling_machine.step(); }
繰り返しますが、これは変化ではなく吸収によって機能することに気付くでしょう。 match
を使用してval
を移動し 、 .into()
それ.into()
使用して以前の状態を吸収できるように.into()
ます。 ただし、値を変更する場合は、 #[derive(Clone)]
実装#[derive(Clone)]
か、状態に合わせてCopy
#[derive(Clone)]
こともできます。
仕事には多少不便で快適ですが、型のシステムとそれに付随するすべての保証を備えたトランジションがまだあります。
このメソッドを使用すると、ステートマシンでの操作中に考えられるすべての状態を強制的に処理することに気付く場合がありますが、これは理にかなっています。 ステートマシンを使用して構造を所有および管理する場合、ステートマシンが存在できる各ステートのアクションを決定する必要があります。
または、単にpanic!()
呼び出すことができますpanic!()
本当にしたい場合。 しかし、単にpanic
たい場合は、最初のアプローチを使用しないのはなぜですか?
実施例
これは、例が不要でない場合に当てはまります。 そこで、以下にいくつかの実例を作成し、コメントを提供しました。
3つの状態、2つの遷移
この例は、ボトル充填機に非常に似ていますが、非常に些細ではありますが、 実際に機能します。 このステートマシンは文字列を受け取り、その中の単語数を返します。
Rust Playgroundへのリンク
fn main() { // <StateA>. ! let in_state_a = StateMachine::new(" ".into()); // , in_state_a.some_unrelated_value; println!(" : {}", in_state_a.state.start_value); // . // let in_state_b = StateMachine::<StateB>::from(in_state_a); // ! ! // in_state_a.some_unrelated_value; // in_state_b.some_unrelated_value; println!(" : {:?}", in_state_b.state.interm_value); // let in_state_c = StateMachine::<StateC>::from(in_state_b); // ! ! // in_state_c.state.start_value; println!(" : {}", in_state_c.state.final_value); } // struct StateMachine<S> { some_unrelated_value: usize, state: S } // impl StateMachine<StateA> { fn new(val: String) -> Self { StateMachine { some_unrelated_value: 0, state: StateA::new(val) } } } // struct StateA { start_value: String } impl StateA { fn new(start_value: String) -> Self { StateA { start_value: start_value, } } } // B struct StateB { interm_value: Vec<String>, } impl From<StateMachine<StateA>> for StateMachine<StateB> { fn from(val: StateMachine<StateA>) -> StateMachine<StateB> { StateMachine { some_unrelated_value: val.some_unrelated_value, state: StateB { interm_value: val.state.start_value.split(" ").map(|x| x.into()).collect(), } } } } // , , struct StateC { final_value: usize, } impl From<StateMachine<StateB>> for StateMachine<StateC> { fn from(val: StateMachine<StateB>) -> StateMachine<StateC> { StateMachine { some_unrelated_value: val.some_unrelated_value, state: StateC { final_value: val.state.interm_value.len(), } } } }
いかだ
最近私のブログ投稿をフォローしているなら、おそらくRaftについて書くことを好むことをご存知でしょう。 この研究を実施するきっかけとなったのは、Raftと@argorakとの会話でした 。
状態間の遷移はA->B->C
ように線形ではないため、いかだは前の例よりもやや複雑ですA->B->C
この状態マシンの状態図と遷移を以下に示します。
+----------+ +-----------+ +--------+ | +----> | | | | Follower | | Candidate +----> Leader | | <----+ | | | +--------^-+ +-----------+ +-+------+ | | +-------------------------+
// fn main() { let is_follower = Raft::new(/* ... */); // , 3, 5 7 Raft. :) // let is_candidate = Raft::<Candidate>::from(is_follower); // ! let is_leader = Raft::<Leader>::from(is_candidate); // Follower let is_follower_again = Raft::<Follower>::from(is_leader); // ... let is_candidate_again = Raft::<Candidate>::from(is_follower_again); // ! let is_follower_another_time = Raft::<Follower>::from(is_candidate_again); } // struct Raft<S> { // ... state: S } // , Raft // , struct Leader { // ... } // , struct Candidate { // ... } // , struct Follower { // ... } // Raft Follower impl Raft<Follower> { fn new(/* ... */) -> Self { // ... Raft { // ... state: Follower { /* ... */ } } } } // // , impl From<Raft<Follower>> for Raft<Candidate> { fn from(val: Raft<Follower>) -> Raft<Candidate> { // ... Raft { // ... attr: val.attr state: Candidate { /* ... */ } } } } // , impl From<Raft<Candidate>> for Raft<Follower> { fn from(val: Raft<Candidate>) -> Raft<Follower> { // ... Raft { // ... attr: val.attr state: Follower { /* ... */ } } } } // impl From<Raft<Candidate>> for Raft<Leader> { fn from(val: Raft<Candidate>) -> Raft<Leader> { // ... Raft { // ... attr: val.attr state: Leader { /* ... */ } } } } // , , impl From<Raft<Leader>> for Raft<Follower> { fn from(val: Raft<Leader>) -> Raft<Follower> { // ... Raft { // ... attr: val.attr state: Follower { /* ... */ } } } }
I-impv Reddit , , . :
. -.
, :
- . (, ), .
- "", .
!
Rust . enum
, . , , .
, . IRC Mozilla Hoverbear.