2nd term 5th week - dsuz/csharp GitHub Wiki

今回のテーマ

  1. 値型と参照型
  2. class(クラス)と struct(構造体)の違いとは何か
  3. デリゲート
  4. System.Action, System.Func

値型と参照型、構造体とクラス

クラスは参照型、構造体は値型である。例えば以下のコードでは、int 型では値がコピーされていることがわかる。(このようなちょっとしたコードを実行するには、paiza.io を使うとよいでしょう。

using System;

public class Hello
{
    public static void Main()
    {
        // 以下のプログラムにより、
        int i1 = 1;
        int i2;
        i2 = i1;	// i2 に i1 を代入する
        i1 = 2;		// 元となった i1 を更新する
        Console.WriteLine("i1: " + i1);	// i1 は更新したので "2" となる
        Console.WriteLine("i2: " + i2);	// i2 は i1 を代入した時点での値 "1" となる
    }
}

int 型は構造体 System.Int32 の別名 (alias) であり、実は構造体である。構造体は値型なので、このような処理をされた時に値をコピーする。i1 と i2 はそれぞれ別々のもの(インスタンス)となる。

一方、Unity で以下のコードを実行すると、異なる結果となる。

public void TestReference()
{
    GameObject go1 = GameObject.Find("Main Camera");
    GameObject go2 = go1;	// go2 に go1 を代入する
    go1.name = "ABC";
    Debug.Log(go1.name);    // go1 の名前は更新したので "ABC" となる
    Debug.Log(go2.name);    // go2 の名前も "ABC" となる
}

実際はここで go1 と go2 はクラスのインスタンス(オブジェクト)として同じものを指している。これを go1 と go2 は同じインスタンスを「参照 (refer)」しているという。NullReferenceException の Reference は、refer(動詞)の名詞型である。NullReferenceException とは、「参照がないことによる例外」という意味で、null とは「参照がないこと」という意味である。なお、GameObject はクラスであり、C# ではクラスは参照型である。

Unity でよく使われる Vector2, Vector3 は構造体であり、構造体は値型である。以下のようなコードを実行するとそれがわかる。

public void TestVector1()
{
    Vector2 v1 = new Vector2(0f, 0f);
    Vector2 v2 = v1;	// v2 に v1 を代入する
    v1.x = 10f;			// 元となった v1 を更新する
    Debug.Log(v1);		// v1 は更新したので (10, 0) となる
    Debug.Log(v2);		// v2 は v1 を代入した時点での値 (0, 0) となる
}

インスタンスの変数となっている構造体は値の一部を更新することができない。例えば以下のコードはコンパイルエラー (CS1612) になる。

public void TestVector2()
{
    this.transform.position.x = 10f;    // y, z 座標は変えずに x 座標だけを 10 にしたい
}

構造体は値を取得しようとした時も、そのインスタンスのコピーから値が返されるためである。従ってそのコピーの一部を変更したとしても意味がないのでコンパイルエラーとなる。

そのような事をしたい場合は、以下のように構造体をインスタンスごと入れ替える必要がある。

public void TestVector2()
{
    // y, z 座標は変えずに x 座標だけを 10 にしたい
    Vector2 pos = this.transform.position;
    pos.x = 10f;
    this.transform.position = pos;    
}

Unity で提供しているライブラリでは、Color, Vector2, Vector3 などが構造体である。(構造体の方が圧倒的に少ない)

なお、C# では、値型の値はスタックメモリに格納され、参照型の値はヒープメモリに格納される。スタックメモリは小さい(1MB)が、ヒープメモリは非常に大きい(実質上限なし)。従って、C# では「サイズの小さいデータは構造体に、サイズの大きいデータはクラスにすべき」である。

参考資料

  • 独習 C#
    • 2.2.1 データ型の分類
    • 3.2.1 値型/参照型による代入
    • 9.3.5 構造体

デリゲート

準備

  1. CSharp2-5.unitypackage をダウンロードして Unity のプロジェクトにインポートする
  2. Cinemachine パッケージをプロジェクトに追加する(既に入っているならこれは必要ない)

デリゲートとは

デリゲートを一言で言うと「メソッドを表す型」である。型であるから、その型の変数を宣言することができる。デリゲート型の変数には「メソッド」を入れることができる。変数に入れておいたメソッドは、後で呼び出すことができる。

デリゲートを使うには、以下のように書く。

// 定義
delegate void MyDelegateMethod();
public delegate void MyPublicDelegateMethod(int i);

// 宣言
MyDelegateMethod dm = default;
public MyPublicDelegateMethod pdm = default;

// メソッドの宣言
void Method1()
{
    Debug.Log("Method1 called.");
}

void Method2(int num)
{
    Debug.Log(num);
}

void Start()
{
    // デリゲート型変数へのメソッドの追加
    dm += Method1;
    pdm += Method2;

    // 変数に入っているメソッドを呼び出す
    dm();
    pdm(5);
    
    // デリゲート型変数からメソッドを削除する
    dm -= Method1;
    pdm -= Method2;
}

ここで以下のような注意点がある。

  1. デリゲートは型なので、変数に入れる関数は型が一致している必要がある。型として一致している必要があるのは「戻り値の型・引数の型・引数の数」である。(※)
  2. デリゲート型の変数にはメソッドを複数入れることができる。上記の例では演算子 += で代入しているが、これは「追加」である。= で代入すると、既に追加されたメソッドを全部取り除いて代入する。
  3. デリゲート型の変数にメソッドが複数入っている時に、変数に入っているメソッドを呼び出すと、入っている全てのメソッドが呼び出される。これを「マルチキャストデリゲート」という。(『独習 C#』 10.1.3 マルチキャストデリゲート を参照)
  4. 演算子 -= を使うと、デリゲート型の変数に入っているメソッドを削除することができる
  5. デリゲート型を宣言する時は戻り値の型を void にすることが多い。戻り値を返すこともできるが、注意点がある。(『独習 C#』 10.1.3 マルチキャストデリゲート の補足「戻り値のあるメソッドの挙動」を参照)

(※)従って以下のコードのようなことはできない。「一致するオーバーロードがない」としてコンパイルエラーになる。

delegate void MyDelegateMethod();	// 引数なし
MyDelegateMethod dm = default;

void Method1(int i)	// 引数あり
{
    Debug.Log(i);
}

void Start()
{
    dm += Method1;	// 引数なしのデリゲート型に引数ありのメソッドを入れようとしている(コンパイル エラーになる)
}

参考資料

  • 独習 C#
    • 10.1.1 デリゲートの基本
    • 10.1.3 マルチキャストデリゲート

使用例 - ゲームを一時停止する

1 Delegate/Pause シーンを実行し、ESC キーを押すとボールが一時停止する。もう一度押すと再開する。

ボールには BallController3D コンポーネントがアタッチされているが、この中でデリゲートを使って「一時停止・再開」する関数を PauseManager3D コンポーネントに登録している(PauseManager3D が持つデリゲート型の変数に、関数を追加している)。BallController3D は、ESC が押された時に登録された関数を呼び出している。

試してみましょう

メソッドを削除しない

BallController3D スクリプトの OnDisable にある _pauseManager.OnPauseResume -= PauseResume; をコメントアウトして実行してみましょう。何が起きるか観察し、なぜそれが起きたのかを考えましょう。

メソッドを追加するのではなく、置き換えてみる

BallController3D スクリプトの OnEnable にある

_pauseManager.OnPauseResume += PauseResume;

_pauseManager.OnPauseResume = PauseResume;

に書き変えて実行してみましょう。何が起きるか観察し、なぜそれが起きたのかを考えましょう。

課題 1 ゲームを一時停止する(10点)

このシーンでは ESC キーを押すと、デリゲートの仕組みを使った Pause 型(bool 型の引数を一つ受け取る)の関数が呼ばれてボールが止まり、画面 (UI) に "PAUSE" と表示される。もう一度 ESC キーを押すと再開する。

しかし、シーン内に「止まっていない」ものがいくつかある。これをボールや UI と同じようにデリゲートの仕組みを使って止めるように機能を追加せよ。

デリゲートを使う意味

デリゲートを使うと、オブジェクトの参照を減らすことができる。また、「呼ばれる側」から任意の関数を登録し、呼んでもらうことができる。結果的に処理を減らすことができる。

特に、オブジェクト同士が相互に参照している関係は相互依存関係となり、設計上はよろしくない。そういう関係になってしまった場合は、デリゲートを使って「疎」な関係にできないか検討しましょう。

event と System.Action

delegate の宣言時 event キーワードを追加すると、デリゲート型の変数に関数を「追加 (+=)」することしかできなくなり、「置換 (=)」することができなくなる。これによって誤って関数を置換してしまうことを防げる。その他の event キーワードの効果は『独習 C#』11.4.2 イベント/デリゲートの相違点 を参照せよ。

System.Action を使うと、戻り値を返さないメソッドに対するデリゲート型の定義と変数宣言を一度に行うことができる。(戻り値を返したい場合は System.Func を使う)なお、System.Action, System.Func はクラスではなくデリゲートである。

System.Action は以下のように使う。

// 引数なしの場合

/// <summary>ターン開始時に呼ばれるメソッド</summary>
public event Action OnBeginTurn;

/// <summary>
/// ターン開始時に呼ぶ
/// </summary>
public static void BeginTurn()
{
	OnBeginTurn();
}

// 引数あり(一つ)の場合

/// <summary>回復時に呼ばれるメソッド</summary>
public event Action<int> OnHeal; // 引数の型を <> の中に書く

/// <summary>
/// HP を回復する
/// </summary>
/// <param name="healHp">回復する HP の値</param>
public void Heal(int healHp)
{
	// 追加されたメソッドを引数を渡して呼び出す
	OnHeal(healHp);
}

// 引数が n 個の場合は、<> の中に型を列挙する

public event Action<int, string, ... , Tn> OnGameOver;

ゲームプログラミングでは、マネージャーから「何かが起きた時(イベント)」に「たくさんの GameObject」に対して命令をしたい事がよくあり、event と System.Action を組み合わせるパターンはよく使う。

参考資料

  • 独習 C#
    • 10.1.4 匿名メソッド - 表 10.2 (System.Action, System.Func 等について)
    • 11.4 イベント

使用例 - ローグライクのターン管理

Cinemachine が入っていなかったらインストールしておくこと(インストールされていないと画面がスクロールしない)。2 Event/RougueLikeTurnBasedGridMovement シーンを開き、実行する。WASD で移動できる。自分が一つ行動すると、敵も一つ行動することを確認する。また、End Turn ボタンをクリックすると、移動せずに自分のターンを終了できる。

考えてみましょう

使用例1で使った PauseManager3D と、使用例2で使った TurnManager は、どちらもデリゲートを管理している。しかし、前者は MonoBehaviour を継承し GameObject に追加しているが、後者は MonoBehaviour を継承せず、GameObject に追加されていない。なぜ前者は GameObject に追加しているのか、なぜ後者は追加していないのか。

課題 2 - ターン数を表示する(5点)

RougueLikeTurnBasedGridMovement シーンに手を加えて、以下のように、画面に「ターン数」が表示されるように機能を追加せよ。

課題 3 - delegate を System.Action に書き変える(5点)

「使用例 1 - ゲームを一時停止する」で使った 1 Delegate/Pause シーンの処理で delegate を使っている所をすべて System.Action に置き換えよ。

その他

System.Action とまったく同じ機能を持った UnityEngine.Events.UnityAction というデリゲートがある。この2つは機能的な違いはないので、どちらを使ってもよい。

キーワード

  1. delegate
  2. マルチキャスト デリゲート
  3. event キーワード
  4. System.Action
  5. System.Func
⚠️ **GitHub.com Fallback** ⚠️