型状態プログラミングについて調べてみた

Page content

どうも、たくチャレ(@takuchalle)です。

今「基礎から学ぶ 組み込み Rust」を読んでます。 そのなかで型状態プログラミング(Typestate Programming)が気になったので、少し深堀りしてみました。

型状態プログラミング(Typestate Programming)とは

The Embedded Rust Book(日本語版1)によると

The concept of typestates describes the encoding of information about the current state of an object into the type of that object.

と書いてあります。

「現在のオブジェクトの状態をオブジェクトの型にエンコードする」みたいな意味だと私は理解しました。

具体例

The Embedded Rust Bookの最初の例では、Builder Patternが紹介されています。FooBuilder未設定状態で、Foo設定済み状態として表現されています。いきなりFooを作成することはできず、FooBuilder経由で適切に初期化してからでないとFooを取得できないようになっています。それをRustの強力な型付けによってコンパイル時に保証してくれます。

このシンプルな例でもなんとなくは分かるのですが、もう少し実践的なPeripherals as State Machineの例を詳しく見ていきたいと思います。

シンプルなGPIOでも有効/無効や入力/出力などいろいろな状態を持ちます。 マイコンのペリフェラルを一種のステートマシンとして扱い、型状態プログラミングを適用していきます。

具体的なコードはDesign Contractsから抜粋していきます。全体像を見たい場合はDesign Contractsを確認してください。

まずGpioConfigを定義します。

/// GPIO interface
struct GpioConfig<ENABLED, DIRECTION, MODE> {
    /// GPIO Configuration structure generated by svd2rust
	periph: GPIO_CONFIG,
	enabled: ENABLED,
	direction: DIRECTION,
	mode: MODE,
}

struct Disabled;
struct Enabled;
struct Output;
struct Input;
struct PulledLow;
struct PulledHigh;
struct HighZ;
struct DontCare;

この時に有効無効をENABLED、入出力をDIRECTION、モードをMODEとしてジェネリクスで定義します。 そして状態を表す構造体を定義します。メンバがない構造体なのでサイズが0で実行ファイルには残らずコンパイル時のマーカとして使います。

こうしておくことで型状態プログラミングを実現することができ、Rustの強力な片付けを享受することができます。


次にGPIOを有効化して出力に変更するメソッドを見てみましょう。

impl<EN, DIR, IN_MODE> GpioConfig<EN, DIR, IN_MODE> {
	pub fn into_enabled_output(self) -> GpioConfig<Enabled, Output, DontCare> {
		self.periph.modify(|_r, w| {
			w.enable.enabled()
			 .direction.output()
			 .input_mode.set_high()
		});
		GpioConfig {
			periph: self.periph,
			enabled: Enabled,
			direction: Output,
			mode: DontCare,
		}
	}
}

返り値の型がGpioConfig<Enabled, Output, DontCare>になってることがわかると思います。 self.periph.modifyで実際のハードウェアの設定を変更して、新しく遷移した状態GpioConfig<Enabled, Output, DontCare>になっています。

引数が&selfではなく、selfになってることがポイントかなと思っています。selfを消費しつつ新しい状態として返すことで、GPIOのインスタンスがプログラム中に一つしか存在することができないので余計なバグを生む心配がなくなります。


最後に実際に出力を行う部分を見てみます。

impl GpioConfig<Enabled, Output, DontCare> {
	pub fn set_bit(&mut self, set_high: bool) {
		self.periph.modify(|_r, w| w.output_mode.set_bit(set_high));
	}
}

GpioConfig<Enabled, Output, DontCare>に対してメソッドを定義しているので、それ以外の状態では出力が行えないようになっています。 つまり「GPIOが入力の設定になっているのに出力しちゃって正しく動かない」みたいなしょうもないバグは絶対に起きないことが保証されます。Rust最高ですね。

C/C++では出力行う時に状態をチェックして出力する、みたいなコードになるかと思います。

void set_bit(GPIO *gpio, int set_high) {
	if(gpio->state != STATE_OUTPUT)	return;
	
	// 出力
}

型状態プログラミングを適用することでコンパイル時に状態をチェックできるので、実行時の状態チェックのオーバーヘッドが不要になります。ゼロコスト抽象化と呼ばれていますね。

めちゃめちゃわかりやすい画像をツイッターで見つけたので、貼っておきます。

まさにこれがRustを使うメリットの一つをわかりやすく表現していますね。

終わりに

基礎から学ぶ 組み込み Rustに出てきた型状態プログラミングについてちょっと調べてみました。 Rustの強力な型付けやゼロコスト抽象化の特徴を効果的に利用した手法で、個人的にはすごく感動しました。特にプロセッサの性能やメモリサイズが限られている組み込みに非常に有用な手法だと感じました。 もちろん組み込み以外でも使える汎用的な手法だと思うので使える場面があればどんどん使っていきたいと思います。


  1. 基礎から学ぶ組み込み Rust の著者が日本語訳してくれてるじゃないですか。ありがたや… ↩︎