スポンサーリンク
Unity C#・スクリプト実装

Unityのスクリプト設計を改善!SOLID原則で整理する方法

Unity C#・スクリプト実装

「とりあえず動くから」で書き進めてきたUnityのスクリプト、気づけばGameManagerが何百行にもなっていたり、新しい敵キャラを1体追加するだけなのに、あちこちのコードを直す羽目になっていたりしませんか。

そのモヤモヤ、実は「SOLID原則」という考え方を知っているかどうかで、かなり変わってきます。名前だけ聞くと難しそうに感じるかもしれませんが、中身は意外とシンプルな5つの視点です。

この記事では、あなたのコードが今どんな状態にあるのかをチェックする方法から、実際のコード例を使った具体的な直し方まで、順番に見ていきます。


そのコード、SOLID違反のサインは?

SOLID原則を学ぶ前に、まず確認してほしいことがあります。それは「今のコードが本当に手を加える必要があるのか」ということ。実は、下記の2つに当てはまらないなら、無理に設計を変える必要はないんです。

1クラスに3つ以上の役割が混ざっていないか

例えばPlayerクラスを開いてみて、こんな処理が全部同じファイルに書かれていないでしょうか。

  • キー入力の受け取り
  • キャラクターの移動処理
  • 効果音の再生
  • HPやスコアの管理

役割が3つ以上重なっているクラスは、ちょっとした修正のつもりが思わぬところに影響してしまうことが多いです。「移動のスピードを調整しただけなのに、なぜか音が鳴らなくなった」なんてこと、経験ありませんか。これは複数の役割が1つのクラスに閉じ込められているサインです。

新機能追加のたびに既存コードを書き換えていないか

新しい敵キャラや新しいアイテムを追加するたびに、すでに動いている弾のクラスやアイテム処理のクラスを毎回開いて修正している場合も要注意です。本来、機能を追加するときは新しいコードを足すだけで済むのが理想で、既存のコードにあまり手を入れずに済む状態を目指したいところです。

ここで出てきた「クラス」や「インターフェース」といった言葉にまだ馴染みがない場合は、先にこちらの記事で基本を押さえておくと、このあとの内容がぐっと理解しやすくなります。

この2つのどちらか、あるいは両方に心当たりがあるなら、次から紹介する対処法が実際に役立つはずです。逆にどちらも当てはまらない場合は、無理にリファクタリングを急ぐ必要はありません。




単一責任の原則|1クラス1役割にする方法

さきほどの「1クラスに役割が3つ以上混ざっている」に心当たりがあった人向けの対処法です。単一責任の原則は、名前の通り「1つのクラスが持つ役割は1つだけにする」という考え方です。

分割の目安:役割を書き出して1機能=1クラスにする

まずは今のクラスがどんな役割を抱えているか、紙やメモに箇条書きで書き出してみてください。それぞれの役割が独立したクラスとして分けられそうなら、それが分割の合図です。

実際のコードで見てみましょう。入力・移動・音声がすべて詰め込まれたPlayerクラスがこちらです。

public class Player : MonoBehaviour
{
    private Rigidbody rb;
    private AudioSource audioSource;

    void Update()
    {
        // 入力受け取り
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");

        // 移動処理
        rb.MovePosition(transform.position + new Vector3(h, 0, v) * Time.deltaTime * 5f);

        // ジャンプ時に音を鳴らす
        if (Input.GetKeyDown(KeyCode.Space))
        {
            rb.AddForce(Vector3.up * 5f, ForceMode.Impulse);
            audioSource.Play();
        }
    }
}

このままでも動きはしますが、「移動のスピードだけ調整したい」というときにも、このクラス全体を開いて該当箇所を探すことになります。役割ごとに分けると、こんな形になります。

public class PlayerInput : MonoBehaviour
{
    public float Horizontal => Input.GetAxis("Horizontal");
    public float Vertical => Input.GetAxis("Vertical");
    public bool JumpPressed => Input.GetKeyDown(KeyCode.Space);
}

public class PlayerMovement : MonoBehaviour
{
    private Rigidbody rb;

    public void Move(float h, float v)
    {
        rb.MovePosition(transform.position + new Vector3(h, 0, v) * Time.deltaTime * 5f);
    }

    public void Jump()
    {
        rb.AddForce(Vector3.up * 5f, ForceMode.Impulse);
    }
}

public class PlayerAudio : MonoBehaviour
{
    private AudioSource audioSource;

    public void PlayJumpSound()
    {
        audioSource.Play();
    }
}

そして、元のPlayerクラスは各クラスをまとめて連携させるだけの、いわば司令塔の役割に絞ります。

public class Player : MonoBehaviour
{
    private PlayerInput input;
    private PlayerMovement movement;
    private PlayerAudio audio;

    void Update()
    {
        movement.Move(input.Horizontal, input.Vertical);

        if (input.JumpPressed)
        {
            movement.Jump();
            audio.PlayJumpSound();
        }
    }
}

こうしておくと、移動のバランス調整はPlayerMovementだけ、効果音の変更はPlayerAudioだけを見ればよくなります。修正のたびにファイル全体を読み返す必要がなくなるので、あとから見返したときの負担もかなり減るはずです。

「どこまで細かく分ければいいのか分からない」という場合は、こちらの記事で分割の考え方をさらに詳しく解説しているので、あわせて読んでみてください。




オープン・クローズドの原則|switch文を消す方法

「新しい敵やアイテムを追加するたびに、既存のコードを書き換えている」に心当たりがあった人向けの対処法です。オープン・クローズドの原則は、「機能を追加するときは新しいコードを足すだけで済み、既存のコードにはなるべく手を入れない」という考え方です。

よくあるのが、弾が当たった相手によって処理を分けるために、switch文やif文でタグ判定をしているケースです。

public class Bullet : MonoBehaviour
{
    void OnCollisionEnter(Collision collision)
    {
        switch (collision.gameObject.tag)
        {
            case "Enemy":
                collision.gameObject.GetComponent().TakeDamage(10);
                break;
            case "Wall":
                collision.gameObject.GetComponent().TakeDamage(10);
                break;
            // 新しい敵や壊せるオブジェクトが増えるたびにここに追記が必要
        }
    }
}

この書き方だと、新しい敵キャラや新しい壊せるオブジェクトを追加するたびに、このBulletクラスを開いてcase文を増やし続けることになります。しかも増えれば増えるほど、どこかのタグ名を打ち間違えたときのバグにも気づきにくくなっていきます。

ここで役立つのが「インターフェース」です。ダメージを受ける対象が共通して持つべき振る舞いを、IDamageableというインターフェースとして先に定義しておきます。

public interface IDamageable
{
    void TakeDamage(int amount);
}

ダメージを受ける側のEnemyやBreakableWallは、このインターフェースを実装します。

public class Enemy : MonoBehaviour, IDamageable
{
    public void TakeDamage(int amount)
    {
        // 敵のダメージ処理
    }
}

public class BreakableWall : MonoBehaviour, IDamageable
{
    public void TakeDamage(int amount)
    {
        // 壁が壊れる処理
    }
}

すると、Bulletクラス側はタグで分岐する必要がなくなり、こんなにシンプルになります。

public class Bullet : MonoBehaviour
{
    void OnCollisionEnter(Collision collision)
    {
        IDamageable damageable = collision.gameObject.GetComponent();
        if (damageable != null)
        {
            damageable.TakeDamage(10);
        }
    }
}

この形にしておけば、新しい敵や新しいギミックを追加したいときは、IDamageableを実装した新しいクラスを作るだけで済みます。Bulletクラス自体には一切手を加える必要がありません。

「追加は自由に、既存はなるべく触らない」という状態が作れると、機能が増えていくほど心理的な負担が軽くなっていくのを感じられるはずです。




抽象化で解決する2つの視点

ここまでの2つで解決しない、もう少し規模が大きくなってきたときに出てくる悩みを2つまとめて紹介します。どちらも「インターフェースの使い方をもう一段階工夫する」という共通点があります。

インターフェース分離の原則|インターフェースの分け方

キャラクターに関する処理をまとめようとして、こんな大きなインターフェースを作ってしまうことはないでしょうか。

public interface ICharacter
{
    void Move();
    void Attack();
    void Explode();
}

これだと、移動しない設置型のトラップや、爆発しないキャラクターまで、使いもしないExplode()やMove()の実装を強制されてしまいます。空っぽの中身だけのメソッドがあちこちに増えていくのは、見た目以上にコードを読みにくくします。

そこで、機能ごとに小さいインターフェースへ分けます。

public interface IMovable
{
    void Move();
}

public interface IAttackable
{
    void Attack();
}

public interface IExplodable
{
    void Explode();
}

そして、それぞれのオブジェクトは自分に必要なものだけを実装すればよくなります。

public class Enemy : MonoBehaviour, IMovable, IAttackable
{
    public void Move() { /* 移動処理 */ }
    public void Attack() { /* 攻撃処理 */ }
}

public class Bomb : MonoBehaviour, IExplodable
{
    public void Explode() { /* 爆発処理 */ }
}

「このオブジェクトに何ができるか」がインターフェースを見るだけで分かるようになるので、あとからコードを読む人にも意図が伝わりやすくなります。ちなみに、こうしたインターフェース経由の参照をInspector上で扱いやすくしてくれるツールもあるので、実装量が増えてきたら検討してみるのもひとつの手です。

Odin Inspector and Serializer
✅アセットストアでチェックする

依存性逆転の原則|依存の向きを変える方法

スイッチを押すとドアが開く、という仕組みを作るとき、こんなふうにSwitchクラスが直接Doorクラスを参照してしまうことがあります。

public class Switch : MonoBehaviour
{
    public Door door;

    public void OnPush()
    {
        door.Open();
    }
}

この書き方だと、スイッチで開けたいものがドアだけなら問題ありませんが、後から「宝箱も開けたい」「シャッターも動かしたい」となったとき、Switchクラスそのものを何度も書き換える羽目になります。

ここでも、共通の振る舞いをインターフェースとして定義します。

public interface ISwitchable
{
    void Activate();
}

public class Door : MonoBehaviour, ISwitchable
{
    public void Activate() { /* ドアを開く処理 */ }
}

public class TreasureBox : MonoBehaviour, ISwitchable
{
    public void Activate() { /* 宝箱を開ける処理 */ }
}



SOLID原則は全部守るべき?適用の見極め方

ここまで読んで、「全部の原則をきっちり守らないといけないのかな」と感じた方もいるかもしれません。結論から言うと、そんなことはありません。

SOLID原則は絶対に守らなければいけないルールではなく、コードを変更しやすく保つための手段のひとつです。目的はあくまで「保守しやすいコード」であって、SOLID原則を適用すること自体が目的になってしまうと、逆に本末転倒になってしまいます。

適用するかどうかを見極める目安は、次の2つです。

  • プロジェクトの規模がどれくらいか
  • 今後、機能追加や仕様変更が発生する見込みがあるか

例えば、1週間で完成させる小規模なプロトタイプや、機能追加の予定がほぼない個人開発の小さなゲームであれば、すべての原則を厳密に適用する必要はありません。インターフェースを何層にも分けるより、多少雑でもさっさと動くものを作ったほうが合理的な場面は普通にあります。

一方で、長期間の運用を見込んでいるプロジェクトや、チームで開発していて他の人が後からコードを読む可能性がある場合は、SOLID原則を意識しておくメリットが大きくなります。特に「今後もコンテンツを追加し続ける予定がある」という場合は、オープン・クローズドの原則や依存性逆転の原則が効いてくる場面が増えていきます。

過剰にインターフェースを増やしすぎると、逆にコードの見通しが悪くなる「オーバーエンジニアリング」と呼ばれる状態に陥ることもあります。すべてのクラスに対して無理やり原則を当てはめようとするのではなく、「このクラスは今後も変更が多そうだから、ここだけ意識して分けておこう」というように、部分的に適用していく考え方がちょうどいいバランスです。

他の設計パターンと組み合わせて使うケースも多いので、シングルトンやサービスロケーターといった選択肢も含めて検討したい場合は、こちらの記事も参考になります。




よくある誤解・注意点

ここまで紹介してきた内容の中で、特に初心者がつまずきやすいポイントを2つ、最後に整理しておきます。

継承が壊れるサイン

Unityでは「継承」を使ってクラスをまとめたくなる場面がよくありますが、継承には注意が必要な落とし穴があります。それは、子クラスが親クラスのルールを勝手に上書きしてしまうケースです。

よく例えられるのが、長方形クラスを継承して正方形クラスを作るケースです。長方形は縦と横の長さをそれぞれ自由に変更できるのが前提のクラスですが、正方形は縦と横が必ず同じ長さでなければいけません。そのため正方形クラスの中で「縦を変えたら横も一緒に変わる」という独自のルールを追加してしまうと、長方形として扱おうとしたときに想定外の動きをしてしまいます。

継承を使う前に、次のポイントを確認してみてください。

  • 子クラスは親クラスの「〜は〜である」という関係が本当に成立しているか
  • 親クラスで定義された振る舞いを、子クラスで制限したり上書きしたりしていないか

もし矛盾を感じる場合は、無理に継承を使わず、共通のインターフェースを定義してそれぞれ個別に実装する方法や、あるクラスが別のクラスを内部に持たせて使う「委譲」という方法を検討してみるのがおすすめです。

単一責任とインターフェース分離の違い

「単一責任の原則」と「インターフェース分離の原則」は、どちらも“何かを分割する”という点が似ているため、混同されがちです。ただ、この2つが対象にしているものは実は違います。

原則分割の対象
単一責任の原則クラスが持つ「責任」の範囲
インターフェース分離の原則インターフェースの「粒度」の細かさ

単一責任の原則は「このクラスに役割を詰め込みすぎていないか」というクラス側の話で、インターフェース分離の原則は「このインターフェースに不要なメソッドまで詰め込んでいないか」というインターフェース側の話です。

対象が違うだけで、根っこにある「必要なものだけをまとめる」という考え方は共通しています。この違いを意識しておくと、どちらの原則を適用すべき場面なのかが判断しやすくなります。




まとめ

SOLID原則は、5つすべてを一度に完璧に使いこなす必要はありません。まずは自分のコードを見返して、役割が詰め込まれすぎているクラスや、switch文で分岐が増え続けている箇所がないかをチェックしてみてください。

該当する箇所が見つかったら、その部分だけクラスを分割したり、インターフェースを挟んでみたりする。そんな小さな一歩から始めるだけで、次に機能を追加するときの負担がぐっと軽くなっていくはずです。


よくある質問(FAQ)

Q
SOLID原則とデザインパターンは何が違いますか?
A

SOLID原則は「クラスをどう設計すればいいか」という考え方や指針そのもので、デザインパターンはその考え方を実現するための具体的な実装のテンプレートです。

例えば、この記事で紹介したインターフェースを使った依存の減らし方はSOLID原則の実践例ですが、オブザーバーパターンやコマンドパターンといった名前がついた型のある実装方法は、SOLID原則を踏まえたうえでよく使われる具体的な手法にあたります。

まずはSOLID原則で「なぜそう設計するのか」を理解してから、デザインパターンを学ぶと理解がスムーズです。

Q
既存プロジェクトに後からSOLID原則を適用する場合、どこから手を付けるべきですか?
A

プロジェクト全体を一度に作り直そうとすると、途中で挫折したり、動いていた部分まで壊してしまったりするリスクが高くなります。

優先すべきは、バグが頻発している箇所や、機能追加のたびに手を加えることが多い箇所です。そうした箇所から少しずつクラスを分割したり、インターフェースを導入したりしていくと、無理なく設計を改善していけます。

Q
SOLID原則を理解するのに、事前にどの程度のC#知識が必要ですか?
A

クラス、継承、インターフェースといったC#の基本的な文法が分かっていれば十分です。デザインパターンのような発展的な知識までは必要ありません。

もしインターフェースや継承の扱いに不安がある場合は、先に基礎を押さえてからこの記事の内容を読み直すと、理解がより深まります。

※当サイトはアフィリエイト広告を利用しています。リンクを経由して商品を購入された場合、当サイトに報酬が発生することがあります。

※本記事に記載しているAmazon商品情報(価格、在庫状況、割引、配送条件など)は、執筆時点のAmazon.co.jp上の情報に基づいています。
最新の価格・在庫・配送条件などの詳細は、Amazonの商品ページをご確認ください。

スポンサーリンク