TL; DR
- Rust でステートマシンをいい感じに扱えるようにするライブラリを書いてみた。
- とくに他の言語のライブラリを調査したわけではないので、もう少しいいアイディアがあるかもしれない。
何を作ったか
Rust でマクロや他のライブラリを一切使わずにステートマシンを実装できるライブラリを作り、crates.io に公開してみました。
https://crates.io/crates/statemachine-rs
ドキュメントはこちらにあります。
リポジトリはこちらです。
何ができるのか
詳しくは README に記載しておりますので、使用してみたい場合はぜひご覧ください。
Rust には enum
(Rust では代数的データ型になっています)という大変素晴らしい概念が利用できます。ステートマシンにもこれを利用しないわけにはいきませんよね。このライブラリは、enum
等を使用したステートマシンの実装のお手伝いをします。
下記のサンプルでは、ボタンの切り替えを行ってみています。
まず初期設定を StateMachineBuilder
というビルダーを使って行えるように実装してあります。
State
として ButtonState
を用意し、Input
として Input
という enum
を定義します。下記では On/Off という状態を、Press
という入力を使って切り替えられるようにしています。
Input
に対して State
がどのように変化するのかは、クロージャーで定義します。transition
関数の引数は State
と Input
のタプルになっています。ここで、状態と入力のパターンマッチングを行い、状態遷移を定義します。
use statemachine_rs::machine::{builder::StateMachineBuilder, StateMachine}; #[derive(Clone, Debug, PartialEq)] enum ButtonState { On, Off, } enum Input { Press, } fn main() { let sm = StateMachineBuilder::start() .initial_state(ButtonState::Off) .transition(|state, input| match (state, input) { (ButtonState::On, Input::Press) => ButtonState::Off, (ButtonState::Off, Input::Press) => ButtonState::On, }) .build() .unwrap(); assert_eq!(ButtonState::Off, sm.current_state()); sm.consume(Input::Press); assert_eq!(ButtonState::On, sm.current_state()); }
状態を進める際には consume
という関数が生えており、この consume
に入力を渡すと状態遷移が行われます。consume
は StateMachine
トレイトの関数です。consume
以外にも、現在の状態の取得や状態のリセットなどを行える関数を用意してあります。また、StateMachine
トレイトは pub
なため、自身で構造体とその実装を定義してオリジナルのステートマシンを用意することもできます。
pub trait StateMachine<State, Input> { fn current_state(&self) -> State; fn consume(&self, input: Input) -> State; fn reset(&self) -> State; fn set(&self, new_state: State); }
実装して迷っているポイント
今回 Rust ではじめて OSS のライブラリを作ってみたので、慣習がわかっていない部分があります。
実装の都合で State
の enum
に対しては現在 Clone
が要求されます。現在、標準で提供しているのは BasicStateMachine
という構造体です。これは State
に Clone
を要求しています。実装側を見ていただくとわかるのですが、どうしても clone
が必要と思われる箇所が出てくるためです。
/// The basic state machine implementation. /// It holds `initial_state`, `current_state`, `transition` function. pub struct BasicStateMachine<State, Input, Transition> where Transition: Fn(&State, Input) -> State, State: Clone, { /// `initial_state` is literally an initial state of the state machine. /// The field isn't updated the whole life of its state machine. /// That is, it always returns its initial state of its machine. initial_state: State, /// `current_state` is the current state of the state machine. /// It transit to the next state via `transition`. current_state: RefCell<StateWrapper<State>>, /// `transition` is the definition of state transition. /// See an example of [`StateMachine::consume()`], you can grasp how /// to define the transition. transition: Transition, _maker: PhantomData<Input>, }
もし State
に Clone
を要求するのが、大きなパフォーマンス劣化を招く原因になりうるのだとしたら、この Clone
の制約を外せるように実装を調整する必要があるのかなと思っています。が、正直これをどう上手に調整したらよいかの想像がついていません。このあたりのワークアラウンドは結構迷ってます…。
もし何かアイディアがある方がいらっしゃいましたら、お気軽に Issue を立てたり、PR を投げたりなどよろしくお願いします😃
また、BasicStateMachine
は RefCell
を使用していることからもわかるようにスレッドセーフではありません。スレッドセーフな StateMachine
実装は、今後時間を見つけて追加する予定です。
はじめてのクレート公開の感想
cargo package
からの cargo publish
をするだけでクレートを公開できてしまうのは、正直かなり便利ですね。Scala のときは sonatype だったか、どこかの外部サービスに一度ユーザー登録をして、JIRA をうんたらかんたら…といった作業が必要だった(勘違いしてるかも)と記憶しているのですが、crates.io は GitHub の ID さえあればアカウントを作成でき、サイト上でアクセスキーを生成してあとはローカルから cargo publish
を打つだけ、という非常にスムーズな構成になっていました。
OSS の公開に際しての悩みの種といえばドキュメントですが、ドキュメントは crates.io に登録した時点で自動的に URL を用意してくれ、そちらに cargo doc
の結果をホストしてくれます。今回私が作ったクレートのドキュメントはこちらにありますが、これを見たときに「ああ、クレートを公開しちゃったよ…すごい…」という気持ちになりました。いたれりつくせり。
ドキュメントを書くのはかなり骨の折れる作業で、主にサンプルコードの用意の部分で結構手間取りました。ただ、サンプルコードを用意しておくと、cargo test
したタイミングで doc test が走り、コメント内のコードのコンパイルが通るかや assertion が通るかを自動でチェックしてくれます。ドキュメントのメンテ漏れに気づけるようになるので、この仕組みは非常に素晴らしいです。ちなみに他の言語での経験だと Python でも同じことができますね。
クレートの使い勝手については、自分が普段 Rust コードを書く際には問題ないなというレベルで公開してしまっているため、他の方がどう感じるかが結構気になっています。というわけで、ドッグフーディングしてくださる方、よろしくお願いします。Issue や PR お待ちしております!