凝集度とは?
凝集度とは、ソフトウェア工学や情報科学などの分野で使われる概念。
プログラムやシステム内のモジュールやコンポーネントがどれだけ関連して一緒に動作しているかを表す尺度。
一般に凝集度は高ければ高いほど良い、逆に低いと良くないとされている。
低凝集な実装とは?
あるシステムのユーザーをあらわすUserクラスを考える。
属性と、コンストラクタ、setter/getterがあるとして、こんな感じになる。
class User{
//属性
private int age;
private String name;
//コンストラクタ
public User(int age, String name){
this.age = age; //年齢
this.name = name; //名前
}
//getter
public int getAge(){
return this.age;
}
public String getName(){
return this.name;
}
//setter
public void setAge(int age){
this.age = age;
}
public void setName(String name){
this.name = name;
}
}
例えばこのUserクラスをつかって、ユーザーを新規作成するcreateUserメソッドを実装する。
Userクラスの属性に関して、
年齢:age → 0以上の整数
名前:name → null以外の文字列
としたいので以下のように実装する。
//ユーザー新規作成処理
public void createUser(int age, String name){
if(age < 0){
throw new IllegalArgumentException("年齢が0以上でありません");
}
if(name == null){
throw new NullPointerException("名前を設定してください");
}
User user = new User(age, name);
//略
}
局所的にはこのコードは全く問題ないようにみえるが、しかし次に、ユーザー情報を更新するupdateUserメソッドもつくることになったとする。
//ユーザー情報更新処理
public void updateUser(int age, String name){
if(age < 0){
throw new IllegalArgumentException("年齢が0以上でありません");
}
if(name == null){
throw new NullPointerException("名前を設定してください");
}
user.setAge(age);
user.setName(name);
}
updateUserメソッドの方にも、Userクラスに値を設定する前の入力チェックが必要となるので、createUserメソッドと同じ処理がかかれ、コードは重複することになる。
これのなにがまずいか??
このようなコードの重複が引き起こす弊害は、仕様変更が必要となった時に表れる。
たとえばname属性のルールとして、
- nullを不許可
- 3文字以上の文字列である ← new!
というルールを追加するという仕様変更があったとする。
nameのルールをcreateUser、updateUserに記述していたので、書き換える箇所が最低2箇所。
//ユーザー新規追加処理
public void CreateUser(int age, String name){
if(age < 0){
throw new IllegalArgumentException("年齢が0以上でありません");
}
if(name == null){
throw new NullPointerException("名前を設定してください");
}
//New!
if(name.length() < 3){
throw new IllegalArgumentException("名前は3文字以上で設定してください");
}
User user = new User(age, name);
}
//ユーザー情報更新処理
public void UpdateUser(int age, String name){
//中略
if(age < 0){
throw new IllegalArgumentException("年齢が0以上でありません");
}
if(name == null){
throw new NullPointerException("名前を設定してください");
}
//New!
if(name.length() < 3){
throw new IllegalArgumentException("名前は3文字以上で設定してください");
}
user.setAge(age);
user.setName(name);
}
今後の拡張によってはより多くの箇所(Userクラスを更新するすべての箇所)を修正しなければならない。
つまり仕様変更の際に修正しなければならない箇所の数が跳ね上がり、変更コストが高くなる。(このようなソースコードを変更容易性が低いという。)
今回のようにデータ(ageとかnameとか)とそのデータに関するロジック(ageとかnameとかに関するルール)が分離しているコード、凝集度は低くなり、変更容易性が低くなる。
どうすれば高凝集な実装となる?
結論
・データ(インスタンス変数)
・そのデータに関するロジック(インスタンス変数を正常に制御するメソッド)
を一つのクラスにまとめておく
そしてインスタンス変数は直接公開せず、インスタンス変数を正常に制御するメソッドのみを通してアクセスできるようにする。
つまりいわゆるカプセル化をする。
先ほどの例の続きでいくと、(だいぶ雑だが)例えばこういう実装にする
class User{
//属性
private int age;
private String name;
public User(int age, String name){
if(age < 0){
throw new IllegalArgumentException("年齢が0以上でありません");
}
if(name == null){
throw new NullPointerException("名前を設定してください");
}
this.age = age;
this.name = name;
}
}
データを正しく制御するロジックをUserクラスのメソッド(今回はコンストラクタ)にもたせておく。
createUserとupdateUserクラスからはage, nameのバリデーションチェックのロジックはなくなる。
//ユーザー新規追加処理
public void CreateUser(int age, String name){
User user = new User(age, name);
userRepository.save(user);
}
//ユーザー情報更新処理
public void UpdateUser(int age, String name){
User user = new User(age, name);
userRepository.update(user);
}
この例であれば、先ほどの”nameは3文字以上の文字列”というルールを追加するという仕様変更があった際も修正箇所がUserクラス内に閉じる。
class User{
//略
public User(int age, String name){
if(age < 0){
throw new IllegalArgumentException("年齢が0以上でありません");
}
//ここを追加するだけで良い
if(name.length() < 3){
throw new IllegalArgumentException("名前は3文字以上で設定してください");
}
if(name == null){
throw new NullPointerException("名前を設定してください");
}
this.age = age;
this.name = name;
}
このようにデータとそのデータに関するロジックを近く(同じクラス内に)置いておくことで凝集度が高くなり、変更が容易となる。
本記事ではさらに、より実践的なカプセル化のパターンとして、値オブジェクトパターンとファーストクラスコレクションパターンを紹介する。
カプセル化の具体的なパターン1 値オブジェクト
値オブジェクト(value Object)とは値をクラス(型)として表現する設計パターン。
言い換えるとデータのラッパーであり、正確には
- インスタンス変数を初期化するコンストラクタ
- インスタンス変数を正常に操作することを保証したメソッド
から構成されるクラス。
具体的にどのように値オブジェクトを実装していったらよいか、みていく。
1.コンストラクタで確実に正常値を設定する
お金を表すMoneyクラスを値オブジェクトとして実装していく。
まずは先ほどと同じ良くない例として、ロジックのないコンストラクタとgetter/setterのみをもつMoneyクラス
class Money{
private int amount; //金額値
private Currency currency; //通貨単位
//コンストラクタ
public Money(int amount, Currency currency){
this.amount = amount;
this.currency = currency;
}
//getter
public int getAmount(){
return this.amount;
}
public String getCurrency(){
return this.currency;
}
//setter
public void setAmount(int amount){
this.amount = amount;
}
public void setCurrency(Currency currency){
this.currency = currency;
}
}
課題:不正値を渡せてしまう
Money money = new Money(-100, null);
下記の正常値のルールをコンストラクタに実装する
- 金額 amount: 0以上の整数
- 通貨 currency: null以外
class Money{
// 省略
public Money(int amount, Currency currency){
if(amount < 0){
throw new IllegalArugumentException("金額が0以上でない");
}
if(currency === null){
throw new NullPointerException("通貨を指定してください");
}
this.amount = amount;
this.currency = currency;
}
}
2.計算ロジックをデータ保持側(値オブジェクト)に寄せる
class Money{
// 省略
public void add(int other){
this.amount += other;
}
}
ただし、まだいくつか課題アリ
3.不変で思わぬ動作を防ぐ
上記例のようなインスタンス変数の上書きは、理解を難しくする。
変数の値が変わる前提だと、いつ変更されたのか、今の値がどうなっているのかをいちいち気にしなければならないし、仕様変更で処理が変わった時、意図しない値に書き換わる、いわゆる「思わぬ副作用」が容易に発生する。
これを防止するために、インスタンス変数は不変(イミュータブル)にする。
class Money{
// インスタンス変数をfinalで不変に!
final private int amount;
final private Currency currency;
public Money(int amount, Currency currency){
// 省略
this.amount = amount;
this.currency = currency;
}
}
4.変更したい場合は新しいインスタンスを作成する
インスタンス変数は不変にした場合、さきほどのaddメソッドのような変更ができなくなるじゃないかと思うかもしれないが、インスタンス変数の中身を変更するのではなく、変更値を持ったMoneyクラスのインスタンスをまた新たに生成する。
class Money{
// 省略
public Money add(int other){
int added = this.amount + other;
return new Money(added, currency);
}
}
加算値を持ったMoneyのインスタンスを生成し、返すロジックにした。
これで不変にしつつ、変更できるようになった。
5.メソッド引数やローカル引数も不変にする
メソッドの引数は、一般にメソッド内で変更可能。
void doSomething(int value){
value = 100; //引数が変更できてしまう。。
}
しかし途中で値が変化すると、どう変化したのか追うのが難しくなるし、バグの原因にもなる。したがって引数は不変にしておきたい。
class Money{
// 省略
public Money add(final int other){ //引数を不変に!
int added = this.amount + other;
return new Money(added, currency);
}
}
ローカル変数も同様に、途中で値が変化すると意味が変わってしまう。不変にしておく。
class Money{
// 省略
public Money add(final int other){
final int added = this.amount + other; //ローカル変数も不変に!
return new Money(added, currency);
}
}
6.「値の渡し間違い」を型で防止する
// bad
final int ticketCount = 3; //チケット枚数
money.add(ticketCount); //!?
なんと金額ではないチケット枚数の値を、金額として加算できてしまう。
渡し間違いを防ぐにはMoney型どうしのみで加算するメソッド構造にする。
class Money{
// 省略
public Money add(final Money other){ //引数の型をMoney型に!
final int added = this.amount + other.getAmount();
return new Money(added, currency);
}
}
ついでに異なる通貨単位での加算も防止する。通貨単位が異なる場合、例外を投げる。
class Money{
// 省略
public Money add(final Money other){
// 通貨単位が異なる場合、例外を投げる
if(!currency.equals(other.getCurrency())){
throw new IllegalArgumentException("通貨単位が違います");
}
final int added = this.amount + other.getAmount();
return new Money(added, currency);
}
}
これでバグに強い、頑強な加算メソッドに仕上がった。
あらためて値オブジェクトとは
値をあらわすインスタンス変数と、その値に関するロジックをひとつのクラスにまとめて、primitiveな値をカプセル化しておくパターン。
今回のMoneyクラスの例で言うと、金額の制約条件(0円以上)をコンストラクタにカプセル化したり、金額計算ロジックが別のクラス(Moneyクラスを呼び出す側のクラス)にバラバラにかかれないよう、Money.addメソッドとして備えている。これにより高凝集を果たしている。
またMoney.addには同じMoney型のみ渡せるので、意図の異なる値を間違って渡してしまうミスも防いでいる。
カプセル化の具体的なパターン2 ファーストクラスコレクション
コレクション処理も低凝集に陥りやすい。RPGゲームのパーティを例に説明する。
// bad
// フィールドマップ上の制御を担当するクラス
class FieldManager{
// メンバーを追加する
public void addMember(List<Member> members, Member newMember){
if(members.stream().anyMatch(member -> member.id == newMember.id))){
throw new RuntimeException("既にパーティに加わっています");
}
if(members.size() == MAX_MEMBER_COUNT){
throw new RuntimeException("これ以上メンバーを追加できません");
}
members.add(newMember);
}
// パーティメンバーが一人でも生存している場合trueを返す
public boolean partyIsAlive(List<Member> members){
return members.stream().anyMatch(member -> member.isAlive());
}
}
FiledManagerはフィールドマップ上の制御を担当するクラス。パーティにメンバーを追加するaddMemberメソッドと、パーティメンバーが生存しているかどうかを返すpartyIsAliveメソッドが定義されている。
ゲーム中、メンバーが追加されるタイミングはフィールドマップ中だけではない。重要イベント中に仲間が追加されるロジックが以下のように実装されるかもしれない。
// ゲーム中の特別イベントを制御するクラス
class SpecialEventManager{
// メンバーを追加する
public void addMember(List<Member> members, Member newMember){
if(members.stream().anyMatch(member -> member.id == newMember.id))){
throw new RuntimeException("既にパーティに加わっています");
}
if(members.size() == MAX_MEMBER_COUNT){
throw new RuntimeException("これ以上メンバーを追加できません");
}
members.add(newMember);
}
// ↑FieldManagerクラスとおなじロジックが実装されている???
}
SpecialEventManagerはゲーム中の特別イベントを制御するクラスである。
FieldManagerと同様のメンバー追加メソッドaddMemberが、SpecialEventManagerにも実装されており、重複コードとなっている。
FieldManager.patryIsAliveの重複ロジックが別のクラスに実装されてしまう可能性もある。
以下のBattleManager.membersAreAliveは、FieldManager.patryIsAliveとは名前も実装も違うが、ロジックの振る舞いは同じであり、見かけだけが異なる重複コードである。
// 戦闘を制御するクラス
class BattleManager{
// パーティメンバーが一人でも生存している場合trueを返す
public boolean membersAreAlive(List<Member> members){
boolean result = false;
for(Member each: members){
if(each.isAlive()){
result = true;
break;
}
}
return result;
}
// ↑FieldManager.patryIsAliveとおなじロジック???
}
このようにコレクションに関する処理は、あちこちに実装されてしまいがちであり、低凝集になりがちである。どうすればよいか。
コレクション処理をカプセル化する(ファーストクラスコレクション)
ファーストクラスコレクション(First Class Collection)とは、コレクションに関するロジックをカプセル化する設計パターン。
メンバーのコレクションListをインスタンス変数にもつクラスとして設計する。そしてメンバーの集まりは「パーティ」と呼ばれるのでListを持つクラスをPartyと命名する。
// ファーストクラスコレクション
class Party{
private final List<Member> members;
Party(){
this.members = new ArrayList<Member>();
}
}
さらにインスタンス変数を操作するロジックをこのPartyクラスに移動する。
先ほどのメンバー追加用のaddMemberメソッドを、addメソッドと命名してPartyへ移動する。ただしそのまま追加すると、membersの要素が変化する副作用が発生する。
class Party{
// 中略
void add(final Member newMember){
this.members.add(newMember);
// △ インスタンス変数membersが変化してしまう。membersの変化は副作用となる。
}
}
副作用を防ぐために一手間加える。新しいリストを生成し、そのリストへ追加する作りにする。
class Party{
// 中略
Party add(final Member newMember){
List<Member> adding = new ArrayList<>(members);
adding.add(newMember);
return new Party(adding);
}
}
これで元のmembersは変化せず、副作用を防げる。
そのほかメンバーが一人でも生存しているか判定するメソッドをisAliveと命名して移動する。またメンバーを追加可能か調べるロジックをexists, isFullとした。最終的にコードは次のようになる。
class Party{
static final int MAX_MEMBER_COUNT = 4;
private final List<Member> members;
Party(){
this.members = new ArrayList<Member>();
}
private Party(List<Member> members){
this.members = members;
}
Party add(final Member newMember){
if(exists(newMember)){
throw new RuntimeException("既にパーティに加わっています");
}
if(isFull()){
throw new RuntimeException("これ以上メンバーを追加できません");
}
List<Member> adding = new ArrayList<>(members);
adding.add(newMember);
return new Party(adding);
}
boolean isAlive(){
return this.members.stream().anyMatch(member -> member.isAlive());
}
boolean exists(final Member member){
return this.members.stream().anyMatch(member -> member.isAlive());
}
boolean isFull(){
return this.members.size() == MAX_MEMBER_COUNT;
}
}
コレクションと、コレクションを操作するロジックが、一つのクラスにギュッと凝集する構造になった。
まとめ
本記事で紹介した値オブジェクト、ファーストクラスコレクションパターンのように、データとそのデータに関するロジックを近いところ(一つのクラス)に置いておき、データをカプセル化することで、高凝集かつ変更容易性の高いコードを実現できる。