Cohesion
「凝集」の説明
ソフトウェアの設計評価軸として、凝集度と結合度がよく使われる。結合度は開発者の多くがよく理解しており、誤用されることは少ないが、凝集度は「凝集」という日常では滅多に使わない単語のせいでそれ自身では理解が難しい。
「凝集(Cohesion)」の語源は「粘着(Adhesion)」と同じである。Adhesiveは粘着性を意味し、何かが何かに付着するとき、一方的で外的なものである。べったりくっついていれば粘着力が高いと言う。一方でCohesionは「同じ種類のものだから」「よく似ているから」という理由で自然にくっついているものだ。
すなわち、以下の違いがある。
Adhesion: あるものが別のものにくっつくこと
Cohesion: 2つのものがくっついているという相互関係のこと
Cohesionは、考えが一致していてお互いに関係していて、それらを引き離すのが不自然に思えることである。そこに接着剤は必要ないし、ピースを合わせるために余分なコードを書く必要もない。
凝集度のレベル
出自は、Stevens, Wayne P.; Myers, Glenford J.; Constantine, Larry LeRoy Structured Design (1974)で、その後ヨードンによって世に広まった。 元のStructured Designで定義されたレベルは以下の通りで、これをベースにここでも検討する。
機能的(Functional)
逐次的(Sequential)
情報的(Communicational)
時間的(Temporal)
論理的(Logical)
偶発的(Coincidental)
そして、モジュールが機能的凝集かどうかを見極めるテクニックとして、以下の手順が書かれている。
1. モジュールの機能を説明する文を書いてみる。
2. もし文が(句点を含む)、複数文の組み合わせからなる、または2つ以上の動詞をもつのであれば、それは2つ以上の機能をもっているということだ。これは逐次的凝集、通信的凝集の可能性が高い。
3. 文が時間と関係している単語(「最初に」「次に」「それから」「~の後」「~するとき」「始める」など)を含むときは、モジュールは逐次的凝集や一時的凝集の可能性が高い。
4. 文の述語が、単一の特定のオブジェクトを含まないならば、そのモジュールは論理的凝集の可能性が高い。
5. 「初期化」や「クリーンナップ」などのような単語は、一時的凝集を暗に示している。
※もちろん、機能的凝集の場合も、複合文で説明を書くことができるが、それをどうやっても一文にまとめることができないなら、それは機能的凝集じゃないだろう。
原典が書かれた時点ではオブジェクト指向はなく、そこで言うモジュールは単一の後者のメソッド / ファンクションについてである。
モジュールとは、システムの他の部分が呼び出し可能な名前を持つ、1つまたは複数の連続したプログラムの文の集合を指すために使われる。PI/IのプロシージャやFORTRANのメインラインとサブプログラム、一般的にはサブルーチンが該当する。
だが現代では、以下のどちらにも使われるので、これまたやや紛らわしい。
メソッドのグルーピングに関する凝集度
単一のメソッドの役割についての凝集度
さらには、マイクロサービスアーキテクチャの各サービスについても評価軸として使える概念なので、以下3つに分けて凝集度のレベルを考えていく。
メソッド/ファンクションレベル: 以下、単に「メソッド」と呼ぶ
クラス/ネームスペースレベル: 以下、単に「クラス」と呼ぶ
サービス/サブシステムレベル
機能的
メソッドレベル
クラスレベル
次のようなCSVParserがあったときに、1行だけパースして1つの取引データを返すparseFromCSVと、複数行パースして、parseLinesFromCSVは「与えられたCSV文字列をパースする」という単一の役割に関連しており、引き離すのは不自然なので同じネームスペースにおく。
code:java
public class BankStatementCSVParser {
private static final DateTimeFormatter DATE_PATTERN
= DateTimeFormatter.ofPattern("dd-MM-yyyy");
private BankTransaction parseFromCSV(final String line) {
final String[] columns = line.split(",");
final LocalDate date = LocalDate.parse(columns0, DATE_PATTERN); final double amount = Double.parseDouble(columns1); final String description = columns2; return new BankTransaction(date, amount, description);
}
public List<BankTransaction> parseLinesFromCSV(final List<String> lines) {
final List<BankTransaction> bankTransactions = new ArrayList<>();
for(final String line: lines) {
bankTransactions.add(parseFromCSV(line));
}
return bankTransactions;
}
}
Uncle Bobは、著書Clean Codeの中で、クラスに関しての凝集度の高い状態は、インスタンス変数とメソッドが共依存の関係にあることだとしている。以下のStackのコードはすべてのメソッドがtopOfStackを使っていて、elementsもpush/popが使っているので、凝集度が高いクラスの例としている。
code:java
public class Stack {
private int topOfStack = 0;
List<Integer> elements = new LinkedList<Integer>();
public int size() {
return topOfStack;
}
public void push(int element) {
topOfStack++;
elements.add(element);
}
public int pop() throws PoppedWhenEmpty {
if (topOfStack == 0)
throw new PoppedWhenEmpty();
int element = elements.get(--topOfStack);
elements.remove(topOfStack);
return element;
}
}
サービスレベル
いわゆるマイクロサービスアーキテクチャで、サービス分割の指針となるのは、データのライフサイクルに沿って分割し、サービスレベルでの機能的凝集を目指すことである。
逐次的
1つの機能としてひとまとめにすべきところを分割しすぎたり、複数のメソッドを必ずセットで順次呼び出す必要があるようなケースである。
メソッドレベル
トランザクションの更新処理と通知メール送信が(現在の業務では)必ずセットで使われているので、1つのメソッド実装になっているような場合。ひとまとまりにするのは業務的意味があるので、比較的凝集度は高めだが、後々通知メールを送らないケースが出てきたり、通知をメールではなくSlackへの投稿にするなどの変更が入ったりしたときに、変更要求としてはユーザの更新にはなんら影響が無いはずが、コードがひとまとまりになっているため、影響を考慮しながら修正・テストしなくてはならなくなる。という点において、機能的凝集よりもレベルが低いと位置づけられる。
code:java
public void updateUserAndNotify(User user) {
// Update user to the database
// Notify to the user via e-mail
}
上記例を機能的凝集にするには、単純に分割すればよい。
code:java
public void updateUser(User user) {
// Update user to the database
}
public void notifyViaEmail(User user) {
// Notify to the user via e-mail
}
が、逆に1まとまりの機能を分割してしまうと、それはやはり機能的凝集とは言えず、かならずセットで順序どおり呼び出さなければならない逐次的凝集になってしまう。
対処としては①YAGNIとして必要となるまで放っておくか、必要となったときの修正を簡単にするために、②updateUserAndNotifyをファサードにしておくか、がとりうる戦術である。
code:②の実装例.java
// ファサード (責務的にUseCaseと呼ばれたり、アプリケーション層と呼ばれたり)
class UpdateUserAndNotify {
UserRepository userRepository;
UserUpdateNotifier notifier;
void handle(User user) {
userRepository.update(user);
notifier.notify(user);
}
}
// せっかく将来の変更容易性のためにファサードを導入するので、
// 直接実装に依存せずインタフェースに依存させるようにしておく
interface UserRepository {
int update(User user);
}
interface UserUpdateNotifier {
void notify(User user);
}
情報的
同じデータや同じドメインオブジェクトに作用するメソッドをグルーピングしたもの。
メソッドレベル
メソッド単位で見たときは、入力または出力は同じだが役割の異なる処理が一つにまとまっているもの。
code:java
public enum PerformanceCriteria {
業績への貢献,
チームメンバへの貢献,
スキル向上への取り組み,
}
public long calculateScore(Employee emp, PerformanceCriteria criteria) {
// ...
}
long s1 = calculateScore(emp, PerformanceCriteria.業績への貢献);
long s2 = calculateScore(emp, PerformanceCriteria.チームメンバへの貢献);
long s3 = calculateScore(emp, PerformanceCriteria.スキル向上への取り組み);
return s1 * weight.get(PerformanceCriteria.業績への貢献)
+ s2 * weight.get(PerformanceCriteria.チームメンバへの貢献)
+ s3 * weight.get(PerformanceCriteria.スキル向上への取り組み);
このケースは、それぞれ分割する
クラスレベル
永続化層によくみられ、1つのテーブルに対するCRUDを1つのクラスでもつ。同じデータを参照するメソッドが1箇所に集められているので、データの型を変更したときの修正の見通しが立てやすいメリットがある。
code:java
public class UserDao {
public List<User> findByName(String name) {
// ...
}
int insert(User user) {
// ...
}
int update(User user) {
// ...
}
int delete(Long userId) {
// ...
}
}
ドメイン層やドメイン層に提供するインタフェースは、情報的凝集で止まってはダメで、機能的凝集を目指す。
サービスレベル
マイクロサービスアーキテクチャにおけるEntity Serviceアンチパターン(あるエンティティについてのCRUDをそのままサービスの単位にしてしまう設計)
時間的
典型的な例は、なにかの初期化とクリーンナップの操作(データベースの接続と終了)である。初期化とクリーンナップの処理は無関係だが、時間的には特定の順番で呼び出さなければならない。
メソッドレベル
例えば「初期化処理」というメソッドの中で、いろんなオブジェクト、環境のセットアップを含める。共通点はプログラムライフサイクルの
論理的
例えば、何かを「解析する」という共通点のもとグルーピングする。以下の例はCSVやJSON、XMLなどの異なるフォーマットのデータを解析した結果を同一のオブジェクトに変換して返す。
code:java
public class BankTransactionParser {
public BankTransaction parseFromCSV(final String line) {
// ...
}
public BankTransaction parseFromJSON(final String line) {
// ...
}
public BankTransaction parseFromXML(final String line) {
// ...
}
}
当然ながら、クラスは「CSVの解析」「JSONの解析」「XMLの解析」という複数の責務をもち、このBankTransactionParserを使う側がどれを選択するか決めることになる。したがって凝集度は低い。コンスタンチンとヨードンの定義ではレベル2。
メソッドレベル
これは1メソッド単位では、引数によってどのフォーマットをパースするかを切り替えるようなものになる。Open Closed Principleを破る典型的な形なので、ポリモーフィズムを使うポイントになる。
code:java
public class BankTransactionParser {
public BankTransaction parseFrom(final String line, ParseFormat format) {
switch(format) {
case CSV:
// ...
case JSON:
// ...
case XML:
// ...
}
}
}
典型的なポリモーフィズムの実装は、BankTransactionParserのインタフェースを定義し、各フォーマットのパーサをそのインタフェースを実装して個別に作る。これで機能的凝集に持っていける。
code:java
public interface BankTransactionParser {
BankTransaction parseFrom(final String line);
}
public class BankTransactionCSVParser implements BankTransactionParser {
public BankTransaction parseFrom(final String line) {
// ...
}
}
public class BankTransactionJSONParser implements BankTransactionParser {
public BankTransaction parseFrom(final String line) {
// ...
}
}
public class BankTransactionXMLParser implements BankTransactionParser {
public BankTransaction parseFrom(final String line) {
// ...
}
}
これはアプリケーションのレイヤーによらず、どこでも通用する話なので、メソッドレベルでの論理的凝集は改善の余地がある。
ただし、論理的凝集の凝集度レベルをあげるメリットはOCPと等価であり、上記の例でいくとProtocolBufferのような新たなフォーマットが追加されたときに、BankTransactionParserを使っているコードを変更せず、新たに追加するBankTransactionBrotocolBufferParserを単独でテスト出来ることにある。複雑性が低く単独でテストできるメリットがあまり大きくない場合は、パターンマッチでの実装でもよいと考えられる。
偶発的
互いに関係のない処理がひとまとまりになっているもの。
メソッドレベル
1つのメソッドが長くてコーティング規約を守れないので適当なところで、メソッドを分ける。みたいなことをした結果、得られるメソッドは偶発的なものである。
なぜその単位でメソッドが別れているのか? はたまた、いくつかの処理がなぜそのメソッドに統合されているのか? 合理的な説明ができないものである。
このレベルは避けなければならないし、僅かな努力で避けることができるだろう。
クラスレベル
悪いクラス設計の代表とされる、いくつかのクラスで繰り返し出てくるコード片を詰め込んだCommonUtilは、クラスレベルの偶発的凝集とされることがある。
だが通常、Utilクラスは状態(インスタンス変数)を持たず、副作用のないメソッドの集合体であることが多い。決して関連性が強いとは言えないメソッドの集まりなので、凝集度は高くはないが、テストは書きやすく、コード変更時の問題も生じにくい。したがって偶発的凝集メソッドはだいぶ許容できないレベルだが、クラスレベルではその点ではさほどデメリットではないといえる。
だがしかし、基準なきUtilクラスは凝集度以外の面で以下のような問題を引き起こす事がよくある。
いろんなクラスから参照されすぎて、挙動を変えるのが事実上不可能になる。
例えばStringUtilなどのように基本的な型に関するUtilは、他の依存ライブラリにも含まれる似た名前のUtilと機能が重複し、どれを使えばよいか分からない、あちらではAパッケージのStringUtil、こちらではBパッケージのStringUtilのように混在して使われ保守性を下げることになる。
SOLID原則のわかりにくさ
https://www.youtube.com/watch?v=tMW08JkFrBA
Kelvin Henneyの「SOLID設計原則を分解する」で語られているとおり、SOLIDのうち単一責任原則SRPはCohesionそのものを指すものであるが、説明がわかりにくいし、Uncle Bobの「変更理由が同じなら一つにまとめよ」というのも高凝集の一側面(変更に関しての凝集度の話)だけを語っていて正確ではない、と指摘している。また、ISP(インタフェース分離の原則)はSRPと重複している。