MiceAdventCalendar2019 2日目の鯉住さんの記事マイクロマウスで考えるシステム設計に下記のようなつぶやきがあったので、設計大好きな人間として記事を書いてみました。
これを読んでくれている誰かに刺さってくれないかなぁ。もっとソフト設計の記事ふえないかなぁ。
システム・ソフトウェアの設計において、依存という考え方は非常に強力なツールとなりますが、(マイクロマウス界隈では)あまり浸透していないようなので、紹介します。
抽象的な話が続きますが、意識高い系でも、スピリチュアルなやつでもありません。後ろの方に行くに従って具体例を挙げていきます。
依存とは何か
「依存」という言葉を聞くと、ネガティブなイメージが浮かぶと思います。
一応、日本語としての定義を確認してみましょう。
依存(いそん) の意味
[名](スル)《「いぞん」とも》他に頼って存在、または生活すること。「会の運営を寄付金に依存する」「依存心」
goo 辞書より引用
例文からもなんとなく不穏なイメージを受けてしまいますね。
英語では dependency です。depend on とか習った気がします。
システムやソフトウェアにおいては、構成単位の間で依存を考えます。 構成単位は関数・クラス・ファイル・モジュール・コンポーネント・ライブラリ・アプリケーション・サービスなどです。
やばそうな依存とやばくない依存
依存はネガティブなイメージが強いですが、当たり前に存在している概念でもあります。 何個か例を出してみました。
- ヒトは酸素に依存する
- 物理学は数学に依存する
- あるシステムαの管理はAさんに依存する
- C言語で書かれたプログラムの挙動はコンパイラの実装に依存する
依存は、UMLのオブジェクト図を使って以下のように表します。矢印の根本が、矢印の向かう先に依存している、という図です。
どうなるとやばそうか、考えてみましょう。
- ヒトは酸素に依存する
- 酸素が大気中から無くなる事は無いだろう(やばくない)
- 物理学は数学に依存する
- 数学の理論が崩壊する事は無いだろう(やばくない)
- あるシステムαの管理はAさんに依存する
- Aさんは病気やけがをするかもしれないし、会社を辞めるかもしれない(やばそう)
- C言語で書かれたプログラムの挙動はコンパイラの実装に依存する
- プログラムを他のコンパイラを使うシステムに移植するかもしれない(やばそう)
やばそうなパターンでも、万事が現状のまま進むのであれば、何の問題もないのです。
- Aさんは不老不死であるし、会社に絶対の忠誠を誓う
- プログラムは未来永劫このコンパイラでしかコンパイルしない
やばそうじゃない、やばそう、の境界は、依存先が**「安定」**しているかどうかです。
つまり、状況によっても変わって来ます。
例:ヒトは酸素に依存する。(今はダイビングで潜水中)
設計と依存
マイクロマウス用語を出していきます。
良い設計とは何でしょうか。議論の余地は残ると思いますが、この記事では、要求を満たしつつ、コスト(主に時間)を下げる事ができれば良い設計になるとします。
要求を満たす方法については、冒頭で触れた、鯉住さんの記事マイクロマウスで考えるシステム設計を見てみましょう。
という事で、コストについて考えてみます。何が起きるとコストが高くなるでしょうか。実例を思い返してみましょう。
- 探索のアルゴリズムをいじりたいが、足立法に依存しすぎてて触りたくない
- 制御をいじったら探索がバグるようになった
- 新作でマイコンを変えたので、ほとんどのソースコードを修正する必要があった
ドキッとする人がほとんどじゃないでしょうか。私も全部踏んだことがある気がします。
共通している事は、何かを変更した時に、関係なさそうな所を修正する必要が出てきたという事です。 ただ作っているという状態は全体の作業の中ではごく一部で、変更をしている事がほとんどです。
つまり、コストを下げるためには、変更時の影響範囲が小さくなるように設計する必要があります。
さて、上の例を依存で書いてみましょう。
書いていなかったオブジェクトが一杯出てきましたが、ニュアンスは伝わると思います。依存というのは、当然、伝搬します。 一番左の例で言えば、探索動作は足立法に依存し、足立法は歩数マップ生成に依存します。 探索動作は、間に足立法をはさむ形で、歩数マップ生成に依存するという事ですね。
あるオブジェクトを変更した場合、そのオブジェクトに依存する上流は影響を受ける可能性があります。
上の図で、赤色に塗ったオブジェクトの上流を見てみましょう。・・・悲惨ですね。
つまり、依存関係が多ければ多いほど、影響範囲が広がりコストが増大します。
依存をコントロールする
依存が多い設計はコストが増大する事がわかりました。では、次のステップとして、依存を減らす方法を考えてみましょう。
依存の伝搬
上の図をもう一度出します。
依存が伝播する原因は2つです。
- ネストが深い
- 左、中央の例を見ると、ネストが深いと依存範囲が大きくなる事がわかります
- 多くのオブジェクトから参照されている
- 右の例です。読んで字のごとくですね
依存されて良いオブジェクトと、依存に気を付けるべきオブジェクト
例えば、math.hに依存する事に何か問題があるでしょうか?
問題ありません。安定しているからです。
新興言語であったり、外部ライブラリを使うとこのような事が起きる事もありますが・・・。
いままでまうすのうえでうごいていたさんかくかんすう pic.twitter.com/osseOtDh4X
— sshun (@sshun_robot) October 23, 2019
安定とは、次のような状態を指します。
- 仕様が確定している
- バグがない
逆に、これらに該当しない物については、依存範囲が広くならないように気を付けるべきです。
といっても、これだけの指標では、自分で作ったプログラムはすべて依存に気を付けるべきオブジェクトになってしまいます。
具体的には、**変更したい動機がある箇所(=変更しまくる事になる!)**について、依存に気を付けて設計しましょう。
やっと設計らしい話が出てきました。
制作物に対して、注力したい箇所の依存をコントロールする事で、効率よく開発を行う事ができます。
- アルゴリズムを色々試したい!
- 制御を色々試したい!
- マイコンを色々変えてみたい!
まあ、ここで挙げたような要素は結局全部いじりたいので、全体的に依存を意識する事になるのですが。
マイクロマウスに関しては競技規定が安定しているため、競技規定には依存しまくって大丈夫です。マイクロマウス競技の良いところですね。
抽象に依存する
依存に気を付ける、という微妙な表現で誤魔化してきましたが、
具体的に依存をコントロールする方法を紹介していきます。
それは、抽象に依存する=Inversion of Control という方法です。
SOLID原則のDでもあります。
まず、今まで挙げていた例を抽象に依存する形式に書き直してみます。
歩数マップ生成を変更した時の影響範囲を見比べてみましょう。 ひとまず矢印の種類や記号は気にせず、矢印の方向だけを見て依存を上流へ辿ります。 変更前は探索動作、最短動作が歩数マップ生成に依存しているのに対し、変更後は足立法と最短用独自アルゴリズムまでの依存で済んでいます。
青色でマークしてある、インタフェースが追加されています。これが、抽象と呼んでいる物です。
インタフェースとは、使われ方の仕様です。
図の意味を説明しておきます。詳しく知りたければUMLを勉強しましょう。
- 黒塗り矢印
- 依存
- 矢印の向かう先に依存する
- 白抜き矢印
- 継承
- 矢印の向かう先のインタフェースを実装する
- 矢印根本の白抜きひし形
- 集約
- 矢印の向かう先を所持する
(UMLを知っている人からすると、依存の種類は?(dispatch, creates, etc..)、クラスのメンバは?集約の多重度は?などが気になると思いますが、省略しています。上の要素だけでサクッと依存に注目してモデリングできるのでオススメです。何かの本で紹介されていたサブセットだったと思うのですが、出典を見つけられませんでした。)
抽象に依存する(コード例)
そろそろより具体的にイメージできるように、コードでの実例を出していきます。
ここまでの流れで、オブジェクト指向言語の話かあ、自分はC言語使ってるし関係ないかなあ、と思っている方も多いと思うので、C言語で書いて行こうと思います。
本題じゃない所はテキトーに書くので、補完しながら読んでください。あと、最短は省略します。
コードを図と比較できるように、再掲しておきます。
まずは、抽象に依存する前のバージョンです。
// main.c
#include "search.h"
int main(void){
// 初期化とかメニュー選択とか
// ...
{
// 探索の実行
search();
}
while(1);
return 0;
}
// search.c(探索)
#include "adachi.h"
void search(void){
t_agent agent;
agent_init(&agent);
maze_init(&maze);
while(1) {
// 壁の取得と更新(省略)
// 足立法の呼び出し
// agent: 自身の座標と方角、maze: 迷路データ、next_dir: 足立法が返す、次に行く方角
t_direction next_dir = DIRECTION_INVALID;
t_adachi_result adachi_res = adachi_next_dir(&agent, &maze, &next_dir);
// 足立法の結果に基づいてエラー処理したり、移動したり、座標更新したり(省略)
}
}
// adachi.h(足立法)
// 足立法の結果
typedef enum {
ADACHI_RESULT_OK,
ADACHI_RESULT_FINISHED,
ADACHI_RESULT_ERROR,
} t_adachi_result;
// 足立法を実行する
t_adachi_result adachi_next_dir(const t_agent *agent, const t_maze *maze, t_direction *next_dir);
// adachi.c(足立法)
#include "adachi.h"
#include "potential_map.h"
t_adachi_result adachi_next_dir(const t_agent *agent, const t_maze *maze, t_direction *next_dir) {
potential_map_update(maze);
// 歩数マップと迷路データから方向を計算
*next_dir = ...;
return ADACHI_RESULT_OK;
}
// potential_map.c/h(歩数マップ生成)
int potential_map_update(const t_maze *maze) {
// 迷路データから歩数マップ生成を生成
}
探索動作(search())から、直接足立法の実装(adachi_next_dir())を呼んでいますね。これが悪しき依存の実体です。 さらに、adachi_next_dir()は、歩数マップ生成に依存しているため、探索動作(search())が足立法と歩数マップ生成(potential_map_update())に依存してしまっています。
この探索関数を、抽象に依存するように書き直してみます。
// main.c
#include "search.h"
#include "adachi.h"
int main(void){
// 初期化とかメニュー選択とか
// ...
{
// 探索の構築
t_search_alg search_alg = { adachi_next_dir };
// 探索の実行(集約)
search(&search_alg);
}
while(1);
return 0;
}
// search.h
#include "agent.h"
#include "direction.h"
#include "maze.h"
// 探索の結果
typedef enum {
SEARCH_RESULT_OK,
SEARCH_RESULT_FINISHED,
SEARCH_RESULT_ERROR,
}t_seatch_alg_result;
// 探索アルゴリズムを実行する関数へのポインタ
typedef t_search_alg_result (*t_search_alg_func)(t_agent *agent,
const t_maze *maze, t_direction *next_dir);
// 探索アルゴリズムのインタフェース
typedef struct {
t_search_alg_func calc_next_dir;
} t_search_alg;
// search.c(探索)
#include "search.h"
void search(const t_search_alg *alg){
t_agent agent;
agent_init(&agent);
maze_init(&maze);
while(1) {
// 壁の取得と更新(省略)
// 探索アルゴリズムの呼び出し
// agent: 自身の座標と方角、maze: 迷路データ、next_dir: 探索アルゴリズムが返す、次に行く方角
t_direction next_dir = DIRECTION_INVALID;
t_search_result search_res = alg->calc_next_dir(&agent, &maze, &next_dir);
// 探索アルゴリズムの結果に基づいてエラー処理したり、移動したり、座標更新したり(省略)
}
}
// adachi.h(足立法)
#include "search.h"
// 足立法を実行する
t_search_result adachi_next_dir(const t_agent *agent, const t_maze *maze, t_direction *next_dir);
// adachi.c(足立法)
#include "adachi.h"
#include "potential_map.h"
t_adachi_result adachi_next_dir(const t_agent *agent, const t_maze *maze, t_direction *next_dir) {
potential_map_update(maze);
// 歩数マップと迷路データから方向を計算
*next_dir = ...;
return SEARCH_RESULT_OK;
}
// potential_map.c/h(歩数マップ生成)
int potential_map_update(const t_maze *maze) {
// 迷路データから歩数マップ生成を生成
}
これで抽象に依存するようになりました。search.hに探索アルゴリズムを呼び出すインタフェースの定義が追加されていますね。 今回は関数1つのみのインタフェースなので、関数ポインタをそのまま使うのもアリでしたが、せっかくなので構造体にしました。
次に、足立法に依存していた探索動作がどうなったか確認しましょう。 探索アルゴリズムのインタフェース(t_search_alg *)を受け取り、そこから、calc_next_dir()を呼び出しています。足立法への依存から脱却しました!
足立法の実装は全く変わっていないと言っても良いですね。
他には、mainが変わっています。mainでsearch()が要求する対象を用意して、注入しています。 このように、依存する対象を実行前に注入する手法を依存性の注入(DependencyInjection)と呼びます。
実際の実装では、処理の状態を保持する必要がある場合が多いので、void *で状態を受け取れるようなインタフェースにすると良いでしょう。
(このような場合、処理の途中状態を保持する = 処理の文脈 = context = ctxという名前が良く使われます。)
// 探索アルゴリズムを実行する関数へのポインタ
typedef t_search_alg_result (*t_search_alg_func)(void *ctx, t_agent *agent,
const t_maze *maze, t_direction *next_dir);
// 探索アルゴリズムの初期化
typedef int (*t_search_alg_init)(void *ctx);
// 探索アルゴリズムの終了
typedef int (*t_search_alg_exit)(void *ctx);
// 探索アルゴリズムのインタフェース
typedef struct {
void *ctx;
t_search_alg_init init;
t_search_alg_exit exit;
t_search_alg_func calc_next_dir;
} t_search_alg;
void *は使いたくありませんが、このあたりはC言語の限界です。こんな危険なインタフェース嫌だ!という人はRustに引っ越しましょう。(C++でも良いとは思います。)
抽象に依存する事による効能
抽象に依存すると、影響範囲が小さくなり、かつ、変更が容易になるのでした。
上の例では、影響範囲が小さくなった事は確認できますが、変更をしていないので、イマイチ効能が見えません。関数ポインタを覚えたての人がはしゃいで作っただけのプログラムのようにも見えてしまいます。
ひさしぶりに、初心を思い出して、左手法で走り回るマウスを見たい気分になって来ませんか?左手法を追加してみましょう。 まずはモデルを書いてみます。
簡単ですね。探索アルゴリズムインタフェースを実装する、左手法を作ればよさそうです。
コーディングしてみましょう。
// main.c
#include "search.h"
#include "adachi.h"
#include "lefthand.h"
int main(void){
// 初期化とかメニュー選択とか
// ...
switch(mode){
case MODE_SEARCH_ADACHI:
{
// 探索の構築
t_search_alg search_alg = { adachi_next_dir };
// 探索の実行(集約)
search(&search_alg);
}
break;
case MODE_SEARCH_LEFTHAND:
{
// 探索の構築
t_search_alg search_alg = { lefthand_next_dir };
// 探索の実行(集約)
search(&search_alg);
}
break;
}
while(1);
return 0;
}
// lefthand.h
#include "search.h"
// 左手法を実行する
t_search_result lefthand_next_dir(const t_agent *agent, const t_maze *maze, t_direction *next_dir);
// lefthand.c
#include "lefthand.h"
t_search_result lefthand_next_dir(const t_agent *agent, const t_maze *maze, t_direction *next_dir){
// 左手法の実装(省略)
return SEARCH_RESULT_OK;
}
search.cには一切手を加えずに、左手法を追加する事ができました。
search.cの動きが怪しいなあと思ったら、バグが出そうなnext_dirの固定パターンを返す探索を実装する事で、再現性のあるデバッグをしたりもできます。
最後に
依存を意識し、整理する事で、プログラムの変更、追加が影響する範囲を少なくし、時間を有効活用できるようになります。プログラムの設計をする際は、ぜひ意識してみてください。
力尽きたので一度ここで記事を切りますが、依存については第二弾の投稿を書くかもしれません。
具体的にはモックを使って上流のオブジェクトをテスト可能にする手法、カオス化するmainの収拾をつける方法、STM32HALの構成の批判など。
参考
Amazonへのリンクです。
Clean Architecture 達人に学ぶソフトウェアの構造と設計
おすすめです。CleanArchitectureそのものの解説はもちろんですが、この本の真の価値は前半の依存についての記述の完成度だと思います。この記事を見て、依存を気にしてみようと思った人はぜひ読んでみてください。
エリック・エヴァンスのドメイン駆動設計 (IT Architects’Archive ソフトウェア開発の実践)
こちらは、ドメイン(問題領域)をどのように実際の設計に落とし込んで行くべきか、方法論的観点と、ソフトウェア開発の観点から書かれている本です。この記事とは毛色が違いますが、設計好きかも、という人はとりあえず読んでおけばいいと思います。