⚖️

【ソフトウェア設計】モジュールになぜ分けるのか?

2024/02/12に公開

はじめに

最近、APoSD(A Philosophy of Software Design)を読んで、ソフトウェア設計に関して色々思う事が出来たというか、整理してみたくなったので、記事にまとめてみました。なお、APoSDの言葉を多用はしていますが解説記事という分けでは無く、自分の考え方の言語化にしっくり来たので使わせてもらってるという感じです。

TL;DR

  • モジュールとは関数/クラス/サービスなどの何等かの機能のまとまり
  • 良いモジュールは複雑性を隠蔽する
  • 複雑性を隠蔽しないモジュールの価値は低い

モジュールとは?

プログラムは正しく動くことがまず何より大事ですが、その次というかほぼ同じくらい大事な事が読みやすく拡張しやすいことですよね? コードは圧倒的に読みものなので、どう読みやすく、つまり理解しやすい状態にしておくかは重要な事です。APoSDでは複雑性が低いコード、という言い方をしています。

そして、複雑性に対処するために効果的な武器がモジュールです。ここでいうモジュールは特定の言語機能のモジュールではなく、関数かもしれませんし、クラスかもしれませんし、APIやマイクロサービスかもしれません。如何なる粒度であろうとも、機能を表現する塊をモジュールと呼ぶことにします。クラスを分割する時も、マイクロサービス化する時も、本質的に考える事は同じですし。

大きくて複雑な問題に対象するために重要なのは、やはり分割統治です。大きなものは切り分ける事で理解しやすくなりますし、良いモジュールは複雑性を隠蔽できます。

モジュールを作るときには凝縮度結合度を意識することが重要になってきます。
凝縮度とはモジュールの中の各ロジックの関連性です。一番凝縮度が低いモジュールは「その他」とラベリングされた箱に関連の無い機能がしっちゃかめっちゃかに放り込まれた状態で、複雑性の解消には役立ちません。凝縮度のモデルは古典ですが、今でもある程度参考になる指標だと感じます。凝縮度が高いモジュールは必要な機能が集まっており、利用するのも保守をするのも簡単になります。APoSDがいうDeepModuleも概ね似た概念と理解しています。結合度は他のモジュール等への依存の大きさです。この依存は様々です。単純に機能として別のモジュールを直接的に呼んでいる事もあれば、扱うDBのテーブルやEntityが同じなので同時に変更が必要なケース、あるいは別な機能を前提条件として実行しなければいけない場合、など様々な依存が発生します。これらが多い場合にモジュールAとモジュールBは結合度が高いと表現されます。

良いモジュールとは高凝縮かつ疎結合なモジュールとなります。

モジュール化の目的

認知範囲の局所化

では、そもそも何故モジュールを作るのでしょうか? 巨大な神クラスの何がいけないのでしょうか? モジュール化をする目的として、まず対象を小さくすることで認知的負荷を下げる効果があります。例えば以下のコードを見てください。大量に変数を定義した複雑で長いコードのサンプルだと思ってください。

int a = 1;
int b = 2;
...
int x = 24;
int y = 25;
int z = 26;

// なんか百行くらいのロジック
x += 2;
// なんか百行くらいのロジック
y = y * x;
// なんか百行くらいのロジック
int r = x + y;
// なんか百行くらいのロジック

ちょっとこのサンプルは見た目が単純過ぎてイメージがしづらいかもしれませんが、実際のビジネスロジックではrの値が何であるかを判定確認するのは、ちょっとした認知のリソースを使います。どこでxyの値が変わったのかを調べる必要がありますから。しかし、実際にはほかの処理に依存してないとすると以下のように書き換えれますよね?

int calc(){
  int x = 24;
  int y = 25;
  x += 2;
  y = y * x;

  return x + y;
}

int a = 1;
int b = 2;
...
int z = 26;

// なんか百行くらいのロジック
int r = calc();
// なんか百行くらいのロジック

こうすればrの値を確認するためにcalc()の中だけを確認すれば良く、十分に小さいのでxとyがどのような値に変更されたかも自明です。このようにモジュールを分割して、認知する範囲を小さくする事は認知的負荷を下げるための第一歩です。

変更箇所の局所化

もう一つのモジュールの分かりやすい効果は、変更箇所の局所化、すなわち再利用です。
部品化/再利用という言葉は、特にオブジェクト指向が流行ったころやコンポーネント指向の文脈でもてはやされた感じもしますね。

例えば以下の消費税を計算するコードを見てください。サンプルとして簡単にするために税率を10%固定にします。

// お肉を買う
var meatPrice = 1_000 + 1_000 * 0.1;

// 魚を買う
var fishPrice = 500 + 500 * 0.1;

// 本を買う
var bookPrice = 5_000 + 5_000 * 0.1;

消費税は変わるものなので、12%とかに代わる度にいろんな箇所を修正するのは大変なので以下のように関数に切り出しましょう。

double calcTax(int price){
  return price * 0.1;
}

// お肉を買う
var meatPrice = 1_000 + calcTax(1_000);

// 魚を買う
var fishPrice = 500 + calcTax(500);

// 本を買う
var bookPrice = 5_000 + calcTax(5_000);

このようにすればcalcTaxを修正するだけで消費税の変更にも対応でき、すべての利用箇所を変更する必要はありません。これは変更箇所の増大を避ける事に繋がります。これもモジュール化の重要な効果です。

ただ、これは見方によっては依存度が増えています。calcTaxに依存が付くのでそちらの改修が他にも影響したりしてしまいますね。この例では問題ないですが、こうした共通化が罠になるケースもあります。これに関しては後程説明します。

抽象化による複雑さの隠蔽

抽象化とはすなわち重要ではない情報の隠蔽です。モジュールを作る効果として、複雑性を隠蔽する事があります。非常に強力な抽象化を行い複雑性を隠蔽している仕組みの一つがSQLです。

SELECT * FROM USER WHERE name='Taro';

これはUSERテーブルからTaroという名前のユーザを検索して取得するクエリです。やりたい事を記述した直感的な内容ですが、実際この裏側では以下のような処理が呼び出されています。

  1. クエリパーサー
  2. 実行計画
  3. 評価

さらに評価の中でもキャッシュへのアクセスの有無やB-Treeへの探索、トランザクションに関する処理などなど本当に様々な処理が隠蔽されています。SQLを実行する時には基本的にはデータベースのキャッシュの状態やら、対象のデータのメモリやディスク上での状態やら、そういった詳細を渡す必要は無く、やりたい事に関連する情報だけを渡しています。モジュールの奥に複雑性を押やり、必要最小限だけの入力パラメータと期待される出力だけを公開するのが良いモジュールです。また、このような入出力パラメータ等をインターフェースと呼ぶ事にします。

これにより利用者はインターフェースのみに依存してコードが書けるので、例えばキャッシュの利用法やオプティマイザの仕組み、探索のアルゴリズムといった実装を変えたりしても(パフォーマンスは別ですが)利用側のコードを変更する必要はありません。インタフェースと実装の分離 は再利用性の高い良いモジュールのための条件です。

逆に悪いモジュールも見てみます。例えば以下のようなコードがあります。ECサイトとか、何等かのサービスに申し込むと初回入会ポイントが貰える事がありますよね? それをイメージでしてください。3000ポイント貰って保存する部分をモジュール化しています。

int addEntryPoint(){
  user.point = 3000;
  user.save(); // 例えばDBに保存する処理
}

// 入会した時の処理
addEntryPoint();

とりあえず、上手く動きでそうです。ではこれに紹介キャンペーン経由なら5000ポイント付与! という処理を追加してみましょう。ポイント額をaddEntryPointの中に閉じ込めたい場合は以下のように書けます。

int addEntryPoint(boolean isIntroduce){
  if(isIntroduce){
    user.point=5000;
  }else{
    user.point=3000;
  }
  user.save(); // 例えばDBに保存する処理
}

if(紹介キャンペーン){
  // 紹介キャンペーンで入会した時の処理
 addEntryPoint(true);
}else{
  // 入会した時の処理
  addEntryPoint(false);
}

引数のisIntroduceでキャンペーン経由なのか否かを判定しています。ちゃんとモジュールが再利用出来てますね? でも複雑さはどうでしょうか? 呼び元と呼び先で同じ判定しないですか? では、ここに「7の付く日だけ入会ポイント7777」という素敵なキャンペーンを追加したら? addEntryPointの引数が増えて、さらに既存の各キャンペーンでもそれに合わせた改修が要りますよね?

int addEntryPoint(boolean isIntroduce, boolean isSpecialDay){
  int points = 3000; // 基本ポイント
  if(isIntroduce){
    points = 5000; // 紹介キャンペーン経由
  }
  if(isSpecialDay){
    points = 7777; // 特別キャンペーンの日
  }
  user.point += points; // ポイントを加算
  user.save(); // 例えばDBに保存する処理
}

しかも、このサンプルの書き方だと誤ってisIntroduceisSpecialDayの両方をtrueにすると、7の付く日のキャンペーンが優先されバグの温床になりがちです。このケースなら直接呼び元にロジックを書いた方がまだ良いかと思います。モジュールを作製することで複雑性は減らずI/Fが増えたことによる認知負荷の増加、分岐が重複している変更箇所の増大など、モジュール作成のペナルティだけを受けてしまうからです。

addEntryPointは共通部品ではあり、再利用はされていましたが抽象化を提供していません。呼び出し元に中の詳細が滲み出ていましたし、結果的に呼び元も呼び先も修正するのでむしろ修正コストが増えています。これは低機能で抽象度の低いモジュールにありがちな事です。このサンプルコードはあからさま過ぎるので「こんな書き方流石にしないよ」と思うかもしれませんが、「最初はそのつもりでは無かったけど、後々の拡張で...」といった理由で似たような構造になる事はしばしばあります。最初はこうじゃなかったのに...的なorz

APoSDではこのような抽象度が低く低機能なモジュールをShallow Moduleと呼び批判しています。個人的にはすべてのShallow Moduleが悪いとは思っていませんが、今回の例みたいなケースはもちろんNGですし、いっけんすると再利用出来ているように見えるので、共通化の罠に嵌まりがちなので注意が必要なのは間違いないですね。

モジュール化をする目的として、最も強力で実現が難しいのが抽象化による複雑さの隠蔽です。ただ、必ずしも3つを常に満たす必要は無いと考えています。認知範囲の局所化や変更箇所の局所化も単独でも十分役には立ちます。ただ、そうしたモジュールはJavaでいえばprivatepackageスコープのメソッドやクラス等、抽象化を提供するモジュールに従属する部品でしょう。publicスコープで提供するのであれば 「それは正しく抽象化されているか? 複雑さを隠蔽しているか?」 を意識するべきでしょう。

まとめ

今回は、いったんモジュールに関して書きましたが、如何でしょうか? 記事のポイントは以下ですね。

  • モジュールとは関数/クラス/サービスなどの何等かの機能のまとまり
  • 良いモジュールは複雑性を隠蔽する
  • 複雑性を隠蔽しないモジュールの価値は低い

たぶん、同じだと思う事もあれば「全然違うよ!」となる部分もあると思っていて、いろんな人の考え方が知れると面白そうです。
他にも色々あるので、次回はカプセル化辺りに関して記事を書いてみたいと思います。

それではHappy Hacking!

Discussion