ゲームプログラマのための設計シリーズ:原理・原則編の記事です。
概要
- クラス内に状態を隠そうとしすぎたあまり、クラスの肥大化を招くことがある
- 大量にメンバ関数が増えるくらいなら、privateをやめてpublicにすることを検討する
- 処理からのアクセスが制限されていれば十分カプセル化できていると考える
本文
カプセル化の落とし穴
カプセル化とはオブジェクト指向の三大要素にも数えられる重要な概念です。
ここでは「外から見える必要のないものは、見えないよう隠す」ようにすることとします。
- クラスの文脈なら、メンバをできるだけprivateに持っていく
- ファイルの文脈なら、コードをできるだけ.cppに持っていく
などといった心がけが良く紹介されます。
これらはいい効果をもたらすことも多いのですが、うまく適用しないとカプセル化を強めることなく別の弊害を招いてしまうことがあります。
private変数にこだわりすぎ問題
敵のクラスを作りました。
class Enemy
{
public:
int mHP = 10; // 体力
int mAttack = 1; // 攻撃力
int mDefence = 1; // 防御力
};
メンバーをさらけ出すのはカプセル化に反しているので、privateに移動してそれらを操作するための関数をメンバに加えました。
class Enemy
{
public:
// ダメージを与える
void endamage(int attack)
{
// 相手の攻撃力から防御力を差し引いた値を、体力から減じる
const int damage = std::max(attack - mDefence, 0);
mHP -= damage;
// 体力は0未満にはならない
mHP = std::max(mHP, 0);
}
// バフをかける
void buff(int level)
{
mAttack += level;
mDefence += level;
}
// ...などなど
private:
int mHP = 10; // 体力
int mAttack = 1; // 攻撃力
int mDefence = 1; // 防御力
};
敵の状態を変更するには必ずメンバ関数を通す必要があるので、例えば体力がマイナスになる、など異常な状態になる危険が減りました。カプセル化の効果です。
今度は、敵の状態をログに出力したくなりました。mHPなどを見る手段はないので、メンバ関数にログ出力用のクラスを渡してEnemyクラス内で処理するようにしてみました。
class Enemy
{
public:
// ログを出力
void writeLog(Logger& logger) const
{
logger.writeLog(
std::format("HP : {}, Atk : {}, Def{}", mHP, mAttack, mDefence)
);
}
// ... あとは同じ
};
「Tell, Don't Ask(尋ねるな、命じよ)」の格言に沿った実装です。
しかし、同様の関数が増えたらどうでしょう?例えば、HUDにステータスを表示する、敵モデルの上にもUIを出したい、通信同期したい、Jsonにシリアライズしたいなどなど・・・
class Enemy
{
public:
void writeLog(Logger& logger) const;
void showHUD(HUD&) const;
void showUI(UI&) const;
void netSend() const;
void serializeToJson(JsonWriter&) const;
// ...などなど
};
Enemyクラスがずいぶんと肥大化してしまいました。他クラスへの依存も多いです。なんだかあまりよくない状況です。
そして、メンバ関数が増えていくとメンバ変数のカプセル化は結局弱まります。メンバ変数に触れる処理の量がどんどん増えていくからです。
参考:https://qiita.com/kobitnex/items/b7093143d06029021b93
またこうなってしまうとテストも大変になります。
Enemyクラスの状態をendamage/buff関数で頑張って作り上げないと、各メンバ関数に対するテストコードが書けません。
void HUDUnitTest()
{
// 敵の状態を頑張って望む状態に持っていく
Enemy enemy;
enemy.endamage(5); // これらの関数の仕様が変わると、テストが壊れてしまう・・・
enemy.buff(3);
// HUDのテスト
HUD hud;
enemy.showHUD(hud);
assert(hud.enemyHP() == 5);
assert(hud.enemyAtk() == 4);
}
Enemy::endamageの仕様が変わったりする度、ユニットテストが崩壊するので大変面倒なことになります。
別クラスのpublic変数に状態を出すことで、責務を分割する
一つの解は、以下のように状態を別クラスのpublic変数としてしまうことです。
// 敵の状態だけを集めた構造体
struct EnemyStatus
{
int mHP = 10; // 体力
int mAttack = 1; // 攻撃力
int mDefence = 1; // 防御力
};
// Enemyクラスは敵の状態変化にのみ責任を負う
class Enemy
{
public:
// 読み出しは許可する
const EnemyStatus& getStatus() const { return mStatus; }
// 書き込みはカプセル化
void endamage();
void buff();
private:
EnemyStatus mStatus;
};
// 敵の状態を利用する関数群 Enemyクラスのメンバ関数ではなくすことで、責務が分離された
void WriteEnemyLog(Logger& logger, const EnemyStatus&);
void ShowHUD(HUD& hud, const EnemyStatus&);
// などなど 追加する度にEnemy/EnemyStatusクラスを触ることもない
public変数にして大丈夫なの?と思われるかもしれませんが、もともとEnemyクラスを触っていた部分からはconstのEnemyStatusクラスにしかアクセスできず、カプセル化は実は保たれています。
「クラスの外に状態は絶対に見せないぞ」というよりは「処理の側から余分な情報へのアクセスが制限されていればOK」くらいの気持ちでいると、クラスの肥大化とバランスが取れると思います。
テストについても、Enemyクラスを通さず実装できるようになり、Enemyのメンバ関数の影響を受けずに安定するようになりました。
void HUDUnitTest()
{
// HUDのテスト Enemyに依存しなくなったので、好きなように状態を作ることができる
HUD hud;
ShowEnemyHUD(hud, {.mHP = 5, .mAttack = 4,.mDefence = 4 });
assert(hud.enemyHP() == 5);
assert(hud.enemyAtk() == 4);
}
注意点として、public変数を作るときはそのクラス(構造体)にそれ以外の機能を何も持たせない ことに気を付けてください。クラスの中からも外からもメンバを読み書きするのは混乱のもととなります。
(※ここからさらに踏み込んでmHPの非負性を担保するクラスを作るべきですが、別記事とします。)
余談:public変数 vs setter/getter
とにかくpublic変数はダメって教わったから・・・ということで、以下のようなコードが出てきた経験はないでしょうか。
class C
{
public:
// これは、mDataをpublicにするのと何が違うのか?
void setData(int d) { mData = d; }
int getData() const { return mData; }
private:
int mData = 0;
};
カプセル化という意味ではだいぶ弱い状態ですが
- setDataやgetDataの中に実装を追加する形で後から一括変更しやすい
- mDataに対する参照は取れないので、書き込みコードはsetDataを検索すれば網羅できる
という点はなかなかバカにできないです。
先のEnemyStatus構造体のように本当に状態を保持すること以外やることがない場合を除き、やはりsetter/getterでくるむのは最低限やっておいた方がいいように思います。
ただやはり、今回の例のEnemy::endamageなどのように読み書きルールを表現したメンバ関数を通して操作させるようにするのが基本になります。
(DDDでいうドメインモデル貧血症を避ける)