概要
メンテナンスしやすい/テストしやすいコードを書くための知識として以下を紹介してます。
- 凝集度
- 結合度
- 循環的複雑度
ちなみに、言語はJavaで書いてます(他の言語は読み替えて下さい)
あと結合度の部分はもしかしたら間違えた理解してるかも。その場合ご指摘ください。
(以外と本やサイトによって書かれてることがバラバラなので…(´・ω・`))
それぞれの説明
凝集度
モジュール強度とも言われる。あるコードがどれだけそのクラスの責任分担に集中しているかを示す尺度。
ひとつの役割のためだけに存在(全てのメソッド群がその役割のためだけに存在)しているクラスは凝集度が高い。
逆に、各メソッドに共通性がなく、様々な役割を担っているクラスは凝集度が低い。
凝集度が低いと何が悪いか
- そのクラスが何をするものなのか理解するのが難しくなる
- 保守やテストが難しくなる
- 再利用しづらくなる
凝集度が低いクラスはメンバ変数を多く持つ傾向がある
凝集度が低いと、メンバ変数が多くなる傾向がある。メンバ変数はクラスの状態そのものであるため、メンバ変数が多いとそのインスタンスが取りうる状態のパターンが増え、複雑度が増して保守性が著しく低下する。またテストもしづらくなり、パターンも増える。
凝集度の高いクラスの例: Stringクラス
「文字列を扱う」というためだけにこのクラスは存在しており、他の役割を担っていない。
Stringクラス
具体的な例
Price(価格)というクラスを考えてみる。そのクラスは、価格のデータを扱うメソッドのみを持つ。
public class Price {
private final int value;
public Price(int value) {
this.value = value;
}
public Price() {
this(0);
}
/** 価格を足す */
public Price add(Price other) {
return new Price(this.value + other.value);
}
/** 価格をかける */
public Price multiply(int amount) {
return new Price(this.value * amount);
}
/** 割引する */
public Price discount(double percentage) {
return new Price(this.value * percentage);
}
/** 価格をフォーマットして返却 */
public String format() {
return NumberFormat.getCurrencyInstance().format(this.value);
}
public void print() {
System.out.println(this.format());
}
}
// 使う側
Price price = new Price(1000);
price.print(); // ¥1,000
System.out.println(price.format()); // ¥1,000
System.out.println(price.add(new Price(500)).format()); // ¥1,500
System.out.println(price.discount(0.9).format()); // ¥900
// 補足: Priceはイミュータブルになっており、最初設定した値が変更されないため
// 最後のdiscountを呼び出した際にも value = 1000 で計算が行われる
凝集度を高くするには
- 単一責務の原則を意識
- クラス内に関係のないメソッドを作らない
- メンバ変数を参照していないメソッドは外だしする
- privateメソッドは外だしできないか検討する
- メンバ変数は極力少なくする
- クラスやメソッド内のステップ数が大きくなってる場合は分割を検討する
結合度
簡単に言うと「使う側と使われる側のクラスやメソッドが、どれだけ内容を知っているか(どれだけ依存しているか)」。
結合度が高いほど、保守性が落ちテストも非常にしづらくなる。
結合度の種類
以下の順に結合度は高くなる。
メッセージ結合
引数のないメソッドの呼び出ししかしておらず、返り値もない。理想の形。
public class Main {
private final Price price;
public Main(Price price) {
this.price = price;
}
public void output() {
this.price.print();
}
}
Price price = new Price(1000);
Main main = new Main(price);
main.output(); // ¥1,000 と表示される
上記は、 Main
クラスと Price
クラスがデータを共有しておらず、 print()
の実装内容に Main#output()
が依存していない。
Price
クラスは Main
クラスのことは全く知らないし、 Main
クラスも Price#print()
が中で何をしているのか気にしていない。たとえば print()
メソッドの実装を変更してファイルに文字列を出力するようにしたとしても、実際には何もしなかったとしても、 Main#output
は関知しない。
このような結合は メッセージパッシングとも呼ばれ、このようなオブジェクト間のやりとりを メッセージング という。相手オブジェクト側に作業を完全に委任しており、ちょうど誰かに「これやっといて」とだけ伝えるようなやりとりになるため、メッセージングという名前がついた(んだと思う)。
※補足:上記の場合は報告も不要な感じの実装になってるけど「作業完了したら教えて」というようなコールバックの実装もメッセージ結合を維持したまますることができる。
データ結合
単純なデータの受け渡しだけを行う。
// データ結合の例
public class NameFormatter {
public String format(String firstName , String familyName) {
return new StringBuilder().add(familyName).add(" ").add(firstName).toString();
}
}
public class Main {
private final String firstName;
private final String familyName;
public Main(String firstName, String familyName) {
this.firstName = firstName;
this.familyName = familyName;
}
public void printName() {
NameFormatter formatter = new NameFormatter();
System.out.println(formatter.format(this.firstName, this.familyName));
}
}
Main main = new Main("一貴", "小田");
main.printName(); // 小田 一貴
上記の Main#printName()
内の NameFormatter
との結合はデータ結合となる。
データ結合では 「必要な情報」 のみ共有 する。なお、ここでいう「情報」とは以下が該当する。
- メソッド(型が該当する)
- 実際のデータ(値が該当する)
データ結合は、同じ入力に対して常に同じ出力になる。また、NameFormatter
へは必要な情報しか渡していないため、 printName()
ではNameFormatter#format
から最終的に返却される値を使うだけでよく、内部でどのような実装がされているのかは関知しなくていい。
一般的に、データ結合は引数として渡すデータをイミュータブルまたはプリミティブ型 あるいはインタフェースを渡す場合の結合を指す(と思う)。ただ、参照型のオブジェクトを渡すケースでもデータ結合に該当する場合もある(呼び出し先のメソッドが必要な情報しか公開されていない、等)。
スタンプ結合
データの構造体(つまり、複数のメンバ変数を持つインスタンス)を引数に渡す。
public class Item {
// コンストラクタとgetterとsetterはそれぞれあるという体で
private String name;
private String group;
}
public class CashRegister {
public String format(Item item, Price price) {
return item.getName() + ":" + price.format();
}
}
CashRegister regi = new CashRegister();
System.out.println(regi.format(new Item("スマブラ", "ゲーム"), new Price(6800))); //スマブラ:¥6,800
スタンプ結合の場合、その構造体の中で、呼び出し先が使わないデータが参照できる(つまり、呼び出し先は不要な情報も知っている)。上記の場合は group
が使われていないが、やろうと思えば、 CashRegister#format
内でいずれも参照することもできる。
単に参照だけならそれほど問題にはならないが、場合によっては呼び出し先のメソッド内でデータの値を書き換えられる可能性もある。
なのでスタンプ結合の場合に呼び出し元が正しく使おうと思った時、呼び出し先のメソッド内でどのデータを扱っているのか、ある程度知っている(関知している)必要がでるケースがある。
上記は、CashRegisterを以下のようにすれば呼び出し元とはデータ結合になる。
ただし、呼び出し元とItem、また呼び出し元とPriceはスタンプ結合になっている
public class CashRegister {
public String format(String item, int price) {
return item + ":" + price;
}
}
CashRegister regi = new CashRegister();
Item item = new Item("スマブラ", "ゲーム");
Price price = new Price(6800);
System.out.println(regi.format(item.getName(), price.format())); //スマブラ:¥6,800
このように、クラスを利用するかぎりスタンプ結合は簡単にはなくせない。いずれもインタフェースを使うことで、データ結合またはメッセージ結合にまで結合度を下げることはできるけど、長くなるから割愛。
※個人的には、中規模のプロジェクトの場合スタンプ結合までは普通に許容するという方がオブジェクト指向原理主義に走るより現実的にいいと思う。
制御結合
与えられた引数の内容によって戻り値が変わるようなケース。
public class Item {
// コンストラクタとgetterとsetterはそれぞれあるという体で
private String name;
private Price unitPrice;
}
public class SaleService {
public Price fixPrice(Item item, int amount, boolean discount) {
Price newPrice = item.getUnitPrice().multiply(amount);
if (discount == false) {
return newPrice;
}
return newPrice.discount(0.90);
}
}
Item item = new Item("スマブラ", new Price(6800));
SaleService service = new SaleService();
System.out.prinln(service.fixPrice(item, 2, false)); // ¥13,600
System.out.prinln(service.fixPrice(item, 2, true)); // ¥12,240
この制御結合は、とりうるパターンによって同じような値を引数に入れてもパターンの数だけ返却値が異なる。つまりはとりうるパターンが引数に依存する。
この制御結合は、ポリモーフィズムを用いることでスタンプ結合以下にまで結合度を下げることができる。
(ただ、個人的には制御結合も3〜4パターン程度なら許容してもいいと思ってる)
外部結合
外部結合は、1つのグローバルなリソースを複数のモジュールが参照している状態を指す。
ここから先は、この結合を気軽に許容するとテストが非常にしづらくなる。
なお、グローバルなリソースとはたとえば以下のものを指す。
- グローバルなstatic変数
- Singletonに保持された編集可能なデータ
- ファイル
- データベースのリソース
- 別サーバーからの返却値(Web APIなど)
これらは、他のインスタンスなどで変更した値が、他の参照するクラス全体に影響させる。
またそれらリソースの構造が変更されると、呼び出している全てのクラスを修正する必要がでてくる。
テスタブルなコードを書くには、この外部結合以降の結合をしているコードをいかに少なくするかが重要となる(と思ってる)。
共通結合
グローバルなリソースの複数のデータをいろんなクラスが参照しあっている状態。
外部結合よりよりカオスなことになる。
内容結合
他のクラスの公開していないメソッドや変数名まで知っており、直接参照 / 呼び出ししてるような結合。内容結合では、依存してるクラスの内部実装(公開されていない実装)が変わると直に影響を受けるため、依存先の変数名を変えただけでも影響を受ける。
※ リフレクションを利用した場合にはそのままクラッシュにつながる危険性がある。
結合度を低くするには
以下を心がけることで、結合度は低く抑えることができる。
- データを極力直接参照しない
- setter / getter は極力使わない(特にsetter)
- インタフェースの作成を心がける
- 特に外部との連携をするクラスは外だし&インタフェース化する
- 依存性逆転の原則
- Dependency Injection を利用する
- Guiceなど、色々なDIライブラリがある
- ファクトリメソッドと組み合わせるとより効果的
- デメテルの法則を意識
循環的複雑度(サイクロマティック複雑度)
簡単にいうと、「if文/for文/while文/switch文/try文/catch文」などの数。それらが何もないと1で、分岐などの数があるほど1ずつ増していく。
例えば以下のメソッドの循環的複雑度は4となる。
public void hogehoge(int test) {
if (test > 5 ) {
// doSomething1
} else if (test < 1) {
// doSomething2
}
for (int i = 0; i < 5; i++) {
// doSomething3
}
}
循環的参照度が高いと
当然ながら、テストのパターンが増える。
また、可読性も落ち、メンテナンスがしづらくなる。
循環的参照度を下げる方法
- メソッドを分割する
- ネストを浅くする(早期リターンを心がける)
- DRY原則を守る
- ポリモーフィズムで処理を分ける
- StreamAPIなどを活用する
まとめ
- 凝集度は高い方が良い
- 結合度は低い方が良い
- 循環的複雑度は低い方が良い