設計契約
前の章では、設計契約を強制しないインターフェイスを書きました。では、架空の GPIO 設定レジスタをもう一度見てみましょう。
| 名前 | ビット番号 | 値 | 意味 | 備考 |
|---|---|---|---|---|
| enable | 0 | 0 | 無効 | GPIO を無効にする |
| 1 | 有効 | GPIO を有効にする | ||
| direction | 1 | 0 | 入力 | 方向を入力に設定する |
| 1 | 出力 | 方向を出力に設定する | ||
| input_mode | 2..3 | 00 | ハイインピーダンス | 入力を高抵抗に設定する |
| 01 | プルロー | 入力ピンはローにプルされる | ||
| 10 | プルハイ | 入力ピンはハイにプルされる | ||
| 11 | 該当なし | 無効な状態。設定しないこと | ||
| output_mode | 4 | 0 | ローに設定 | 出力ピンはローで駆動される |
| 1 | ハイに設定 | 出力ピンはハイで駆動される | ||
| input_status | 5 | x | 入力値 | 入力が < 1.5v の場合は 0、入力が >= 1.5v の場合は 1 |
代わりに、基盤となるハードウェアを利用する前に状態をチェックし、実行時に設計契約を強制するとしたら、次のようなコードを書くことになるでしょう。
/// GPIO インターフェイス
struct GpioConfig {
/// svd2rust によって生成された GPIO 設定構造体
periph: GPIO_CONFIG,
}
impl GpioConfig {
pub fn set_enable(&mut self, is_enabled: bool) {
self.periph.modify(|_r, w| {
w.enable().set_bit(is_enabled)
});
}
pub fn set_direction(&mut self, is_output: bool) -> Result<(), ()> {
if self.periph.read().enable().bit_is_clear() {
// 方向を設定するには有効でなければならない
return Err(());
}
self.periph.modify(|r, w| {
w.direction().set_bit(is_output)
});
Ok(())
}
pub fn set_input_mode(&mut self, variant: InputMode) -> Result<(), ()> {
if self.periph.read().enable().bit_is_clear() {
// 入力モードを設定するには有効でなければならない
return Err(());
}
if self.periph.read().direction().bit_is_set() {
// 方向は入力でなければならない
return Err(());
}
self.periph.modify(|_r, w| {
w.input_mode().variant(variant)
});
Ok(())
}
pub fn set_output_status(&mut self, is_high: bool) -> Result<(), ()> {
if self.periph.read().enable().bit_is_clear() {
// 出力状態を設定するには有効でなければならない
return Err(());
}
if self.periph.read().direction().bit_is_clear() {
// 方向は出力でなければならない
return Err(());
}
self.periph.modify(|_r, w| {
w.output_mode.set_bit(is_high)
});
Ok(())
}
pub fn get_input_status(&self) -> Result<bool, ()> {
if self.periph.read().enable().bit_is_clear() {
// 状態を取得するには有効でなければならない
return Err(());
}
if self.periph.read().direction().bit_is_set() {
// 方向は入力でなければならない
return Err(());
}
Ok(self.periph.read().input_status().bit_is_set())
}
}
ハードウェア上の制約を強制する必要があるため、実行時のチェックを大量に行うことになり、時間とリソースを浪費します。また、このコードは開発者にとってもかなり使いにくいものになります。
型状態
では、代わりに Rust の型システムを使って状態遷移のルールを強制するとしたらどうでしょうか。次の例を見てください。
/// GPIO インターフェイス
struct GpioConfig<ENABLED, DIRECTION, MODE> {
/// svd2rust によって生成された GPIO 設定構造体
periph: GPIO_CONFIG,
enabled: ENABLED,
direction: DIRECTION,
mode: MODE,
}
// GpioConfig における MODE の型状態
struct Disabled;
struct Enabled;
struct Output;
struct Input;
struct PulledLow;
struct PulledHigh;
struct HighZ;
struct DontCare;
/// これらの関数は任意の GPIO ピンで使用できる
impl<EN, DIR, IN_MODE> GpioConfig<EN, DIR, IN_MODE> {
pub fn into_disabled(self) -> GpioConfig<Disabled, DontCare, DontCare> {
self.periph.modify(|_r, w| w.enable.disabled());
GpioConfig {
periph: self.periph,
enabled: Disabled,
direction: DontCare,
mode: DontCare,
}
}
pub fn into_enabled_input(self) -> GpioConfig<Enabled, Input, HighZ> {
self.periph.modify(|_r, w| {
w.enable.enabled()
.direction.input()
.input_mode.high_z()
});
GpioConfig {
periph: self.periph,
enabled: Enabled,
direction: Input,
mode: HighZ,
}
}
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,
}
}
}
/// この関数は出力ピンで使用できる
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));
}
}
/// これらのメソッドは有効化された任意の入力 GPIO で使用できる
impl<IN_MODE> GpioConfig<Enabled, Input, IN_MODE> {
pub fn bit_is_set(&self) -> bool {
self.periph.read().input_status.bit_is_set()
}
pub fn into_input_high_z(self) -> GpioConfig<Enabled, Input, HighZ> {
self.periph.modify(|_r, w| w.input_mode().high_z());
GpioConfig {
periph: self.periph,
enabled: Enabled,
direction: Input,
mode: HighZ,
}
}
pub fn into_input_pull_down(self) -> GpioConfig<Enabled, Input, PulledLow> {
self.periph.modify(|_r, w| w.input_mode().pull_low());
GpioConfig {
periph: self.periph,
enabled: Enabled,
direction: Input,
mode: PulledLow,
}
}
pub fn into_input_pull_up(self) -> GpioConfig<Enabled, Input, PulledHigh> {
self.periph.modify(|_r, w| w.input_mode().pull_high());
GpioConfig {
periph: self.periph,
enabled: Enabled,
direction: Input,
mode: PulledHigh,
}
}
}
では、これを使うコードがどのようになるか見てみましょう。
/*
* 例 1: 未設定から High-Z 入力へ
*/
let pin: GpioConfig<Disabled, _, _> = get_gpio();
// これはできません。pin が有効化されていないためです!
// pin.into_input_pull_down();
// 次に、ピンを未設定状態から High-Z 入力へ切り替えます
let input_pin = pin.into_enabled_input();
// ピンから読み取ります
let pin_state = input_pin.bit_is_set();
// これはできません。入力ピンにはこのインターフェースがないためです!
// input_pin.set_bit(true);
/*
* 例 2: High-Z 入力からプルダウン入力へ
*/
let pulled_low = input_pin.into_input_pull_down();
let pin_state = pulled_low.bit_is_set();
/*
* 例 3: プルダウン入力から出力へ切り替え、High を設定
*/
let output_pin = pulled_low.into_enabled_output();
output_pin.set_bit(true);
// これはできません。出力ピンにはこのインターフェースがないためです!
// output_pin.into_input_pull_down();
これは確かにピンの状態を保持する便利な方法ですが、なぜこのやり方にするのでしょうか? なぜ GpioConfig 構造体の内部に状態を enum として保持するより優れているのでしょうか?
コンパイル時の機能的安全性
設計上の制約を完全にコンパイル時に強制しているため、実行時コストは発生しません。ピンが入力モードのときに出力モードを設定することは不可能です。代わりに、出力ピンへ変換して状態を順にたどり、その後で出力モードを設定しなければなりません。これにより、関数を実行する前に現在の状態を確認することによる実行時ペナルティはありません。
さらに、これらの状態は型システムによって強制されるため、このインターフェースの利用者が誤る余地はなくなります。不正な状態遷移を行おうとすると、コードはコンパイルされません!