7. カプセル化する
あなたは単一責任の原則に基づき神クラスを分割しました。しかし、本当にそうでしょうか?あなたがクラスに閉じ込めたはずの関心事は、実は外に漏れ出てしまっているかもしれません。
ドメインモデル貧血症
例えば、ゲームのスコアを Score というクラスの Value というプロパティで管理しているとします。一見すると、スコアという関心事が専用のクラスに切り出されていて良さそうに思えます。しかし実はこれは良くない状況です。
// 一見よさそうだが...
public class Score
{
public int Value { get; set; }
}では、この状態でスコアを増やしたいときはどうすればいいでしょうか。もしスコアは10点ずつ加算されるというゲームルールだった場合、score.Value += 10 とでも書くことでしょう。しかしよく考えてください。この状況、おかしくないですか?あなたは単一責任の原則に基づいて、スコアという関心事を Score クラスに閉じ込めたはずです。しかし、このままでは「スコアは10点ずつ加算される」というスコアに関する知識が Score クラスの外に置かれることになります。関心事が漏れているのです。
// ❌ Bad: スコアのルールがScoreの外に漏れている
score.Value += 10; // 「10点ずつ加算」というルールがここに書かれている
score.Value = -114514; // 不正な値もセットし放題!
score.Value = int.MaxValue; // やりたい放題!もしここでスコアの計算方法が変われば、あなたは score.Value += 10 とでも書いている箇所を全て探して修正しなくてはいけません。これでは全く安全ではありません。
全ての原因は、Score クラスがフィールドとして Value を持っておきながらそれを扱うメソッドを持たず、実質的に値を保持するだけのデータクラスになっていることです。自分では何もできないので他のクラスに介護してもらわなければ動けず、他のクラスは不正し放題、関心事は漏れ放題。プロパティを使って玄人気分になっておきながら、実態としては全く無意味なことをしていたのです。このように実質的にただのデータクラスと化している状態のことを、ドメインモデル貧血症といいます。
尋ねるな、命じろ
ドメインモデル貧血症を治療する方法は、自分のことは自分でやるようにクラスを修正してやることです。これに関して次のような言葉があります。
尋ねるな、命じろ
(Tell, Don't Ask.)
Alec Sharp
この言葉が主張しているのは、クラスのデータを外部が尋ねてクラスの外で計算したり判断するのではなく、その計算や判断をそのデータを持っているクラス自身がやるように命令しなさいということです。
// ❌ Bad: 「データを尋ねて」外部で処理
if (score.Value < maxScore) // 外部がScoreの値を「尋ねて」
{
score.Value += 10; // 外部がスコアのルールを「計算して」
}
// ✅ Good: クラスに「命じる」
score.AddScore(); // 「スコアを加算しろ」と命令するだけ。ルールはScore内部に閉じている// ✅ Good: 自分のことは自分でやる Score クラス
public class Score
{
public int Value { get; private set; }
const int ScorePerAction = 10;
const int MaxScore = 99999;
public void AddScore()
{
Value = Mathf.Min(Value + ScorePerAction, MaxScore);
}
}
// → スコアのルールが Score に閉じ込められた!
// → 外部は AddScore() を呼ぶだけで、詳細を知る必要がないスコアのルールが Score に閉じ込められ、それを利用する外部クラスは Score クラスの構造やルールを知っている必要がなくなります。これでスコアのルールが変更されても、Score クラスの一箇所を変更するだけで済みます。
setterは悪だ
先程の例で Value のsetterをprivateに変更しました。これは、AddScore 以外の方法で値を変更されないようにするためです。これにより関心が外に漏れる心配はなくなりました。このように、第1回でも述べた通り、アクセスレベルは基本的にprivateが前提です。特にsetterに関しては余程のことがない限り公開してはいけません。
それどころか、getterとsetterそのものを悪とする主張もあるくらいです。
getterとsetterは悪だ
(Getters and Setters Are Evil)
Allen Holub
この主張は未だに議論が絶えません。現実的には、getterは必要になるときがあるため(UIでの表示など)、完全には悪ではありません。悪となり得るのは関心事を外部に流出させている場合です。一方、setterは擁護し難いので私は悪だと思います。publicなsetterを書くときは、本当にそれが必要かを立ち止まって考えてください。恐らく必要ないはずです。
知らない人とは話さない
友達の友達の友達に話しかけてはいけません。
// ❌ Bad: 知らない人と話しまくっている
player.GetInventory().GetSlot(0).GetItem().GetEffect().Apply(player);
// →「プレイヤーの持ち物のスロットのアイテムのエフェクトを適用して」
// → Playerの内部 → Inventoryの内部 → Slotの内部 → Itemの内部まで掘り下げているこのコードは、player の内部構造から Inventory、Slot、Item、Effect まで全ての内部構造を知っている前提で書かれています。つまり、知識が外に漏れ出ている状況です。もし Inventory の構造が変わったら? Slot が廃止されたら? このコードを使っている全ての箇所を修正しなければなりません。
これを防ぐ指針がデメテルの法則です。
直接の友達とだけ話しなさい
(Only talk to your immediate friends.)
Ian Holland
具体的には、あるメソッドが呼び出してよいのは以下のメソッドだけです。
- 自分自身のメソッド
- 自分のフィールドのメソッド
- 引数として渡されたオブジェクトのメソッド
- 自分が生成したオブジェクトのメソッド
// ✅ Good: 必要な操作をクラスのメソッドとして提供する
public class Player
{
Inventory inventory;
// Playerに「アイテムを使え」と命令する (Tell, Don't Ask!)
public void UseItem(int slotIndex)
{
inventory.UseItem(slotIndex, this);
}
}
public class Inventory
{
List<Slot> slots;
public void UseItem(int slotIndex, Player player)
{
slots[slotIndex].Use(player);
}
}
// 呼び出し側はPlayerのメソッドだけ呼べばよい
player.UseItem(0); // 内部構造を知る必要がない!デメテルの法則は、先ほどの「尋ねるな、命じろ」とも深く関係しています。他のオブジェクトの内部構造を尋ねてチェーンを辿る代わりに、やりたいことを直接命じるようにすれば、自然とデメテルの法則も守れます。
staticおじさんにならない
フィールドをstaticにすると値が各インスタンスで共通になり、更に public static にすればインスタンス無しにどこからでも値を取得したり書き換えたりできるようになります。一見便利で簡単なので、初心者は特にこれを使ってしまいがちです。しかし、これは絶対にやってはいけません。
理由はまずドメインモデル貧血症になること。先程説明したように、外から自由に変更できてしまうため、関心がクラスの外にダダ漏れになってしまいます。そして更に酷いことに、public static なフィールドはあらゆる場所からアクセス可能です。つまり、関心が漏れ出る範囲が圧倒的に広く、全く関係ない場所からでもアクセスできてしまいます。そうなると制御フローが滅茶苦茶になり、どこからアクセスされどこで値が変わったかの追跡も困難になり、不正な値も書き込まれ放題です。この世の終わりです。
// ❌ Bad: staticおじさんの末路
public class GameData
{
public static int score; // どこからでも読み書き自由!
public static int hp; // カプセル化?何それ美味しいの?
public static bool isDead; // 全部publicでstaticだ!便利!
public static string playerName;
}
// 全く無関係なクラスからアクセスし放題の地獄絵図
public class ScoreUI : MonoBehaviour
{
void Update()
{
GameData.score += 1; // UIがスコアを直接書き換えている!?
}
}
public class Enemy : MonoBehaviour
{
void OnDestroy()
{
GameData.score += 100; // 敵もスコアを書き換える
GameData.hp -= 5; // なぜか敵がプレイヤーのHPをいじっている
GameData.isDead = true; // 勝手に殺すな
}
}
public class TitleScreen : MonoBehaviour
{
void Start()
{
GameData.score = -999; // タイトル画面からスコアを破壊できる。終わりだ
}
}
// → 誰がいつどこで値を変えたか追跡不能。バグの温床。なりふり構わずstaticを乱用する愚か者をstaticおじさんと呼びます。オジサンにならないでください。
生焼けオブジェクトを作らない
クラスを正しく動作させるために、最初にフィールドなどを初期化する必要がある場合があります。しかしこのとき、インスタンスを生成した後に外から値を個別に代入したりして初期化してはいけません。
これもドメインモデル貧血症と同じ理由です。使う前に外から個別に初期化してやらないといけないということは、自分一人では正しく動作することができないということです。使う前に初期化しないといけないということを外のクラスが知る必要がありますし、もしこれを忘れれば、初期化前の不正状態のフィールドにアクセスすることになり、バグが発生します。このようなオブジェクトを生焼けオブジェクトといいます。
// ❌ Bad: 生焼けオブジェクト
public class Weapon
{
public string Name;
public int Attack;
public float Range;
public void PrintInfo()
{
Debug.Log($"{Name}: ATK={Attack}, Range={Range}");
}
}
// 使う側はこう書かないといけない
var weapon = new Weapon(); // この時点では全て初期値(null, 0, 0)→ 生焼け!
weapon.Name = "Sword"; // あ、Name入れなきゃ
weapon.Attack = 30; // Attackも入れなきゃ
// weapon.Range = 1.5f; // ← これ忘れたらどうなる? → Range=0のバグ武器の完成
weapon.PrintInfo(); // 「Sword: ATK=30, Range=0」 ...?
// → 初期化を忘れると不正な状態のまま使えてしまう。
// → 何をセットすべきか使う側が全て知っていなければならない。正しくは、インスタンスが生成された時点でフィールドに正しい値が設定されているべきです。そのため、コンストラクタで値を受け取り、インスタンスの生成と同時に値が初期化されるようにします。こうすることで、インスタンスが常に安全な状態で存在することができます。
// ✅ Good: コンストラクタで完全に初期化する
public class Weapon
{
public string Name { get; }
public int Attack { get; }
public float Range { get; }
public Weapon(string name, int attack, float range)
{
Name = name; // 生成と同時に全フィールドが初期化される
Attack = attack;
Range = range;
}
public void PrintInfo()
{
Debug.Log($"{Name}: ATK={Attack}, Range={Range}");
}
}
// 使う側
var weapon = new Weapon("Sword", 30, 1.5f); // この時点で完全な状態!
weapon.PrintInfo(); // 「Sword: ATK=30, Range=1.5」
// → 引数を渡さないとコンパイルエラーになるので、初期化忘れがあり得ないところが大変、MonoBehaviourを継承しているクラスはコンストラクタが使えません!つまり生焼けを回避する方法が無く、Start()なりAwake()なりで初期化するか、使う前に初期化メソッドを外から忘れずに呼んであげるかしか方法がありません。カス!(これが可能な限りPure C#でコードを書きたい動機の一つです)
// 😢 MonoBehaviourは生焼けを避けられない
public class WeaponComponent : MonoBehaviour
{
string weaponName;
int attack;
// コンストラクタが使えないので初期化メソッドを用意するしかない
public void Initialize(string name, int atk)
{
weaponName = name;
attack = atk;
}
// Initialize()を呼び忘れたら weaponName=null, attack=0 で動く。怖い
}
// 使う側
weapon.Initialize("Sword", 30); // ← これを呼び忘れたら? → 生焼け!WARNING
init アクセサの危険性
C#9.0から、プロパティに init アクセサが使えるようになりました。init-onlyなプロパティはget-onlyなプロパティと基本的に同じで、初期化時しか値をセットすることができません。しかし、init-onlyはget-onlyと違い、オブジェクト初期化子でも値を設定することができるようになります。
一見便利そうに見えますが、init-onlyなプロパティには落とし穴があります。問題なのは、オブジェクト初期化子でプロパティを初期化するとき、初期化を忘れてもコンパイルエラーにならないことです。つまり、初期化を強制することができないのです。これではまさに生焼けの状態になってしまいます。
C#11から、オブジェクト初期化子で値の初期化を強制する required キーワードが登場しました。これを使えば確かにオブジェクト初期化子を使っても生焼けは回避できます。しかし、UnityはC#9.0で動いているため、これが使えません。つまり、現状のUnityでinit-onlyのプロパティを使うことは、生焼けのリスクと隣り合わせであるということです。
それなら、get-onlyにしてコンストラクタで初期化を強制した方が安全です。コンストラクタに引数を渡し忘れるとコンパイルエラーにできます。単に初期化時にのみ値をセットできるようにしたいだけなら、わざわざ危険なinit-onlyを使うべきではないと思います。
カプセル化・凝集度
以上のように、責務が単一になるようにクラスを分割し、その関心事についてのデータとそれを操作するロジックを集約して閉じ込める。外のクラスはその知識を知ることなく利用でき、クラスの状態が常に正しく、変更にも強い構造になる。これをカプセル化といいます。
ここで、凝集度という言葉があります。凝集度とは、ある目的に関する知識がどれだけ集約されているかを表す指標です。ここにおいて、単一責任を意識し正しくカプセル化するということは凝集度を高めるということに他なりません。一般に、凝集度は高い方が良いとされています。凝集度が高いと、関連するコードが一箇所に集約されて可読性が向上し、仕様変更の影響範囲も限定されるからです。正しくカプセル化し、高凝集を目指しましょう。