設計思想
いかにシンプルに書くか,いかにプログラムを書かないかに重点を置いていく.
具体例(状態遷移の場合)
仕様
シリアル通信でコマンドを受信し,それを解析するようなこと.
1文字ずつ何らかの文字が入力され,それを文法解析するようなことを考える.
要は単純なインタプリタの骨組みだけを設計.
普通にコンパイラの応用なので,トリッキーなことはしない.
ここでは,状態遷移の概念だけに集中したいので,シリアル通信で流すコマンドの文法定義とか,解析後のこととかは考えない.
実装
あまり本質でないところは,省略している.
static関数にするとか,表をconstにするとか,実際に組むときはその辺をちゃんと入れた方が良い.
プロトタイプ宣言も全部書いてないし.
C言語で書くのが目的ではなく,どういうふうに設計するかの方に重点を置きたい.
/****************************************************
* 遷移条件一覧
* これが状態を遷移させる条件になる.
* 状態遷移図で矢印の近くに書いている条件に対して,
* ユニークなラベルをつけたみたいな感じ.
* 同じ条件が複数の矢印についていたら,それはまとめてラベル
* 1つにすることができる.
****************************************************/
typedef enum {
Condition_null = 0, // nullが入力された
Condition_abc, // 'a'か'b'が入力された
Condition_def, // 'd'が入力された
Condition_err, // その他の入力はエラー
xCondition_num,
} Conditions;
/****************************************************
* 状態一覧
* 状態遷移図でいうところの○で描いてあるアレ.
* 初期状態◎はState_initで,enumの先頭に書いた方が
* いいだろう.
****************************************************/
typedef enum {
State_init = 0,
State_exe1,
State_exe2,
State_end,
xState_num,
} MyState;
/****************************************************
* アクション結果ステータス
* 入力が無くても状態を遷移させなければならないときもある.
* 状態遷移表だけで完結できるほど,世の中そんなに甘くない.
* 本当はやりたくないけど,苦肉の策.
****************************************************/
typedef enum {
ActionStatus_wait, // 通常
ActionStatus_next, // 無条件遷移
} ActionStatus;
/****************************************************
* 状態遷移表
* 可能な状態遷移をすべて記述する.
* 状態遷移図で言うところの矢印.
* 第1添え字に現在の状態,第2添え字に遷移条件を入れると,
* 次の状態が得られる.
****************************************************/
MyState StateTable[xState_num][xCondition_num] = {
/* null abc def err */
[State_init] = {State_init, State_exe1, State_init, State_init},
[State_exe1] = {State_init, State_exe1, State_exe2, State_init},
[State_exe2] = {State_init, State_end, State_exe2, State_init},
[State_end] = {State_init, State_init, State_init, State_init},
};
/****************************************************
* 各状態のアクション(プロトタイプ宣言)
* アクション関数は,ActionStatusを返す.
* これは,特殊な遷移が必要なときに利用する.
****************************************************/
ActionStatus Action_init(uint8_t c);
ActionStatus Action_exe(uint8_t c);
ActionStatus Action_end(uint8_t c);
/****************************************************
* 各状態のアクション表
* アクション関数は関数ポインタの配列にしておくことで,現在の状態
* から直接実行できるようになる.
****************************************************/
ActionStatus (*Action[xState_num])(uint8_t c) = {
[State_init] = Action_init,
[State_exe1] = Action_exe,
[State_exe2] = Action_exe, // exe1と状態は違うが,やりたいアクションは同じ
[State_end] = Action_end,
};
/****************************************************
* 状態遷移実行関数
* 今回は1文字入力するたびに,この関数を実行することを
* 想定している.引数は入力された1文字.
****************************************************/
void run(uint8_t c) {
static MyState currentState = State_init; // 初期状態は明示的に指定しておく.
Conditions condition; // 遷移条件
ActionStatus status; // アクション結果ステータス
// 入力文字から遷移条件を決定する
condition = getCondition(c);
do {
// 状態遷移する
currentState = StateTable[currentState][condition];
// 現在の状態でアクション
status = Action[currentState](c);
// ActionStatus_nextなら,無条件で状態遷移
} while(status == ActionStatus_next);
}
/****************************************************
* 遷移条件取得関数
* 引数で与えられた文字から,遷移のための条件を決定する.
* 文法定義に合わせて,いろいろやらないといけないかもしれない.
****************************************************/
Conditions getCondition(uint8_t c) {
Conditions res = Condition_null;
if(c == 0) {
res = Condition_null;
}
else {
switch(c) {
case 'a': res = Condition_abc; break;
case 'b': res = Condition_abc; break;
case 'd': res = Condition_def; break;
default: res = Condition_err; break; // その他の文字はエラー
}
}
return res;
}
/****************************************************
* アクション(初期状態)
****************************************************/
ActionStatus Action_init(uint8_t c) {
/* 処理 */
return ActionStatus_wait;
}
/****************************************************
* アクション(実行状態)
****************************************************/
ActionStatus Action_exe(uint8_t c) {
/* 処理 */
return ActionStatus_wait;
}
/****************************************************
* アクション(終了状態)
****************************************************/
ActionStatus Action_end(uint8_t c) {
/* 処理 */
return ActionStatus_next;
}
メリットとデメリット
何でもかんでもこの手法で改良されるほど,世界はそんなに単純ではない.メリットとデメリットのトレードオフが常に付きまとう.
メリット
状態遷移の原理と実装の分離
状態遷移の具体的な実装(状態遷移表)と状態遷移の原理(状態遷移実行関数)が分離している.このことにより,状態遷移実行関数の構造が数行で表現され,ものすごく単純になる(デバッグしやすい,バグを出しにくい).状態遷移表はただのデータであるため,その変更は状態遷移実行関数にはまったく影響せず,バグの原因の分離にも貢献する.というか,バグの原因は大抵の場合,状態遷移表とかテーブル定義のところしかない.それでもダメな場合は,そもそもこの仕組みで状態遷移を実装できない可能性が高い.
状態遷移図との対応
状態遷移図の各状態,矢印,遷移条件,各状態のアクションがすべて明確に分離される.分離されると,各ユニットは小さく単純にできるので,ソースを見て,人間が理解しやすくなる.いや,それよりも,状態遷移図から状態遷移表を自動生成で簡単に作れるようになることの方が重要かもしれない.プログラムを自動生成するのはかなり大変だけど,表を自動生成するだけなら,一気に難易度が下がる.人間が手作業で表なんか作ってたら,どうせ間違うだけだし,時間の無駄なんだから,自動生成器は作っといた方がいいかもね.
デメリット
遷移条件の数が増えるとめんどい
遷移条件の数が増えると,状態遷移表が大きくなるので,作るのがめんどくなってくる.しかも,通常,すべての状態においてすべての条件を使って遷移するわけではない.ほとんどは状態遷移しないことになるだろう.状態遷移をしないように表を埋めていく作業は中途半端に単純でつまらない作業になりがちだ.if文なら遷移する条件しか書く必要は無いので,それに比べると,大変手間がかかる作業である.しかしながら,条件漏れの発見や,その対処もしやすいので,この辺はif文を使う手法とのトレードオフになる.状態遷移表を自動生成する手法についても考えたほうが良いのかもしれない.
入力が増えるとめんどい
想定されるすべての入力から,状態遷移の条件を一意に決定する必要上,入力が増えていくとかなりめんどい.条件を決定するための優先順位を決めなければならない.しかも,現在の状態によっては,その優先順位が変化してしまうことも十分考えられる.getCondition関数に現在の状態を入力するなど,工夫が必要.
補足
状態を関数ポインタで表現?
アクション関数と状態が全単射であれば,すべての状態を関数ポインタで表現でき,各状態のアクション表を省くことができる・・・・・・ような気がするが気のせいである.現在の状態を状態遷移表の第1添え字として与える都合上,状態遷移表に関数ポインタを値として入れにくい.ポインタをハンドルとして使うことで,各状態を一意に表現できるのは確かに面白いのだが.
アクションの引数
ここでは,アクションで入力された1文字をそのまま使うことを想定している.必要なら文字列にしたり,あるいは,構造体にしたり,調整が必要だろう.
指定回数カウント後の遷移
例えば,'a'が4回連続入力された後,'d'が入力されたときに特定の状態Sに遷移させたい.この場合は,'a'が入力されるたびに,それぞれ別の状態に遷移させる.初期状態0から始めて,最初の'a'入力で状態1,直後の'a'入力で状態2,・・・'a'以外が入力されたら初期状態0に戻るとか.状態4まで達してさらに'a'が入力されたらどうするかは仕様しだい.状態1に戻せば4の倍数のときのみ'd'の入力で状態Sに遷移させる可能性を作れるし,状態4のまま待機させて'd'の入力を待ってから状態Sに遷移させることもできる.しかし,この手法が使えるのは片手で数えられるぐらいまでで,100とか200とかになってくると,状態遷移表がえらいことになるので,やめといた方が良い.自動生成を使ったとしても,そもそも100も200も状態があるような状態遷移図を描かいて,自動生成器に与えないといけない.自動生成器に入力する作業をどこまで単純化できるかは分からないが,少なくともその図をイメージするのは人間の仕事だ.
こんなときは,ActionStatusに特殊なステータスActionStatus_a100とかを追加して,run関数内で何とかする・・・・・・手法も無くはないが,状態遷移表で状態遷移を管理できなくなる.意味的には遷移条件取得関数内で何とかすべきなのだろうか.その場合,いつカウントしたり,カウンタをリセットしたりするか考える必要がある.
もしかすると,ちゃんと字句解析してから遷移条件を決めた方が良いのかもしれない.
無条件で遷移
State_end状態では,アクション関数はStatus_nextを返すので,無条件で状態遷移が発生し,強制的にState_init状態に戻される.これをやらない場合,現在の状態がState_end状態なら,次に文字'a'が来てrun関数を実行すると,State_init状態に遷移したところで止まってしまう.本来ならState_exe1状態に遷移して欲しいのにそうはならない.また,State_end状態からState_exe1状態に遷移させると,初期化アクションを通らなくなるので,めんどい.State_end状態のアクションに初期化シーケンスを含めると,状態遷移表のState_endとState_initで一部の遷移先を常に同じ値にしないといけなくなり,メンテナンス性が低下する.初期化されることが,状態遷移表から不明確になってしまう問題もある.
あるいは,状態遷移表の値を遷移する状態と無条件遷移するかどうかのフラグを組み合わせた,構造体にした方が良いのだろうか?それはそれで,表としての見やすさが損なわれる気がする.
この辺,もうちょっとスマートに書きたいが,良い方法が思いつかない.
状態遷移のタイミング
やりたいアクションによっては,先に現在の状態でアクションを実行してから,状態を遷移させたいかもしれない.アクションと遷移の順番を入れ替えると,アクションの内容も変わってくるので,作る前にちゃんと考えなければならない.動的に順番を変更するとか,実現可能であっても,わけがわからなくなるだけなので,やめた方が良い.
まとめ
メインとなる状態遷移実行関数は,状態を遷移させることだけを抽象化することで,実質10行程度になった.これなら1画面に収まってお釣りが来るぐらいのコンパクトさで,人間も理解しやすくなる.
状態遷移表は現在の状態から,どんな条件が来たら,どの状態へ遷移するのか,明確になった.小さいものなら,人間の手で作ってもたかがしれてるし,すべての状態を追っていけるのでデバッグ自体も難しくはない.ただのテーブルデータであるから,状態遷移図からの自動生成することも,かなり難易度が低い.
人間は複雑なものを容易に理解することはできない.単純なものほど理解しやすくなる.単純なことは単純に,複雑なことは単純なことの組み合わせで書くのをこれからも目指していく.