ソフトウェアにおける振る舞い設計
このドキュメントについて
「仕様変更でバグが大量発生…」「あのモジュールの使い方が謎…」
ソフトウェア開発の現場では、そんな悲鳴がよく聞こえてきます。このドキュメントは、そうした問題の根っこにあるソフトウェアの「振る舞い」の設計にスポットライトを当て、もっと楽に、もっと品質良く開発を進めるための考え方とテクニックをまとめたものです。
ちょっとカタい話も出てきますが、具体的な例を交えながら、現場で「使える」知識を目指して解説していきます。このガイドが、皆さんの日々の開発を少しでも明るくする手助けになれば嬉しいです。
Part 1:振る舞い設計のキホン、「状態」を使いこなそう!
ソフトウェアの振る舞いを考える上で、避けては通れないのが「状態」という概念です。なんとなく使っているこの言葉、まずはチームみんなで共通の理解を持つところから始めましょう。
1. 振る舞いを分解する3つの道具
1.1. 状態(State):システムの「今」を表すスナップショット
「状態」とは、システムの「今この瞬間」の状況のことです。それは、システムの様々な「特徴」の値の組み合わせで決まります。
-
例:音楽プレイヤー
- 特徴:「再生状況」「音量」「リピート設定」など
- ある瞬間の状態:「再生中」「音量: 20」「1曲リピート」
このように、システムがどんな特徴を持っていて、その値が今どうなっているのかを捉えるのが「状態」です。
1.2. 状態空間(State Space):振る舞いの全体地図
システムが取りうる全ての状態の集まりが「状態空間」です。いわば、これから作るソフトウェアの振る舞いの全体像を示す地図のようなもの。
でも、この地図はとんでもなく広大です。特徴が少し増えるだけで、状態の組み合わせは爆発的に増え、あっという間に天文学的な数になってしまいます。
じゃあ、どうやってこの広大な地図を把握するの?
答えは、「同じように扱える状態をグルーピングして『領域』で考える」 ことです。地図を県や市で区切るように、状態空間に境界線を引いていくイメージです。
実践!状態空間を「領域」で捉える4ステップ
この「領域で捉える」という考え方を、シンプルな「音楽プレイヤー」を例に、具体的なステップで見ていきましょう。まるで探偵になったつもりで、システムの振る舞いの謎を解き明かすような感覚で進めてみてくださいね。
ステップ1:システムの特徴を洗い出す(状態変数を決める)
まず、あなたのシステムがどんな「顔」を持っているか、どんな「情報」を持っているかを全部リストアップします。これが、システムの振る舞いを決める「軸」になります。専門用語では「状態変数」と呼びます。
-
音楽プレイヤーの例:
-
再生状態: 今、曲が「再生中」なのか、「一時停止」なのか、「停止」しているのか? -
音量: 音の大きさは「0(無音)」から「100(最大)」まで、どのくらい? -
リピート設定: 曲を「リピートなし」で聞くのか、「1曲だけリピート」するのか、「全曲リピート」するのか? -
シャッフル設定: 曲順は「シャッフルオン」なのか、「シャッフルオフ」なのか?
-
これらの特徴が、これから探検する状態空間の「地図の目印」になります。
ステップ2:各特徴の「重要な点・領域」を見つける
次に、ステップ1で見つけたそれぞれの特徴(軸)について、「ここが変わると、システムの動きも変わりそうだな」という特別なポイントや範囲を見つけ出します。そして、同じような動きをする範囲を「領域」としてまとめます。
-
音量(0~100) の場合、こんな風に考えられます:-
0:これは「ミュート(無音)」という、特別な意味を持つ点ですね。音量が0の時と1の時では、プレイヤーの振る舞いが違うかもしれません。 -
100:これは「最大音量」という、これ以上大きくできない境界の点です。 -
1~99:この範囲は「通常の音量」として、だいたい同じように扱える領域だと考えられます。 - → つまり、
音量という軸は、{ 0, 1~99, 100 } という3つの「意味のある領域」に分割できるわけです。
-
-
再生状態の場合:- 「再生中」「一時停止」「停止」は、それぞれが明確に異なる振る舞いを持つ、独立した領域ですね。
このステップで、広大な状態空間に「意味のある区切り」を入れていくイメージです。
ステップ3:重要な「状態領域」を組み合わせる
ステップ2で見つけた各特徴の「意味のある領域」を組み合わせて、特に「この組み合わせは、システムの振る舞いを考える上で重要だぞ!」という「状態領域」を定義します。すべての組み合わせを考えるのは大変なので、システムの動きに大きな影響を与えそうな組み合わせに絞り込むのがコツです。
-
音楽プレイヤーの例:
- 領域A:
再生状態が「停止」のとき(プレイヤーが完全に止まっている状態) - 領域B:
再生状態が「一時停止」のとき(再生を中断している状態) - 領域C:
再生状態が「再生中」で、かつ音量が「0」のとき(音は出てないけど、実は再生している「ミュート再生」の状態) - 領域D:
再生状態が「再生中」で、かつ音量が「1~99」のとき(普通に音が出ている「通常再生」の状態) - 領域E:
再生状態が「再生中」で、かつリピート設定が「1曲リピート」のとき(特定の曲を繰り返し聞いている状態)
- 領域A:
このように、振る舞いを考える上で特に重要な「スポット」を絞り込んでいくイメージです。
ステップ4:各「状態領域」での振る舞いを定義する
最後に、ステップ3で定義したそれぞれの「状態領域」で、特定のイベント(例えば「再生/一時停止ボタンを押した」)が起きたときに、システムがどう動くべきか(どんな状態に変わるか、どんな結果を出すか)を具体的に定義します。
-
例:「再生/一時停止ボタンを押した」というイベントが起きたら?
-
もし領域A(停止中)にいた場合:
- 「再生中」の状態に変わります。音量は、以前の音量に戻るか、デフォルトの音量になるかもしれません。
-
もし領域D(通常再生中)にいた場合:
- 「一時停止」の状態に変わります。
-
もし領域A(停止中)にいた場合:
この「もし~だったら、こうなる」というルールを明確にすることで、私たちは無限に見える状態の組み合わせを、有限で管理可能な「振る舞いのパターン」に落とし込むことができます。
このプロセスを通じて、私たちは「もしミュート再生中に一時停止したら、次に再生したときもミュートのままか?」といった、口頭や文章だけでは見逃しがちな仕様の曖昧さや考慮漏れを、体系的に発見することができるのです。
1.3. 状態遷移(State Transition):状態から状態への変化
ある状態から別の状態へ変化すること、それが「状態遷移」です。そして、そのきっかけとなるのが「入力イベント」です。
- 事前条件: 遷移前の状態
- 事後条件: 遷移後の状態
ソフトウェアの振る舞いを実装するとは、突き詰めれば「(事前条件, 入力イベント)→ 事後条件」というルールの集まり(状態遷移関数)をコードにすることなのです。
2. 仕様をキッチリ書く技術
「いい感じにしといて」では、良いソフトウェアは作れません。ここでは、誰が読んでも誤解しない、厳密な仕様の書き方を学びます。
2.1. 状態を「ルール」で表現する(内包的定義)
無数の状態を一つ一つ書き出すのは不可能。そこで、システムの特徴を「状態変数」として扱い、状態を論理式で表現します。これを「内包的定義」と呼びます。
ちょっと聞き慣れない言葉かもしれませんが、実は普段から使っている考え方なんです。その反対の「外延的定義」から見ていきましょう。
外延的定義(Extensional Definition)とは?
これは、あるグループ(集合)に属するものを、全部リストアップして示す方法です。
-
例1:10以下の偶数
{ 2, 4, 6, 8, 10 } -
例2:信号機の色
{ 赤, 黄, 青 }
このように、メンバーを一つずつ指し示すのが外延的定義です。シンプルで分かりやすいですよね。
内包的定義(Intensional Definition)とは?
これに対して「内包的定義」は、グループに属するものを**「どんなルール(条件)を満たしているか」で示す**方法です。メンバーを直接リストアップするのではなく、そのメンバーが持っている「共通の性質」や「満たすべき条件」を言葉や式で表現します。
-
例1:10以下の偶数
{ x | x は整数で、x は 10以下、かつ x は 2で割り切れる } -
例2:信号機の色
{ x | x は交通整理のために使われる光の色である }
どうでしょうか? リストアップする代わりに、その特徴を説明していますよね。
なぜソフトウェア開発で「内包的定義」が重要なのか?
外延的定義は分かりやすいですが、ソフトウェアの状態を定義する際にはすぐに限界が来てしまいます。
例えば、先ほどの「エンジン制御システム」の例で考えてみましょう。
-
v_mode: {normal,turbo} -
v_power: {weak,strong,maximum} -
v_temp: {low,middle,high}
もし「エンジンが高性能モードである状態」を定義したいとして、これを外延的定義で書くとどうなるでしょう?
外延的定義の例(非現実的):
{ (mode: turbo, power: strong, temp: low), (mode: turbo, power: strong, temp: middle), (mode: turbo, power: strong, temp: high), (mode: turbo, power: maximum, temp: low), (mode: turbo, power: maximum, temp: middle), (mode: turbo, power: maximum, temp: high) }
このように、考えられる組み合わせをすべて書き出すことになります。もし状態変数がもっと増えたり、それぞれの変数が取りうる値がもっと多かったりしたら、このリストはあっという間に膨大な量になり、書ききれなくなってしまいます。しかも、一つでも書き漏らしたら、それが仕様のバグにつながります。
そこで「内包的定義」の出番です!
内包的定義の例(現実的):
{ x | x.v_mode = turbo AND (x.v_power = strong OR x.v_power = maximum) }
このように、状態変数を使い、論理式(ルール)で表現することで、膨大な数の「高性能モード」の状態を、たった1行のシンプルな記述で定義できます。
内包的定義のメリットまとめ
- コンパクトさ: 膨大な状態を、短い論理式で表現できるため、仕様書がスッキリします。
- 網羅性: ルールで定義するので、そのルールに合致するすべての状態が自動的に含まれます。書き漏らしによるバグを防ぎやすくなります。
- 明確さ: 「なぜその状態なのか」という理由がルールとして明確になるため、チーム内の認識合わせがしやすくなります。
- 変更への強さ: 新しい状態が追加されても、ルールが変わらなければ定義を修正する必要がありません。
この「内包的定義」を使いこなすことが、複雑なソフトウェアの振る舞いを正確に、そして効率的に設計するための強力な武器となるのです。
2.2. 遷移のルールをより詳しく:ガードとアクション
「状態遷移」は、ただ起こるわけではありません。特定の条件が揃ったときにだけ発生し、その結果として何らかの処理が行われます。これを「ガード」と「アクション」で表現します。
- ガード条件: 「この条件が満たされたときだけ、この遷移を許可する」という追加の条件式です。まるで門番のように、遷移を許可するかどうかを判断します。
- アクション: 遷移するときに実行される処理です。状態変数の更新や、他のモジュールへの出力イベント発行などがこれにあたります。これは、遷移が許可された後に「実際に何が起こるか」を示すものです。
これらを使うことで、より複雑で現実的な振る舞いを正確に記述できます。
【実例:ダウンロードマネージャーの「再開」ボタン】
ダウンロードマネージャーの「ダウンロード再開」ボタンを例に考えてみましょう。
- 現在の状態: ダウンロードが「一時停止中」
- 入力イベント: 「再開」ボタンが押された
このとき、ただボタンが押されただけでダウンロードが再開されるわけではありませんよね?
-
ガード条件:
[ネットワーク接続がある]- もしネットワークに繋がっていなければ、ダウンロードは再開できません。この「ネットワーク接続がある」という条件がガード条件です。この条件が
trueの時だけ、次のアクションに進めます。
- もしネットワークに繋がっていなければ、ダウンロードは再開できません。この「ネットワーク接続がある」という条件がガード条件です。この条件が
-
アクション:
/ ダウンロードを再開する- ガード条件が
trueであれば、実際にダウンロード処理を再開する、というアクションが実行されます。
- ガード条件が
このように、ガード条件とアクションを組み合わせることで、「どんな状況で」「何が起きたら」「どう動くか」を、より細かく、正確に表現できるようになります。
3. 状態ベース仕様記述:曖昧さをなくす実践テクニック
状態遷移の考え方をベースに、機能仕様を形式的に記述するアプローチ、それが「状態ベース仕様記述」です。この手法は、自然言語の曖昧さを排し、ロジックに基づいた厳密な仕様定義を可能にします。
3.1. 仕様の分解:EPQ/EPO
APIの仕様を考えるとき、以下の要素に分解すると、抜け漏れが格段に減ります。
- EPQ (状態遷移): Event(イベント), Precondition(事前条件), Postqualification(事後条件)
- EPO (出力): Event(イベント), Precondition(事前条件), Output(出力)
3.2. 実例で学ぶAPI仕様の書き方
架空のエンジン制御API ton() (ターボON)を例に見てみましょう。
API:
void ton()事前条件:
v_mode = normalAND (v_power = weakORv_power = strong)
事後条件:v_mode' = turbo
このように書けば、「ton()はノーマルモードの時しか使えない」「使うとターボモードになる」というルールが、誰にとっても明確になります。これが曖昧さをなくし、テストを容易にする鍵です。
3.3. 契約による設計(DbC):APIの「お約束」を決めよう
APIを使う上で非常に重要な考え方が「契約による設計(Design by Contract)」です。これは、APIを呼び出す側と、APIを提供する側の間で、きっちりとした「お約束」を決めておこう、という考え方です。
- 呼び出し側(あなた)の責任: APIを正しく使うこと。
- API(提供側)の責任: 正しく使われたら、必ず期待通りの結果を返すこと。
この「お約束」を明確にするのが、事前条件と事後条件です。
事前条件(Precondition):APIを呼び出す前の「お約束」
これは、「APIを呼び出す前に、呼び出し側が必ず守らなければならないルール」のことです。API側は「このルールが守られていないと、私の仕事は保証できません!」と宣言しているわけですね。
どんなことを設定するの?
-
引数の妥当性チェック:
-
nullじゃないか? - 数値が想定の範囲内か?(例:
ageが0以上か) - 文字列が空っぽじゃないか?
-
-
オブジェクトの状態チェック:
- ファイルはちゃんと「開かれている」状態か? (
file.write()) - プレイヤーに「再生する曲がセットされている」状態か? (
player.play())
- ファイルはちゃんと「開かれている」状態か? (
【重要】呼び出し側が守れなかったらどうなる?
ここで少し面白いのが、事前条件には2つのタイプがあることです。
-
① 要求型の事前条件 (Demanding)
- 考え方: 「これは呼び出し側のバグでしょ!ちゃんと直して!」というスタイル。
- 違反したら: **例外(Exception)**を投げます。プログラムがクラッシュすることもある、厳しいペナルティです。
-
使い所: 引数の
nullチェックなど、呼び出し側が注意すれば100%防げる問題に使います。
-
② 保護型の事前条件 (Tolerant)
- 考え方: 「これは仕方ないよね。API側で面倒見るよ」という親切なスタイル。
-
違反したら: 例外ではなく、**戻り値(
falseやエラーコードなど)**で「ごめん、今回はうまくいかなかった」と穏やかに伝えます。 - 使い所: ネットワーク接続がない、ディスクの空き容量がないなど、呼び出し側ではどうしようもない環境要因に使います。
この2つを使い分けるだけで、APIは格段に使いやすく、そして堅牢になります。
事後条件(Postcondition):APIが実行した後の「お約束」
これは、「事前条件がきちんと守られていれば、API側が必ず保証する結果」のことです。「お約束通りに使ってくれたなら、この結果は保証します!」という力強い宣言です。
どんなことを設定するの?
-
戻り値の意味:
- ユーザーが見つからなかったら
nullを返す、といった戻り値のルールを明確にします。
- ユーザーが見つからなかったら
-
オブジェクトの状態変化:
-
list.add(item)を実行したら、リストの要素数が必ず1増える、といった状態の変化を保証します。
-
-
外部への出力(副作用):
-
config.save()を実行したら、設定内容が必ずファイルに書き込まれていることを保証します。
-
この「契約」をしっかり意識してAPIを設計することで、バグが減り、仕様が明確になり、チーム開発がもっとスムーズに進むようになります。
Part 2:コンポーネント設計と振る舞い
良いソフトウェアは、良い「部品(コンポーネント)」から作られます。ここでは、変更に強く、再利用しやすいコンポーネントを作るための振る舞い設計に焦点を当てます。
1. なぜコンポーネントの「振る舞い」が大事なのか
コンポーネントの「構造」だけでなく、「振る舞い」をしっかり設計しないと、部品同士が複雑に絡み合い、修正が困難な「スパゲッティコード」が出来上がってしまいます。これこそが、デグレードや開発非効率化の元凶です。
2. 「ソト」と「ナカ」の振る舞いを分ける
良いコンポーネント設計の鍵は、振る舞いを「ソト向け」と「ナカ向け」にきっちり分けることです。
-
インターフェースの振る舞い (ソト向け): コンポーネントが外部に「こう使ってください」と約束する公式な振る舞いです。APIの仕様や、APIを呼び出す順番のルール(プロトコル)がこれにあたります。一度決めたら、軽々しく変えてはいけません。
-
モジュールの振る舞い (ナカ向け): コンポーネント内部の実装詳細です。こちらは、インターフェースの約束さえ守っていれば、いつでも自由に改善(リファクタリング)できます。
この分離により、コンポーネントは「ブラックボックス」として扱えるようになり、システムの変更が容易になります。
プロトコル:APIの「ものがたり」を定義する
APIを単体で定義するだけでは不十分な場合があります。「A()を呼んだら、次は必ずB()を呼ぶ」といった、一連のAPI呼び出しの「お約束」、それがプロトコルです。
プロトコルは、状態遷移図を使って可視化することで、そのコンポーネントの正しい利用方法をチーム全体で共有し、誤用を防ぐ強力なツールとなります。
さあ、実践してみよう!
このドキュメントで紹介した考え方は、実際に手を動かしてこそ身につきます。付属のハンズオン資料には、シーケンス図からステートマシン図を作成したり、API仕様を定義したりする具体的な演習が用意されています。
ぜひこれらの演習にチャレンジして、曖昧さのない、高品質な「振る舞い」を設計するスキルをあなたのものにしてください。それが、あなたのチームの開発を次のレベルへと引き上げる、確かな一歩となるはずです。
Discussion