【Unity】Unity開発者のためのC#クラス入門:インスタンス・null・継承・interface

作成: 2026-07-16

サンプルコードは写せるのに「クラスをどう分けるか」で手が止まる——多くのUnity初心者が変数と関数の次にぶつかる壁です。クラスとインスタンス、値型と参照型、nullとUnity特有の破棄済み判定、[SerializeField] privateとプロパティ、継承とコンポジションの使い分け、そしてinterfaceによる共通化まで、IDamageableを使った実例で「スクリプトの分け方」を解説します。

サンプルコードを写してキャラを動かせるようになった頃、ふと自分の PlayerController.cs を見ると——移動もHPもアイテムもUI更新も全部入りで、気づけば数百行。どこかを直すと別の場所が壊れ、「クラスをどう分ければいいのか」が分からない。変数と関数を覚えた初心者が、次に必ずぶつかる壁です。

この記事では、その壁を越えるためのC#の道具—— クラスとインスタンス値型と参照型と nullプロパティ継承とコンポジションinterface ——を順番にそろえます。最後は IDamageable というinterfaceを使って、プレイヤー・敵・木箱を「同じ剣の一振り」で処理できるところまで組み立てます。

1つの巨大なスクリプトが、役割ごとの小さな部品へ整理されていくイメージ

この記事でわかること

  • クラス= データと振る舞いのまとまり、インスタンス=その実体
  • 値型と参照型の違いと、null / NullReferenceException の正体
  • Unity特有の「破棄済みオブジェクトは == null」という特別ルール
  • public フィールドではなく [SerializeField] private とプロパティ を使う理由
  • 継承とコンポジション の使い分け(Unityは「部品を組む」エンジン)
  • interface で「ダメージを受けられるもの」を共通化する実例

動作確認環境: Unity 2022.3 LTS / Unity 6

Sponsored

「全部PlayerControllerに書く」と何が壊れるか

まず、壁の正体をはっきりさせましょう。次のようなスクリプト、心当たりはないでしょうか。

public class PlayerController : MonoBehaviour
{
    // 移動もHPもコインもUIも音も、ぜんぶここに……
    public float moveSpeed = 5f;
    public int health = 100;
    public int coinCount = 0;
    public Text healthText;
    public AudioClip damageSound;

    void Update()
    {
        // 移動処理 30行
        // ダメージ処理 40行
        // コイン処理 20行
        // UI更新 30行 ……
    }
}

動いてはいます。でも、この書き方は規模が少し増えただけで壊れ始めます。

  • 直す場所が探せない: ジャンプのバグを直したいのに、HPやUIのコードをかき分けて探すことになる
  • 1カ所の変更が全体に波及する: HPの変数名を変えたら、移動処理の中に紛れていた無敵判定まで巻き添えでエラーになる
  • 使い回せない: 「敵にもHPが欲しい」と思っても、この中からHP部分だけを取り出せない
1つの巨大なPlayerControllerクラスが、移動・体力・コイン収集という3つの小さなクラスに分かれる対比図

解決の方向はシンプルで、 「役割ごとに小さなクラスへ分ける」 です。移動は PlayerMover、HPは Health、コインは CoinCollector。実は GameObjectとコンポーネント で見た「部品を組み合わせる」というUnityの設計思想そのものです。この記事では、その「分ける」ためのC#の文法を一つずつ見ていきます。

クラスは「データと振る舞い」のまとまり

クラス は、関連する変数(データ)と関数(振る舞い)をひとまとめにした「設計図」です。そして設計図から作られた実体を インスタンス と呼びます。

クラスは1枚の設計図で、newするたびに別々のデータを持つインスタンスが複数生まれることを表す図

MonoBehaviourを継承しない、純粋なC#のクラスを作ってみます。

// Weapon.cs — MonoBehaviourを継承しない「普通のクラス」
public class Weapon
{
    // データ(この武器が覚えていること)
    public string weaponName;
    public int damage;

    // コンストラクタ:newされた瞬間に呼ばれる初期化関数
    public Weapon(string name, int damageValue)
    {
        weaponName = name;
        damage = damageValue;
    }

    // 振る舞い(この武器ができること)
    public string GetLabel()
    {
        return weaponName + "(攻撃力" + damage + ")";
    }
}

使う側は new でインスタンスを作ります。

Weapon sword = new Weapon("鉄の剣", 10);
Weapon axe = new Weapon("戦斧", 25);

Debug.Log(sword.GetLabel()); // 鉄の剣(攻撃力10)
Debug.Log(axe.GetLabel());   // 戦斧(攻撃力25)

大事なのは、swordaxe同じ設計図から生まれた別々の実体 で、それぞれ独立したデータを持っていることです。「敵が10体いれば、EnemyHealth のインスタンスが10個あり、HPもそれぞれ別」——この感覚が、クラスを使う土台になります。

注意: MonoBehaviour を継承したクラスだけは new できません。GameObjectに載って初めて動く部品だからで、生成は AddComponent() かエディタでのアタッチで行います。逆に言うと、GameObjectに載せる必要のない純粋なデータやルール(所持金の計算、ダメージ計算式など)は、上の Weapon のような普通のクラスに切り出せます。詳しくは MonoBehaviourの記事 を参照してください。

Sponsored

値型と参照型、そしてnull

クラスが使えるようになると、すぐに不思議な現象に出会います。「コピーしたはずなのに、片方を変えたらもう片方も変わった」——これは 値型と参照型 の違いです。

  • 値型intfloatboolVector3 など): 代入すると 中身がコピー される。コピー先を変えても元は変わらない
  • 参照型(クラス全般。string や配列、GameObject も): 代入すると 同じ実体への「参照(住所)」がコピー される。どちらの変数から変えても、同じ実体が変わる
値型は代入で中身がコピーされ別々の箱になるが、参照型は2つの変数が同じ1つの実体を指すことを表す比較図
// 値型:コピーなので、bを変えてもaはそのまま
Vector3 a = new Vector3(0, 0, 0);
Vector3 b = a;
b.x = 100f;
Debug.Log(a.x); // 0 のまま

// 参照型:swordAとswordBは「同じ武器」を指している
Weapon swordA = new Weapon("鉄の剣", 10);
Weapon swordB = swordA;
swordB.damage = 999;
Debug.Log(swordA.damage); // 999 に変わっている!

これはバグではなく、参照型の正しい動きです。敵のリストをAIとUIの両方へ渡しても、全員が「同じ敵たち」を見られるのは参照型のおかげです。

nullは「何も指していない」

参照型の変数は、 どの実体も指していない状態 を取れます。それが null です。null の変数のメンバーへアクセスすると、Unity初心者が最初に出会うエラーの王様 NullReferenceException が発生します。

Weapon currentWeapon = null; // まだ武器を持っていない

// currentWeaponは何も指していないので、ここで
// NullReferenceException: Object reference not set to an instance of an object
Debug.Log(currentWeapon.damage);

対策の基本は「使う前に確認する」です。

if (currentWeapon != null)
{
    Debug.Log(currentWeapon.damage);
}

このエラーの追い詰め方(どの変数がnullだったのかをエラーメッセージと行番号から特定する手順)は、デバッグ手法の記事 で詳しく扱っています。

Unity特有の「破棄済みオブジェクト」判定

ここにUnity特有の重要ルールがあります。Destroy() で破棄したGameObjectやComponentへの参照は、C#の変数としてはまだ何かを指しているのに、 Unityが == nulltrue と答えるように特別に作ってある のです。

[SerializeField] private EnemyHealth enemy;

void DefeatEnemy()
{
    Destroy(enemy.gameObject);
}

void Update()
{
    // 破棄された次のフレーム以降、enemy == null は true になる(Unityの特別ルール)
    if (enemy != null)
    {
        Debug.Log(enemy.name);
    }
}

このおかげで「破棄済みかどうか」を普通のnullチェックで確認できます。ただし落とし穴が1つ。C#の便利記法である ?.??この特別ルールを通りません。破棄済みのオブジェクトを「まだある」と誤判定してエラーになるため、 Unityのオブジェクトにはあえてベタな if (obj != null) を使う のが正解です。

外に見せる窓口を絞る:SerializeField privateとプロパティ

クラスを分けたら、次は「外からどこまで触らせるか」を決めます。全部 public にすると、どのクラスからでも書き換えられてしまい、「HPがいつの間にかマイナスになっている。誰が書いた?」を全クラスから探すはめになります。

方針は2つだけ覚えれば十分です。

1. Inspectorで調整したいだけなら [SerializeField] private

public class Health : MonoBehaviour
{
    // Inspectorには表示されるが、他のクラスからは書き換えられない
    [SerializeField] private int maxHealth = 100;
}

2. 外へ「読ませたい」値は、読み取り専用のプロパティで公開する

public class Health : MonoBehaviour
{
    [SerializeField] private int maxHealth = 100;

    // 外からは読めるが、書き換えはこのクラス内からしかできない
    public int Current { get; private set; }

    void Awake()
    {
        Current = maxHealth;
    }

    public void TakeDamage(int amount)
    {
        // 書き換えの入り口をこの関数に絞ったので、
        // 「0未満にしない」というルールを必ず通せる
        Current = Mathf.Max(0, Current - amount);
    }
}

プロパティ は「読み書きの窓口になれる変数のようなもの」です。get; private set; と書くと、読むのは誰でも・書くのは自分のクラスだけ、になります。こうしてHPの変更を TakeDamage() という1つの入り口に絞れば、「HPがマイナスになった」バグは原理的に起きなくなります。変更の通知まで欲しくなったら、イベントとデリゲートの記事 が次の一歩です。

Sponsored

継承とコンポジション

継承 は、既存のクラスを土台に「差分だけ」を書く仕組みです。実はもう毎日使っています——public class Player : MonoBehaviour: がそれで、Start()Update() が呼ばれるのはMonoBehaviourを継承しているからです。

自分で使う典型例は、敵のバリエーション作りです。

// 敵に共通するものを土台にまとめる
public class EnemyBase : MonoBehaviour
{
    [SerializeField] protected float moveSpeed = 2f;

    // virtual:子クラスが上書き(override)できる関数
    protected virtual void Move()
    {
        transform.Translate(Vector3.left * moveSpeed * Time.deltaTime);
    }

    void Update()
    {
        Move();
    }
}

// スライム:共通の動きをそのまま使う
public class Slime : EnemyBase
{
}

// コウモリ:動き方だけ上書きする
public class Bat : EnemyBase
{
    protected override void Move()
    {
        float wave = Mathf.Sin(Time.time * 5f);
        transform.Translate(new Vector3(-moveSpeed, wave, 0) * Time.deltaTime);
    }
}

便利ですが、継承には「効きすぎる」危険があります。EnemyBaseFlyingEnemyFireFlyingEnemy → …と階層を深くすると、土台の1行の変更が全子孫へ波及して、巨大クラスと同じ「どこを直せば良いか分からない」状態に逆戻りします。

そこで思い出してほしいのが、Unityがもともと採用している コンポジション(部品の組み合わせ) です。「空を飛ぶ敵」は「飛ぶクラスを継承した敵」ではなく、「敵オブジェクトに Flying コンポーネントを足したもの」として作れます。

継承は親から子へ枝分かれするツリー、コンポジションは部品を差し替え可能なスロットに組み合わせる形であることを表す比較図

使い分けの目安はこうです。

判断基準向いている道具
「スライム である」と自然に言える(is-a)継承(浅く。1〜2段まで)
「敵が飛ぶ機能 を持つ」と言う方が自然(has-a)コンポジション(コンポーネントを足す)
種類の違うものに「同じ約束」だけさせたいinterface(次の節)

迷ったらコンポジションに倒してください。GameObjectとコンポーネントの記事 で見た「部品を組み替えて3つのオブジェクトを作る」感覚が、そのまま設計の正解です。

interfaceは「できること」の約束

最後の道具が interface です。interfaceは実装を持たない「約束のリスト」で、「このクラスは、この関数を必ず持っています」と宣言します。

// 「ダメージを受けられるもの」という約束
public interface IDamageable
{
    void TakeDamage(int amount);
}

なぜこれが強力なのか。プレイヤー・敵・壊れる木箱を考えてみてください。3つは「敵の仲間」でも「プレイヤーの仲間」でもなく、共通の親クラスを作ろうとすると不自然です。でも 「ダメージを受けられる」という1点だけは共通 しています。

IDamageableというinterfaceの約束を、プレイヤー・敵・木箱という別系統の3つのクラスがそれぞれ実装し、攻撃側はinterfaceだけを見ることを表す配線図

interfaceなら、系統の違うクラスたちへ横断的に同じ約束をさせられます。

// クラス継承は1つだけだが、interfaceはカンマで並べていくつでも実装できる
public class EnemyHealth : MonoBehaviour, IDamageable
{
    public void TakeDamage(int amount)
    {
        // 約束した関数の中身は、クラスごとに自由に書く
    }
}

攻撃する側は、相手が何者かを知る必要がなくなります。「IDamageable を持っているなら殴れる」——それだけです。次の実践で、この配線を実際に組んでみましょう。

実践:1本の剣でプレイヤーも敵も木箱も殴れるようにする

アクションRPGの剣攻撃、シューティングの弾、パズルゲームのブロック破壊——「1つの攻撃が、種類の違ういろいろなものに当たる」場面はどのジャンルにもあります。ここでは剣を題材に、IDamageable で組み立てます。

完成すると、こうなります。剣を一振りすると、範囲内にいた 敵はHPが減って倒れ木箱は壊れてアイテムを落とし、当たってしまった 味方はHPが減る。剣のスクリプトは、この3種類のクラス名を1つも知りません。

剣を振る青いクレイ人形の攻撃範囲に敵・木箱・味方が入っており、1回の攻撃がIDamageableを通じてそれぞれ違う反応(倒れる・壊れてアイテム・HP減少)を起こす場面図

まず、約束を定義します。

// IDamageable.cs — 「ダメージを受けられるもの」の約束
public interface IDamageable
{
    void TakeDamage(int amount);
}

攻撃側は、約束だけを見ます。剣の当たり判定(IsTriggerをオンにしたCollider)に付けてください。

// SwordAttack.cs — 剣の当たり判定に付ける
using UnityEngine;

public class SwordAttack : MonoBehaviour
{
    [SerializeField] private int damage = 10;

    private void OnTriggerEnter(Collider other)
    {
        // 相手が「ダメージを受けられるもの」なら、種類を問わず殴れる
        if (other.TryGetComponent<IDamageable>(out IDamageable target))
        {
            target.TakeDamage(damage);
        }
    }
}

受ける側は、約束の中身をそれぞれの事情で書きます。

// EnemyHealth.cs — 敵:HPが尽きたら倒れる
using UnityEngine;

public class EnemyHealth : MonoBehaviour, IDamageable
{
    [SerializeField] private int maxHealth = 30;
    private int currentHealth;

    void Awake()
    {
        currentHealth = maxHealth;
    }

    public void TakeDamage(int amount)
    {
        currentHealth -= amount;
        Debug.Log(name + " のHP: " + currentHealth);

        if (currentHealth <= 0)
        {
            Destroy(gameObject);
        }
    }
}
// BreakableCrate.cs — 木箱:一撃で壊れてアイテムを落とす
using UnityEngine;

public class BreakableCrate : MonoBehaviour, IDamageable
{
    [SerializeField] private GameObject itemPrefab;

    public void TakeDamage(int amount)
    {
        // 木箱はHPを持たない。ダメージ量も見ずに壊れる——それも「自由」のうち
        if (itemPrefab != null)
        {
            Instantiate(itemPrefab, transform.position, Quaternion.identity);
        }
        Destroy(gameObject);
    }
}
// PlayerHealth.cs — 味方:HPが減り、0でゲームオーバー通知
using UnityEngine;

public class PlayerHealth : MonoBehaviour, IDamageable
{
    [SerializeField] private int maxHealth = 100;

    public int Current { get; private set; }

    void Awake()
    {
        Current = maxHealth;
    }

    public void TakeDamage(int amount)
    {
        Current = Mathf.Max(0, Current - amount);
        Debug.Log("プレイヤーのHP: " + Current);

        if (Current == 0)
        {
            Debug.Log("ゲームオーバー");
        }
    }
}

Playを押して、剣を敵・木箱・味方の順に当ててみてください。敵は3発で消え、木箱は一撃でアイテムに変わり、味方はHPのログが減っていく——同じ SwordAttack から、3通りの反応が返ってくるはずです。そして SwordAttack.cs をもう一度見てください。if (敵なら) if (木箱なら) という分岐が 1つもない ことが、この設計の成果です。

明日「壊せる街灯」を追加したくなっても、やることは街灯に IDamageable を実装したクラスを付けるだけ。 SwordAttack.cs には1文字も触りません。もし剣を当てても何も起きなかったら、見る場所は2つです。剣のColliderの Is Trigger がオンになっているか。そして TryGetComponentColliderと同じGameObject しか見ないので、Colliderが子オブジェクトに付いているなら GetComponentInParent で親のコンポーネントを探す 形に変えてみてください。

ポイントは2つです。 攻撃側はinterfaceしか見ない(だから増築してもコードが壊れない)。 反応の中身は受ける側それぞれの責任(だから敵と木箱で違う反応を自由に書ける)。この「約束で切り離す」感覚は、Stateパターンイベント駆動の設計 へそのままつながっていきます。

おまけ:先に知っておくと良いこと

この記事の道具を使い始めると、いずれ次の言葉に出会います。今は名前と一言だけ知っておけば十分です。

  • abstract(抽象クラス): 「土台としてだけ使い、単体ではインスタンス化させない」クラス。EnemyBase を直接シーンに置かれたくなくなったら調べてみてください
  • static(静的メンバー): インスタンスではなく クラスそのもの に属する変数・関数。Mathf.Max()new Mathf() なしで呼べるのはこれです。強力ですが多用すると密結合の温床になるため、Singletonパターンの記事 で作法ごと学ぶのがおすすめです
  • ジェネリック(<T>: 型をあとから差し込める仕組み。実は List<int>GetComponent<Rigidbody>() でもう使っています。「使う」は今日からでき、「自作する」はずっと先で大丈夫です

まとめ

  • クラス はデータと振る舞いのまとまり、 インスタンス はその実体。役割ごとに小さく分けるのが巨大クラス対策の第一歩
  • クラスは 参照型。代入は「同じ実体の共有」で、どこも指していない状態が null
  • Unityでは破棄済みオブジェクトが == nulltrue になる。 ?.?? はこの特別ルールを通らない ので使わない
  • Inspectorに出したいだけなら [SerializeField] private、外へ読ませたいなら get; private set; のプロパティ
  • 継承は is-a の関係に浅く使い、迷ったら コンポーネントの組み合わせ に倒す
  • interface は「できること」の約束。系統の違うクラスたちを、同じ攻撃・同じ処理から扱えるようになる

次にあなたのプロジェクトを開いたら、いちばん行数の多いスクリプトを眺めてみてください——その中に「別のクラスに切り出せるまとまり」は、いくつ見えますか?