Flat Leon Works

アプリやゲームを作ってます。

オブジェクト指向プログラミングでのクラス分けのコツは役割分担

役割分担でクラス化していく

プログラムを書いていきソースコードが増えるに従って、プログラムの複雑さはどんどん増していきます。プログラムが複雑さが増すと、機能の実装や修正や追加が行いにくくなっていきます。そしてこの状態が悪化していくとスパゲティコードと呼ばれる手に負えない状態になってしまいます。そうならないためにプログラムをクラスや関数に分けていくのですが、このときの分け方の目安になるのが「役割分担」です。

役割分担とはどういうことなのかというと、クラスを役割の担当者に見立てるということです。「○○係にする」というふうに考えてもいいかもしれません。

class Enemy
{
    int m_MaxHP;     // 最大HP
    int m_CurrentHP;// 現在のHP
public:
    int GetMaxHP( void ) const { return m_MaxHP; }
    int GetCurrentHP( void ) const { return m_CurrentHP; }
    void AddDamage( int value ) { m_CurrentHP -= value; NormalizeHP(); ]
    void RecoverDamage( int value ) { m_CurrentHP += value; NormalizeHP(); ]
    void SetMaxHP( int value ) { m_MaxHP = value; NormalizeHP(); }
    void NormalizeHP( void ) { if ( m_CurrentHP < 0 ) { m_CurrentHP = 0; } else if ( m_CurrentHP > m_MaxHP ) { m_CurrentHP = m_MaxHP; } }
};

例えば、この敵(Enemy)クラス。HP関係の処理コードをHPクラスというHPの担当者に任せるようにすることで敵クラスを見やすくすることができます。

class HP
{
    int m_MaxHP;     // 最大HP
    int m_CurrentHP;// 現在のHP
public:
    int GetMaxHP( void ) const { return m_MaxHP; }
    int GetCurrentHP( void ) const { return m_CurrentHP; }
    void AddDamage( int value ) { m_CurrentHP -= value; NormalizeHP(); ]
    void RecoverDamage( int value ) { m_CurrentHP += value; NormalizeHP(); ]
    void SetMaxHP( int value ) { m_MaxHP = value; NormalizeHP(); }
    void NormalizeHP( void ) { if ( m_CurrentHP < 0 ) { m_CurrentHP = 0; } else if ( m_CurrentHP > m_MaxHP ) { m_CurrentHP = m_MaxHP; } }
};

class Enemy
{
    HP m_HP;
public:
    const HP& GetHP( void ) const { return m_HP; }
};

役割を決めることで実装すべきものと実装すべきでないものが明確になる

クラス化で重要なのはしっかりと役割を決めることです。役割をしっかりと決めることで、そのクラスに何を実装すべきで何を実装すべきでないのかが明確になります。

例えば、敵のHPが半分以下の場合に攻撃力が上がる仕様だったとして以下のように実装したとします。

class HP
{
    int m_MaxHP;     // 最大HP
    int m_CurrentHP;// 現在のHP
public:
    int GetMaxHP( void ) const { return m_MaxHP; }
    int GetCurrentHP( void ) const { return m_CurrentHP; }
    void AddDamage( int value ) { m_CurrentHP -= value; NormalizeHP(); ]
    void RecoverDamage( int value ) { m_CurrentHP += value; NormalizeHP(); ]
    void SetMaxHP( int value ) { m_MaxHP = value; NormalizeHP(); }
    void NormalizeHP( void ) { if ( m_CurrentHP < 0 ) { m_CurrentHP = 0; } else if ( m_CurrentHP > GetMaxHP() ) { m_CurrentHP = GetMaxHP(); } }
    bool IsEnemyAttackUp( void ) const { return m_CurrentHP <= m_MaxHP * 0.5; }
};

これは良くない実装方法です。何が良くないかというと、HPクラスはHPの値を管理するだけの役割のクラスなのに、敵の攻撃力があがるかどうかというメソッドを持ってしまっているところです。確かにHPが関係していますが、HPクラスは敵や攻撃力について関知するべきではありません。

クラス名が役割を表すことになる

クラス化は役割を決めることが重要と説明しました。そして役割を正しくクラス名にすることもまた重要です。なぜならソースコード中では役割はクラス名としてしか出現しないからです。クラス定義時にコメントで一緒に役割を記述することもできますが、基本的にクラスはクラス名で役割を表します。

先ほどのHPクラスのHPが半分以下の場合に攻撃力が上がる仕様は、もしクラス名がEnemyHPというような名前だったのならば、クラスの役割は敵のHPを表すものということになるのでIsEnemyAttackUpというメソッドがあっても問題無いのかもしれません。ここらへんの判断は正解がないので個人の感覚で違いがでるところだと思います。

動作も役割分担の対象になる

役割分担は動作もその対象になります。例えば、検索ボックスのような1行のみ入力可能なテキストボックスクラスがあったとします。そしてこのテキストボックスへは数値のみ入力可能という仕様だったとします。このとき「数値のみかどうか入力チェックを行う」という動作も役割分担によってクラス化することができます。オブジェクト指向というとオブジェクト=モノと捉えてしまいがちですが、「モノ」だけではなく「動作」などの目に見えないものもクラス化していくことが大事です。

ちなみに「数値のみかどうか入力チェックを行う」クラスはデータを持つ必要がありません。なぜなら入力値のみで数値がどうかチェックすることができるからです。ではクラス化しなくても関数でよいのではないかと思うかもしれませんが、クラスにしておくことで役割の担当者として融通がきく存在になります。例えばあとから、「範囲内の数値かどうか入力チェックを行う」という機能を追加したくなった場合に、クラスならメンバ変数を持てるので対応できます。

役割を詳細に決めすぎる必要はない

先ほどの入力チェックのクラス化の例は、もし最初の「数値のみかどうか入力チェックを行う」という役割をそのままクラス名にしてNumberOnlyValidatorなどとしていた場合、「範囲内の数値かどうか入力チェックを行う」という機能を追加するとクラス名と動作が一致しないことになってしまいます。一方、クラス名を役割そのままではなく少し曖昧にNumberValidatorとしていた場合、そのクラスの役割は「数値に関する入力チェックを行う」ということになりクラス名を変えること無く「範囲内の数値かどうか入力チェックを行う」機能を追加することができます。

このように役割を最初から詳細に決定しておくのではなく、ある程度ぼかしていた方が融通が利くクラスになります。役割をしっかりと決めることは重要ですが、詳細に決めすぎる必要もないということです。

役割分担でクラス化するかどうかは複雑さで考える

すべての役割をクラス化する必要はありません。例えば、HPクラスの例では、最大HPと現在HPを管理していますが、これを「最大HPの管理する」クラスと「現在HPの管理する」クラスというふうにさらにクラス化する必要はありません。HPクラスは現状では、それほど複雑な実装にはなっていないからです。

役割分担によるクラス分けのメリット

  • クラスに何を実装すべきか実装すべきでないのかがわかりやすくなる
  • クラスが役割に対してのみ責任を持つことになるので、クラスの動作がわかりやすくなる
  • 役割でクラスを実装することになるのでクラスが肥大化しない(ただし役割が曖昧だと肥大化してしまう)
  • メソッドがそのクラスの役割に対する仕事の依頼になるので、クラス間の関係が疎結合になる
  • 細部から全体まですべてこの考え方でクラス分けが可能

役割分担ではクラス分けできない場合もある

役割分担によるクラス分けが適用できない場合が2つあります。1つは基底クラスを作るとき。もう1つは派生クラスを作るときです。これらの場合のクラス分けは役割分担ではなく、抽象化/具体化で考える必要があります。例えば、少し前に出たHPクラスの例で、新たに敵のMPを管理するクラスが必要になったとします。このとき、HPクラスと同じようにMPクラスを作ると似たようなコードがたくさんあることに気づくと思います。こういうときに行うクラス分けが抽象化です。抽象化とはわかりやすく言えば、分類や区分を見つけるということです。HPクラスとMPクラスはともに戦闘用のパラメータという分類になります。なので戦闘用パラメータクラスを作ってそれをHPクラス、MPクラスが継承するようにすることで重複したコードをなくすことが出来ます。

抽象化/具体化の例

役割分担でクラス化するのか抽象化/具体化でクラス化するのかは、どういう目的でクラス分けをしたいのかで決まります。コードの複雑さを解消したい場合は役割分担でクラス分けを行います。コードの重複を解消したい場合は抽象化でクラス分けをします。また、抽象化によるクラス分けを行う場面はコードの重複の解消の他にもう1つあります。具体化を他にまかせる場合です。この場合正確に言うと抽象化ではなく具体化を想定した作りにしておくということです。例えば、ゲームプログラミングに登場するタスクシステムのタスクは具体化を想定したクラスです。タスクの具体的な役割を利用者にまかせていると言えます。

まとめ

役割分担でクラス分けを行うという手法はシンプルに聞こえますがとても強力な方法です。クラス分けの目安が分からない方は、この方法を試してみてはいかがでしょうか。