よしたろうブログ

設計・人文知・歴史・哲学・漫画とかの話が好きです。

リファクタリング入門 ~凝集度・結合度~

凝集度と結合度は、保守性と生産性の高いコードを書くための尺度。凝集度と結合度は順次、選択、反復という要素を持った構造化プログラミングをベースとしている多くの言語に適用できる概念。

1. リファクタリングの必要性

良いコードとは何か?リファクタリングで目指すべきもの

  • 良いコードとは何か?
  • 良いコードを書く際に陥りがちな罠
    • 設計やアーキテ クチャを学ぶことへの心構え
  • 良いコードを書くための基本的な原則である構造化プログラミング
    • 理論を学ぶうえでの注意点
    • 凝集度と結合度がその手助けをしてくれること

1-1. リファクタリング:動作を変えず内部構造を改善する

リファクタリングと凝集度、結合度

  • リファクタリングとは、外部から見た動作を変えずにコードの内部構造を改善していくこと
  • 外部から見た動作を変えないためには、改善前に適切なテストを準備
  • テストで外部からの動作を変えないことを保証したあとには、さまざまな手法に基づきコードを改善していく

なぜリファクタリングが必要なのか?

  • リファクタリングを行わなければコードは複雑になり劣化
    • ある機能の実装時点で完璧な設計をしていたとしても、リファクタリングをしなければ必ず劣化
    • なぜならソフトウェア開発では時間経過とともに必ず新しい仕様が追加され、その時点で完璧ではなくなる
  • そのためリファクタリングとは特別に大きな時間を確保して行うものではなく、仕様追加の 前後で日常的に行うべき作業

将来を想定した設計の罠──現時点で良いコードを書く

  • コードが劣化しないように、将来を想定した設計は多くの場合外れる
    • その場合は想定した設計は完全に価値がなく、ただコードを複雑にした負債に
  • もし高い精度で将来の予測が当たるとしても、過剰な設計はするべきではない
    • なぜならその設計は現時点では不要なコードであるため、理解することが難しい
    • 現在不要なコードが存在することは、コードを読むエンジニアに混乱をもたらす
    • そのようなコードが当たり前になると、負債が積み上がるようになる
    • 現時点での仕様を表現した良いコードを書き続けることが重要

継続的なリファクタリング──理解しやすく機能追加やバグの発見がしやすいコードを目指す

  • 適切なリファクタリングは、変更容易性が高く、理解がしやすく、機能追加が容易な状態を生み出す
    • コードを書く時間以上にコードを読む時間が長いこと
    • 機能追加時にコードの理解に負荷がかかるコードを書いてはいけない
    • 簡単に理解できれば開発速度は上がり、バグ修正も容易になる
    • 良いコードは継続的な資産

1-2 良いコードとは:リファクタリングで目指すもの

リファクタリングで目指すのは「良いコード」
「良いコード」とは何か、そしてなぜ「良いコード」を書く必要があるのか

良いコードのための関数の分け方

良いコードとは? - 「読みやすいコード」 - 「修正がしやすいコード」 - 「バグを含まないコード」 - 「テストがしやすいコード」

または、

このような抽象的な考え方だけでは、チームで合意して良いコードである判断することが難しい。 毎日欠かさず行う関数の分け方という基本的なことには言及していないからだ

関数の分け方の基準である凝集度と結合度が重要になってくる。

凝集度・結合度と言ったときオブジェクト指向プログラミング(OOP)では、少し異なる意味で説明されることがあるが、ここで説明する凝集度と結合度は構造化プログラミングで提唱されているものを指す

良いコードが実現する生産性の向上

良いコードを書けば会社の利益貢献につながる。なぜか?早くリリースすることにメリットあるからだ

  1. 勝者総取りの事業が多いWeb業界では最初に市場を独占することは重要
  2. 競合他社に勝つ・市場の変化に追随する
  3. ユーザーのフィードバックを元に高頻度の改善をしていく必要がある

会社としてスケールするためには多くの人材が必要となるが、その際に性酸性の低いコード、即ち読みづらく機能追加に時間がかかるコードでは市場の競争には勝てません。チームとして最大の生産性を発揮できるコードというものが「良いコード」の定義になる

1-3. 構造化プログラミングとは:良いコードを書くための普遍的な手法

構造化プログラミングの特徴

  • 構造化プログラミングは、1970 年前後に Edsger W. Dijkstra により生み出された概念
    • 順次、選択(if など)、反復(for など)を用い制御フローを実現
    • コードの明快さ、品質、 および開発時間を改善することが目的
    • 構造化プログラミング言語はほとんどの言語のベースとなる
  • 構造化プログラミング以前
    • goto 文
    • コード内で突然ほかの命令にジャンプできる(コードジャンプ機能)
      • コードジャンプ機能は現在でも限定的に存在する
        • ループ文の continue や break
        • 関数途中の return
        • 例外のための throw
    • これによって複雑なコードを書くことが可能となってしまう

構造化プログラミング時代の設計理論「凝集度と結合度」

多くの言語は多数の価値観を取り入れたマルチパラダイム言語。構造化プログラミングがベースとなっているため、凝集度と結合度はモダンな言語においても活用可能であり、OOPにおける「デザインパターン」「SOLID原則」にも多く登場する考え方

凝集度と結合度のリファクタリングへの活用

  • あるライブラリのアルゴリズムをどのような関数に分割するべきかという視点
  • バックエンド開発のアーキテクチャをどのようなものにするかの検討基準
  • クライアントサイド開発のビューをどのような単位でコンポーネント分割するか

リファクタリングの際には、近代的なアーキテクチャ理論と凝集度・結合度を用いる事で多角的・より適切な技術的決定が行える

また多くのアーキテクチャでは、詳細な関数分割の基準については語られていないが、凝集度と結合度は、より汎用的かつ詳細な関数分割のケースでも有効な判断基準となりる

1-4. 設計やアーキテクチャへの向き合い方:凝集度、結合度を学ぶ前に

さまざまな原則・アーキテクチャ・凝集度と結合度、これらの指標はいついかなる時も正しいわけでもなく対費用効果が高いわけでもない。さまざまな基準や目的実現におけるトレードオフ、これらを考慮した上で、今現在最も適切であろう解決策を見出すことが大切になる

良いとされる設計の落とし穴

盲目的原理主義的に原則をプロダクトに適用するのは、アーキテクチャの複雑さ・冗長なコードの増加などを引き起こし、本来の目的である生産性を獲得できない。何かのアーキテクチャを採用したから性酸性の他界良いコードになるということはない。すべてにはトレ ードオフが存在する

全体的なバランスを取る必要があり、そのためさまざまな選択肢をもち、さまざまな知識を自分の中で体系化し、いつでも引き出せるようにする事が重要

現実的な課題を解決するためにアウトプットを評価する

良いコードを書くには、A の理論が正しいから A を採用するのではなく、A を採用した結果のコードがどういう状態にあるのかを客観的に評価する必要がある。アウトプットを客観的に評価することで 現実的な課題解決に集中し、過剰設計により複雑さを避けられる

客観的な評価観点

  1. 生産性が向上しているか
  2. パフォーマンスが向上しているか
  3. 日常的に反復して行う実装の再現性が高い
  4. ドメインとコードの表現が対応している
  5. 仕様変更・機能追加に柔軟に対応できる

何か一つだけの原則やアーキテクチャを過信しない

良いコードを書くためには、客観的な生産性向上を評価する視点・能力が必要。1 つの設計手法から判断するのではなく、さまざまな設計やアーキテクチャを学び、多面的にアウトプットを評価することが重要になる。

2. 凝集度

関数の役割の少なさを表す「凝集度」単機能を実現し、再利用性を高める

  • 7 つの凝集度
    • それぞれどのようなものであるか
    • 問題がある凝集度
    • 1 つの関数にどのような実装を持つべきかの指針

凝集度とは、関数の処理の役割の少なさを表す尺度。凝集度は高いほど良い。関数 A の中に A、B、C、D、E......とさまざまな処理が書かれていたら凝集度は低く、良くない関数。凝集度が低いと特定の機能だけを再利用することが難しくなったり、コードを変更した際に思いも寄らない処理に対して意図せず影響を与えてしまうことがある。

凝集度には、の 7 つの尺度が存在し、上から順に高くなっていく

  1. 偶発的凝集:無作為に処理が集められた関数
    1. 避けるべき凝集
  2. 論理的凝集:関連のない処理をフラグで切り替える関数
    1. 極力さけるべき凝集
  3. 時間的凝集:機能的に関連はないが同じ時間に実行する処理をまとめた関数
  4. 手続き的凝集:機能的に関連はないが同じ時間で実行順序に意味がある処理をまとめた関数
  5. 通信的凝集:機能的に関連はないが同じ時間で同じ値に対して処理をする関数
  6. 逐次的凝集:機能的に関連はないが関連する値を受け渡して処理をする関数
    1. 3〜6は状況に応じて行われる凝集
  7. 機能的凝集:単一の機能を処理する関数
    1. 理想的な凝集

2-1. 偶発的凝集:無作為に処理が集められた関数

偶発的凝集は、無作為に処理が集められた関数.関数の中に複数の処理が存在し、それらの処理はまったく関係がない。

偶発的凝集の問題点

  1. 可読性が非常に低い
  2. 関連性がない処理の集合のため、再利用性は皆無

偶発的凝集の改善方法

関係のない処理は関数から削除。もともと存在していた処理は、別途適切な箇所で実装する。

2-2. 論理的凝集:関連のない処理をフラグで切り替える関数

論理的凝集は、関連のない処理をフラグで切り替える関数。

論理的凝集の問題点

  1. 論理的凝集の関数は一見きれいに見えてその実、不要なことが多い関数
    1. flag によって処理を切り替えているのが特徴。

論理的凝集の改善方法

論理的凝集の関数そのものを削除してしまう。 flag によって処理を切り替える関数は不要で削除可能な場合がある。その関数自体が本当に必要かどうか検討し、不要な場合には削除

2-3. 時間的凝集:機能的に関連はないが同じ時間に実行する処理をまとめた関数

時間的凝集は、機能的に関連はないが「初期化時」 など近い時間で実行する処理をまとめた関数

時間的凝集の問題点

  1. 機能的には関係ない関数が 1 つの関数にまとめられている
  2. 機能的な関連はないので、あるときには同時に実行するとしても、別のときには同時に実行しないかもしれない
    1. その関数の再利用性は低い

時間的凝集の改善方法

実装を機能的凝集の関数(単一の機能を処理する関数)に切り出す。

2-4. 手続き的凝集:機能的に関連はないが同じ時間で実行順序に 意味がある処理をまとめた関数

時間的凝集に非常に似ており、違いは順序に意味があること。より関連 性の高い処理が関数に実装されており、時間的凝集よりは凝集度が高い。

手続き的凝集の問題点

  1. 機能的には関係ない関数が 1 つの関数にまとめられてしまっている
    1. 順序に意味があるので時間的凝集よりは凝集度が高いが、根本的な問題は同じ
  2. 単一機能を実現しているわけではないので、必ず同時に実行するとは限らない
    1. その関数の再利用性は低い

手続き的凝集の改善方法

実装を機能的凝集の関数(単一の機能を処理する関数)に切り出す。
手続き的凝集の関数の中に詳細な実装が複数行にわたって書かれているなら、その実装を関数として切り出し、手続き的凝集の関数からはそれらの関数を呼び出すだけ形にする。

2-5. 通信的凝集:機能的に関連はないが同じ時間で同じ値に対して処理をする関数

機能的に関連はないが「初期化時」など近い時間で実行し、同じ値に対して処理をする関数。時間的凝集に非常に似ており、違いは同じ値に対して処理をする点。

public void changeA (a *A) {
    changeA1(a)
    changeA2(a)
    changeA3(a)
}

通信的凝集の問題点

  1. 機能的には関係ない関数が 1 つの関数にまとめられている
  2. 単一機能を実現しているわけではないので、必ず同時に実行するとは限らない
    1. その関数の再利用性は低い

通信的凝集の改善方法

通信的凝集の関数の中に詳細な実装が複数行にわたって書かれているなら、その実装を関数として切り出し、通信的凝集の関数からはそれらの関数を呼び出すだけのシンプルな関数にする。

2-6. 逐次的凝集:機能的に関連はないが関連する値を受け渡して 処理をする関数

機能的に関連はないが「初期化時」など近い時間で実行し、関連する値を受け渡して処理する関数。時間的凝集に非常に似ており、違いは手順間で値の受け渡しがあること

public int calc(int num) {
    b1 = b1(num);
    b2 = b2(b1);
    return b3(b2);
}

逐次的凝集の問題点

  1. 機能的には関係ない関数が 1 つの関数にまとめられている
  2. 単一機能を実現しているわけではないので、必ず同時に実行するとは限らない
    1. その関数の再利用性は低い

逐次的凝集の改善方法

逐次的凝集の関数の中に詳細な実装が複数行にわたって書かれているなら、その実装を関数として切り出し逐次的凝集の関数からはそれらの関 数を呼び出すだけのシンプルな関数にする。

2-7. 機能的凝集:単一の機能を処理する関数

再利用性、保守性が高い理想的な状態。これ以上の凝集度はない。

2-8. まとめ

  • 偶発的凝集. 明らかに関連のない処理の集合のため、絶対に避ける

  • 論理的凝集. 排除する方法があるため極力避けるべき。最も犯しがちな間違いで、自身で気が付くことも難しいので最大限注意

  • 時間的凝集、手続き的凝集、通信的凝集、逐次的凝集. 同一時間軸上で一緒に実行する機能をまとめた関数。これらの関数に詳細なロジックが書かれていないかを注意

  • 機能的凝集. 理想的な凝集度で単一の機能を表現。可能な限り機能的凝集の関数を目指す。だが、すべての関数を必ず機能的凝集にできるとは限らない。時間的凝集や論理的凝集の関数を実装することが妥当である場合は、なぜそのような実装になったかを説明できるようにする。

3. 結合度

関数の独立性を表す「結合度」疎結合を実現し、意図せぬ影響を防ぐ

  • 7 つの結合度
    • 問題がある結合度と解決方法
    • 関数のインタフェースをどのように設計するべきかの指針

結合度とは、関数の独立性を表す尺度。結合度は、低いほど良い。結合度が高いと、ある関数が持つデータを変更すると、意図せずほかの関数に影響を与えてしまい不具合が発生してしまうことがある。このようなことを避けるために結合度は低くあるべき。

結合度には、の 7 つの尺度が存在し、上から順に低くなっていく

  1. 内容結合:宣言されていない変数での結合
  2. 共通結合:共通の複数のグローバル変数で結合
  3. 外部結合:共通の単一グローバル変数で結合
    1. 1~3は避けるべき結合
  4. 制御結合:制御フラグで結合
    1. 極力避けるべき結合
  5. スタンプ結合:構造体やクラスで結合
  6. データ結合:スカラ型(単一)の値で結合
  7. メッセージ結合:数のない関数の呼び出しで結合
    1. 5~7は理想的な結合

3-1. 内容結合:宣言されていない変数での結合

具体的には C 言語などで異なる関数から、メモリのアドレスの受け渡しを行わずに同一のメモリアドレスにアクセスしている状態。現代の多くのプログラミング言語はメモリ安全であるため、このようなコードを書くことはできない。メモリアドレスで暗黙的に結合している。

多くのオブジェクト指向言語、例えばJavaRubyPHPPython などで注意すべきは別名参照問題。別名参照問題とは、参照と複製が明示的に記されないオブジェクト指向言語において発生する。

Date retirementDate = new Date(Date.parse("Tue 1 Nov 2016"));
Date partyDate = retirementDate;

二つの変数間で同じ参照を共有するため、共有されたオブジェクトの状態を意図せずに変更してしまう。

  1. 代入は複製のみに限定する
    1. 複製とは「インスタンスそのものコピー」
  2. オブジェクトや変数を不変( immutability )にする
    1. インスタンス生成後、そのインスタンスの状態(メモリ領域に保持されている値)が変化しない」という意味

詳しくは過去記事へ

3-2. 共通結合:共通の複数のグローバル変数で結合

複数のグローバル変数とは構造体(C や Go の概念。Java などの多くのオブジェクト指向言語におけるバリューオブジェクトが近似する概念)やクラス。これらを意味する変数によって結合している。

共通結合の問題点

  1. グローバル変数での結合はその認知が難しく結合度が非常に高い状態
  2. どこでいつ呼ばれるかを完全に理解していないと再利用することが難しく、保守性も低い
  3. Singleton パターンなどで良く見られる現象

共通結合の改善方法

グローバル変数をローカル変数として定義、その変数を適切に受け渡すように変更。受け渡すデータを明確にする。

3-3. 外部結合:共通の単一グローバル変数で結合

int a;

public static void main(String[] args) {
    a = 10;
    twice();
    System.out.println(a);
}

public int twice() {
    return a = a * 2;
}

外部結合の問題点

  1. グローバル変数での結合はその認知が難しく結合度が非常に高い状態
  2. どこでいつ呼ばれるかを完全に理解していないと再利用することが難しく、保守性も低い
  3. Singleton パターンなどで良く見られる現象

外部結合の改善方法

グローバル変数をローカル変数として定義、その変数を適切に受け渡すように変更。

public static void main(String[] args) {
    a = 10;
    b = twice(a);
    System.out.println(b);
}

public int twice(int a) {
    return a = a * 2;
}

3-4. 制御結合:制御フラグで結合

関数にフラグを渡して処理を切り替える関数。

制御結合の問題点

呼び出し側の関数は、呼び出される側の実装の中身を理解して flag を渡していることになり、結合度が高い。

制御結合の改善方法

flag を受け取る関数そのものを削除する。制御結合は、フラグにより処理を切り替える関数 で、一見悪いようには見えないため、やりがちな結合。論理的凝集との関連も強く、制御結合をやっていることで論理的凝集を誘発している可能性もある。

3-5. スタンプ結合:構造体やクラスで結合

これ以降は受け渡す値の種類に応じて結合度が決定される。

スタンプ結合は、構造体やクラスで結合している関数。スタンプ結合自体は問題があるわけではないため、良い悪いではなく、ケースバイケースで必要であるかを検討する。一般的な目指すべき綺麗な関数。

クラスは複数のフィールドを持つため、その中に使用しない不要な値がある場合は不適切な可能性がある。

3-6. データ結合:スカラ型(単一)の値で結合

データ結合も問題があるわけではなく、一般的な目指すべき綺麗な関数。スタンプ結合と比較すると結合する値が少ないため、結合度が低い。

public static void main(String[] args) {
    a = 10;
    b = twice(a);
    System.out.println(b);
}

public int twice(int a) {
    return a = a * 2;
}

上記はスカラ型の値を渡す。何も関数に渡さないよりは値を渡すことにより結合が発生している。もし不必要な値を渡しているのであれば、値を渡さないように変更する。

3-7. メッセージ結合:引数のない関数の呼び出しで結合

引数のない関数呼び出しで結合。値の受け渡しがなく関数の実行でしか結合していないため、最も結合度が低い。

3-8. まとめ

  • 内容結合、共通結合、外部結合. ローカルのスコープで値が管理されておらず、関数間の結合の見通しがとても悪いので、絶対に避けるべき結合。

  • 制御結合. 論理的凝集と同様、フラグを渡す関数は多くの場合不要なので極力避けるべき。一見スタンプ結合やデータ結合と区別が難しいため、注意が必要。

  • スタンプ結合、データ結合、メッセージ結合. 目指すべき結合度。必ずスタンプ結合よりデータ結合やメッセージ結合が優れているわけではなく、受け渡すデータ種別に応じて適切に使い分けるべきもの。

4. 時間的凝集と論理的凝集への対応

  • 特に気を付けるべき 2 つの凝集度である時間的凝集と論理的凝集
    • 時間的凝集、手続き的凝集、通信的凝集、 逐次的凝集をまとめて時間的凝集

4-1. 時間的凝集とリファクタリング

時間的凝集の複雑化を解決:機能的凝集の関数への切り出し

時間的凝集の関数には、機能的には関連のない処理でも同じ時間に実行する処理が、同一関数に書かれてしまう問題がある。

時間的凝集は理想的な凝集ではないが、かといって必ず避けるべき悪いものというわけでもない。ケースバイケースでもある。サーバを起動する main 関数での Config の読み込みやさまざまなクライアントの生成など。サーバサイドの関数でアプリケーション層やクリーンアーキテクチャユースケース層も時間的凝集といえるらしい。

大切なのは、時間的凝集の関数には実装の詳細を書かないこと。詳細に書きすぎた実装は別途、機能的凝集の関数に切り出す。

// 時間的凝集関数
public static void main(String[] args) {
    // 処理1が20行
    // 処理2が20行
    // 処理3が20行
    // 処理4が20行
}

以下のような形に変更する

public static void main(String[] args) {
    // 時間的凝集の関数では、切り出した機能的凝集の関数を実行する
    f1();
    f2();
    f3();
    f4();
}

// もとの処理を機能的凝集の関数に切り出す
// もし以下の関数を関数A以外で利用する場合には、
// 同様に機能的凝集の関数を実行すればよい
func f1() {
    // 処理1が20行
}

func f2() {
    // 処理2が20行
}

4-2. 論理的凝集とリファクタリング

論理的凝集とどう付き合うか

ユースケースは異なるが処理が似ている実装を DRY にしたいときには注意。最初はユースケース1 だけを想定すればよかったが、仕様変更によりユースケース2 も想定する必要が出てきたとき、論理的凝集になりがち。

ユースケースが、3つ4つと増えていくとその関数はフラグだらけに。以下では、ユースケース1 の時、どのような処理が実行されるか把握するのは難しい。

複数のユースケースを表現する関数になので、ユースケース1 を変更した結果、ユースケース2、3、4 にバグが発生する可能性が出る。ほかのユースケースに影響があり、影響範囲の理解が困難になり、保守が難しい関数となる。

public void useCase1And2And3And4(String useCase){

    if (useCase == "ユースケース1" || useCase == "ユースケース2" ||
        useCase == "ユースケース4") {
        // aに関する処理が20行
    }

    if (useCase == "ユースケース1" || useCase == "ユースケース2" ||
        useCase == "ユースケース3") {
        // bに関する処理が20行
    }

    if (useCase == "ユースケース1") {
        // cに関する処理が20行
    }

    if (useCase == "ユースケース1" || useCase == "ユースケース2" ||
        useCase == "ユースケース3" || useCase == "ユースケース4") {
        // dに関する処理が20行
    }
}

4-2-3. 論理的凝集を回避する方法:ユースケースごとに関数を実装する

1つの関数で複数のユースケースを実現するのではなく、ユースケースごとに関数を実装する。 このとき個別のユースケースの関数は論理的凝集から時間的凝集になる。

// 論理的凝集
public void usecase1and2(Boolean isUsecase1) {

    // aに関する処理が20行
    // bに関する処理が20行

    if (isUsecase1) {
        // cに関する処理が20行
    }

    // dに関する処理が20行
}
// 論理的凝集
public void useCase1and2(Boolean isUsecase1) {
    if (isUseCase1) {
        useCase1();
    }

    useCase2();
}

// 時間的凝集
public void useCase1() { 
    a();
    b();
    c();
    d();
}

// 時間的凝集
public void useCase2() { 
    a();
    b();
    d();
} 

useCase1() にはユースケース1の処理だけが書かれてる。今後変更があったとしても、他の関数に影響を与えない。さらにフラグも必要ない。

// ユースケース1
public void useCaseOne() {
    useCase1();
}

// ユースケース2
public void useCaseTwo() {
    useCase2();
}

ユースケース実行関数はフラグを渡して呼び出している時点で、どちらのユースケースを実行 するべきか知っている。最初からユースケースごとに分離して実装した関数を個別に呼び出すべきでフラグは関係ない。

4-3. 論理的凝集をしたくなる理由と解決策

理由1:実装の重複を回避するため

論理的凝集をしたくなる 1 つ目の理由は、実装の 重複を回避するため。この場合は、ユースケースの詳細な処理を機能的凝集の関数に分離する。こうすることでユースケースの関数は論理的凝集から時間的凝集に改善され、機能を再利用できる状態になる。

理由2:共通化しないと機能追加忘れが発生するため

通化により機能追加時の修正漏れを防止したい場合。新しい処理を追加したくなったときに、共通化していないといずれかのユースケースに追加を忘れてしまう(片方だけに機能追加をしてしまうなど)場合もある。

それを防ぎたいがために、以下のようなコードにしたいと考える事があるかもしれません。e という処理を追加したい場合は1箇所に追加すれば忘れることはない。

// 論理的凝集
public void usecase1and2(Boolean isUsecase1) {

    // aに関する処理が20行
    // bに関する処理が20行

    if (isUsecase1) {
        // cに関する処理が20行
    }

    // dに関する処理が20行
    // eに関する処理が20行
}

これは「共通化による変更箇所の局所化」と「論理的凝集の回避による影響範囲の局所化」がトレードオフの関係にあることを意味するが、明らかに後者を選ぶべき。疎結合だし、追加忘れなんてテストや実行時エラーですぐ判明するから。

理由3:実行順序をDRYにするため

たとえば以下の2つのユースケースの関数が存在するときに、b の処理を a より前に実行する必要が出てきたとし、その際に時間的凝集の実装になっていると、どちらのユースケースの関数も修正する必要がある。

public void useCase1() { 
    a();
    b(); // bをaの前に実行するように修正
    c();
    d();
}

public void useCase2() { 
    a();
    b(); // bをaの前に実行するように修正
    d();
} 

それでも多くの場合、論理的凝集を回避したほうが良い。順序が変わる必要が出たということは、順序を変えないと機能しないので、コンパイルや最低限のテスト・動作確認で気付く。論理的凝集 を優先してしまうと、ほかのユースケースの関数に影響するという大きなデメリットがあることを考慮しなくてはいけない。

4-4. 論理的凝集を回避するメリット

  1. 実装がシンプル

詳細なロジックの条件分岐は機能的凝集の関数に移動、ユースケースを切り替えていた条件分岐はなくなる。その結果、ユースケースに必要な機能だけがユースケースの関数に実装される。

  1. 異なるユースケース作成が容易

新たなユースケースが登場したときに、ユースケース間の差分から新規のユースケースを実装するのが楽。以下のコードにユースケースを追加したり、削除したりするのはめちゃくちゃ面倒。

public void useCase1And2And3And4(String useCase){

    if (useCase == "ユースケース1" || useCase == "ユースケース2" ||
        useCase == "ユースケース4") {
        // aに関する処理が20行
    }

    if (useCase == "ユースケース1" || useCase == "ユースケース2" ||
        useCase == "ユースケース3") {
        // bに関する処理が20行
    }

    if (useCase == "ユースケース1") {
        // cに関する処理が20行
    }

    if (useCase == "ユースケース1" || useCase == "ユースケース2" ||
        useCase == "ユースケース3" || useCase == "ユースケース4") {
        // dに関する処理が20行
    }
}

機能単位で分割されていれば、新しいユースケースの関数を追加するだけになる。

public void useCase1() {
    a();
    b();
    c();
    d();
}

public void useCase2() {
    a();
    b();
    d();
}

public void useCase3() {
    a();
    b();
}
  1. 単一責任になる

オブジェクト指向設計原則である SOLID 原則の1つ、単一責任の原則。論理的凝集が問題だと理解しておくと、単一責任の原則をより適切に守ることができる。

4-5. まとめ

多くの場合、直感的にはコードを再利用するために論理的凝集を実施したくなりがち。しかし論理的凝集を回避することには多くのメリットがある。論理的凝集を回避するためには機能的凝集の関数に分離、その関数を実行するようにすることで機能の再利用は可能。このときに機能的凝集の関数を呼び出すところは DRY ではなくなるけど、論理的凝集を回避することのほうがよい。そもそも、DRYは同じコードを書かないという意味ではない。目的が重複した機能を作らないの方が正しい。

5. DRY(Don't Repeat Yourself)

間違った DRY と正しい DRY を見分ける

DRY は、重複するコードは無駄なので関数化して再利用する的な事が多く見られますが、本質的にはコードという粒度ではなく、目的で見なくてはいけない。具体的には、継承の乱用だ。とっくにわかっているように、過剰な共通化は保守性・透明性・可読性を大きく損なう。

熟練のプログラマーたちのは DRY を否定するもの言葉を残す。

  • Prefer duplication over the wrong abstraction
    • 間違った抽象化より複製を好む
  • Avoid Hasty Abstractions
    • 急いだ抽象化の回避
  • Goodbye, Clean Code

それは DRY を適用する粒度を間違えるエンジニアが多いからだと思う。

これまで紹介してた論理的凝集をしたくなる理由なんてのは典型的な間違った DRY の考え方を表している。メソッド・クラス・パッケージ・単一のアプリ、これらの粒度で行いたい目的が重複してはいけないのが DRY の本当の意味。

6. 終わりに

重複しているコードの排除が目的の DRY は間違った DRY。それは継承を乱用した差分プログラミングへの批判を見ればよくわかる。要件変更のたびに設計の変更が強いられるのであれば、間違った DRY をしている兆候。機能的凝集の関数を重複して実行しているだけであれば、DRY を適用すべき理由にはならない。

凝集度や結合度の考え方を用いて、感覚的にコードな匂いを感じとり、問題となるコードを書かないことが重要。また、銀の弾丸という都合の良いものは存在しないので、常にトレードオフを意識して仕事しようぜ。