T.TAO
Back to Blog
/18 min read/Programming

Unity Engine #0 C# 基礎 2

#programming
Unity Engine #0 C# 基礎 2

Unity Engine #0 C# 基礎 2

このノートは主に C# 言語についてです。Unity の機能を完全に理解するには、C# 言語から始める必要があります。この部分は型にはまっていますが、プログラムのパフォーマンス向上と動作の理解に役立ちます。

基本概念

C# では2種類の型に分かれます。

値型:int, short, long, sbyte, bool, float, char, struct, enum

参照型:string, object, delegate, interface, class, array

これらには以下の違いがあります:

C# では、

  • メモリスタック:スレッドスタック。値型と参照型のマネージドヒープ内アドレスを格納。スタック内の値型はスコープ終了後にクリーンアップされ、効率が高い。
  • マネージドヒープ:参照型を格納。マネージドヒープ内のオブジェクトはガベージコレクタ(Garbage Collector、GC)によって管理・解放される。

値と参照

  1. 単独の値型変数(例:関数のローカル変数)はすべてスタック上で管理される。
  2. 値型がカスタム class のフィールド・プロパティである場合、スタック上に独立して格納されず、参照型とともにマネージドヒープに格納される。このとき、それは参照型の一部となる。例えば、Point は struct(値型)だが、Shape(class、参照型)のインスタンスのフィールドとしてある場合、マネージドヒープに格納される。
  3. すべての参照型はマネージドヒープに格納される。
  4. struct(値型)内で参照型フィールドを定義する場合、構造体自体はスタックに格納され、その参照変数フィールドはメモリアドレスのみを格納(メモリアドレス自体もスタックに格納)、ヒープ内の参照インスタンスを指す。
  5. 値型変数を別の変数に代入(またはパラメータとして渡す)と、値のコピーが1回実行される。参照型変数を別の参照型変数に代入すると、コピーされるのは参照オブジェクトのメモリアドレスなので、代入後は複数の変数が同じ参照オブジェクトインスタンスを指す。

out と ref キーワード

  1. out と ref キーワードはどちらもコンパイラにパラメータのアドレスを渡すよう指示し、動作は同じ。
  2. ref は使用前にパラメータを明示的に初期化する必要がある。out はメソッド内で初期化する必要がある。
  3. out パラメータはメソッド内で必ず新しい値を与える必要がある。外部で渡された変数に既に値があっても、メソッド内で再代入する必要がある。つまり、渡された値を単に変更するのではなく、この変数に明確に新しい値を与える必要がある。
  4. out と ref はオーバーロードに使用できない。つまり void TestMethod(ref int a) と void TestMethod(out int a) はオーバーロードとして区別できない。

String 型

特に、string は参照型として一定の特殊性がある。string のあらゆる変更は、実際には新しい string を new する。新しい参照型を new する限り、ヒープに新しい領域が割り当てられる。このときスタック内のコピーもヒープ内の新しいオブジェクトを指すため、string は変更され新しいオブジェクトとなり、元の string とは関係がなくなる。

解決策は、文字列を頻繁に変更する場合は、String の代わりに StringBuilder クラスを使用すること。

StringBuilder の基本的な考え方

おそらく拡張をサポートする char 配列。スペースが不足すると元の配列サイズの容量を確保し、新しく作成された配列は使い切った前の配列を指す。GC 自体は呼び出されない。

Garbage Collector / GC

Garbage Collector(GC と略称)は、C# に付属するガベージコレクタ。これらはもう使用されないメモリの内容を管理する責任がある。詳細は Unity Engine #1 メモリのセクションを参照。ここではいくつかの概念を簡潔に説明する。

  1. メモリ管理プール:上述の通り、Unity にはメモリヒープ(heap)とメモリスタック(stack)の2つの内部部分がある。Stack は小さく一時的なデータの格納に、Heap は大きく永続的なデータの格納に使用。
  2. 変数がアクティブである限り、対応するメモリ使用量は Allocated 状態。
  3. 変数がアクティブでなくなり、占有するメモリが不要になると回収される。Stack 上の回収は高速だが、Heap 上のガベージは即座に回収されない。そのメモリは使用中としてマークされる。使用されなくなったメモリは GC フェーズでのみ回収される。

GC 操作はパフォーマンスに大きく影響するため、GC を避けるように努めるべき。

ボクシング / アンボクシング

ボクシングとは、値型を object 型、またはこの値型が実装する任意のインターフェース型に変換するプロセス。

実際の操作:

  • Heap に行き Object クラスのオブジェクトを new する。
  • 値型データを Object クラスオブジェクトに格納する。
  • Heap 上に作成されたオブジェクトのアドレスを参照型変数に返す。

逆のステップがアンボクシング。アンボクシングは object 型から値型(またはインターフェース型)への明示的な変換。

  • ボクシングされたオブジェクトのアドレスを取得し、オブジェクトインスタンスをチェックして指定された値型のボクシングされた値であることを確認する。
  • 値をインスタンスから値変数型にコピーする。

ボクシングとアンボクシングは実際に多くの GC を生成する。Unity のボクシング操作では、Heap 上に System.Object 型の参照が割り当てられてカプセル化され、対応するキャッシュがメモリガベージを生成する。コード内で変数に対する直接的なボクシング操作がなくても、関数やサードパーティプラグインにこのような現象が存在する可能性が高い。ボクシングとアンボクシング操作をできるだけ減らすべき。

方法:

  1. ジェネリクスを使用する。
  2. yield return 0 の代わりに yield return null を使用する。
  3. 不要な Log を減らす。

オブジェクト指向プログラミング

周知の通り、C# は C++ と同様に Object Oriented Programming。オブジェクト指向言語には多くの重要な特徴がある。

カプセル化

カプセル化とは、コードがデータを変更できる範囲を制限することでデータの安全性を高めること。オブジェクトの内部状態と機能実装の詳細を隠蔽し、必要な操作インターフェースプロセスのみを公開する。

C# では、アクセス修飾子を使用してメンバーのアクセスレベルを制御する。例えば:

  • public:任意のクラスとメンバーに公開、アクセス制限なし。
  • private:現在のクラスにのみ公開。
  • protected:現在のクラスとその派生クラスに公開。
  • internal:このクラスを含むアセンブリ内でのみアクセス可能。

protected 修飾子の動作に注意。

Plain Textpublic class BaseClass
{
    protected int protectedField;

    public void BaseMethod()
    {
        protectedField = 10; // 本クラス内でアクセス可能
    }
}

public class DerivedClass : BaseClass
{
    public void DerivedMethod()
    {
        protectedField = 20; // 派生クラス内でアクセス可能
    }
}

public class AnotherClass
{
    public void AnotherMethod()
    {
        BaseClass baseClass = new BaseClass();
        // baseClass.protectedField = 30; // 許可されず、コンパイルエラーになる
    }
}

ここで、AnotherClass は BaseClass の派生クラスではないため、protected で修飾された int 値型変数 protectedField は BaseClass と DerivedClass によってのみアクセスでき、AnotherClass ではアクセスできない。

継承

継承はもう一つの重要な特徴。継承により、新しく作成されたクラス(派生クラス)が既存のクラス(基底クラス)のプロパティとメソッドを継承できる。

C# では単一継承のみサポート。各クラスは1つの基底クラスからのみ継承できるが、複数のインターフェースを実装できる。

最も簡単な例は、Unity で新しいスクリプトを作成すると MonoBehavior クラスを継承すること。新しいスクリプトでは MonoBehavior に既に定義されている関数を使用できる。

Plain Textpublic class Cube : MonoBehavior { 
    void Start(){}
    void Update(){}
}

ここで、Cube は MonoBehavior の派生クラス。MonoBehavior は Cube の基底クラス。

注意:新しいスクリプトで書いたこれらの MonoBehavior 関数の具体的な実装は、一見オーバーライドのように見えるが、実際には MonoBehavior クラスで void メソッドや abstract メソッドとして明確に定義されていない。これらは実際には Unity エンジンが特定のメカニズムを通じて特別に呼び出す。Unity エンジンは実行時に MonoBehavior の派生クラスに Start()、Update() のような特定の関数が存在するかチェックし、存在する場合は規定の時間にこれらの新しい関数を自動的に呼び出す。これはオブジェクト指向の継承ではなく特殊なリフレクション機構であるため、以下の多態性で言及する override キーワードは不要。

C# では、sealed キーワードを使用して他のクラスがそのクラスを継承するのを防げる。メソッドで sealed キーワードを使用すると、他のクラスがそのメソッドをオーバーライドするのを防げる。この機構をシーリングと呼ぶ。

多態性

多態性とは、同じ操作が異なるクラスインスタンスで異なる実装を持つことができること。

C# では、多態性はメソッドオーバーライド修飾子 override とインターフェース実装によって実現される。派生クラスオブジェクトをその基底クラス型のオブジェクトとして扱いながら、固有の動作を保持することを可能にする。

この例で多態性を説明できる。

Plain Text// 基底クラス
public class Animal
{
    public virtual void MakeSound()
    {
        Console.WriteLine("Some sound");
    }
}

// 派生クラス Dog
public class Dog : Animal
{
    public override void MakeSound()
    {
        Console.WriteLine("Bark");
    }
}

// 派生クラス Cat
public class Cat : Animal
{
    public override void MakeSound()
    {
        Console.WriteLine("Meow");
    }
}

class Program
{
    static void Main(string[] args)
    {
        Animal myDog = new Dog();
        Animal myCat = new Cat();

        // myDog と myCat はどちらも Animal 型だが、それぞれの派生クラスの実装を呼び出す
        myDog.MakeSound(); // 出力 "Bark"
        myCat.MakeSound(); // 出力 "Meow"

        // 基底クラス型でオブジェクトを格納することもできる
        Animal[] animals = new Animal[2];
        animals[0] = new Dog();
        animals[1] = new Cat();

        foreach (var animal in animals)
        {
            animal.MakeSound(); // それぞれ "Bark" と "Meow" を出力
        }
    }
}

基底クラスは Animal で、Dog と Cat はその派生クラス。基底クラスは関数 MakeSound を定義し、異なる派生クラスで override を通じてこの関数をオーバーライドし、それぞれ独自の定義を持たせる。基底クラスを通じて直接呼び出すと、派生クラスの MakeSound メソッドが自動的に呼び出される。

抽象化

C# では、抽象クラス(インスタンス化できないクラス)とインターフェース(実装のないメソッドのセットを定義する)を通じて抽象化を実現できる。

抽象クラスは抽象メソッド(実装のないメソッド)と具体メソッド(実装のあるメソッド)を含むことができる。コンストラクタ、フィールド、メソッド、プロパティなどのメンバーを含むことができる。抽象メソッドは抽象クラスでのみ宣言でき、非抽象の派生クラスでオーバーライドする必要がある。

以下の例は抽象クラスのコード例。上記の多態性のコードと比較できる。実際の重点は関数の抽象化(抽象クラスには非抽象メソッドも含めることができる)。

Plain Textpublic abstract class Animal
{
    public abstract void MakeSound();

    // 抽象クラスは非抽象メソッドも含めることができる
    public void Eat()
    {
        Console.WriteLine("Animal is eating.");
    }
}

public class Dog : Animal
{
    public override void MakeSound()
    {
        Console.WriteLine("Bark");
    }
}

public class Cat : Animal
{
    public override void MakeSound()
    {
        Console.WriteLine("Meow");
    }
}

この例では、MakeSound が唯一の抽象メソッド(abstract 修飾が必要)。Eat は具体メソッド。抽象メソッドは派生クラス Dog と Cat で具体的に実装する必要がある。

インターフェース(Interface)は完全に抽象的な構造で、メンバーのシグネチャのみを定義し、実装は含まない。ただし、インターフェースはフィールド、コンストラクタ、デストラクタを含むことができない。すべてのメンバーはデフォルトで public。アクセス修飾子を含むことができない。

以下の例はインターフェースのコード例。

Plain Textpublic interface IVehicle
{
    void StartEngine(); // エンジン起動
    void StopEngine();  // エンジン停止
    void Drive();       // 運転
    // 上記関数はいずれも具体的な実装を持たない
}

public class Car : IVehicle
{
    public void StartEngine()
    {
        Console.WriteLine("Car engine started.");
    }

    public void StopEngine()
    {
        Console.WriteLine("Car engine stopped.");
    }

    public void Drive()
    {
        Console.WriteLine("Car is driving.");
    }
}

public class Boat : IVehicle
{
    public void StartEngine()
    {
        Console.WriteLine("Boat engine started.");
    }

    public void StopEngine()
    {
        Console.WriteLine("Boat engine stopped.");
    }

    public void Drive()
    {
        Console.WriteLine("Boat is sailing.");
    }
}

具体クラスがインターフェース内の具体関数を実装する。ただし、インターフェース自体はフィールドを持つことができず、抽象メソッドのみ持つことができる。

対比すると、インターフェースはクラスによって実装され、抽象クラスはクラスによって継承される。インターフェースはメソッドを宣言するだけで実装を持てないが、抽象クラスは具体実装を持つことができる。1つのクラスは複数のインターフェースを実装できるが、1つのクラスからのみ継承できる。抽象クラスは異なるアクセスレベルを持つことができるが、インターフェースのすべてのメンバーはデフォルトで public。

構造体(Struct)

構造体(Struct)はクラスの構造と非常に似ているが、多くの本質的な違いがある。

クラスとの違い

  1. Struct は値型でスタックに格納され、class は参照型でヒープに格納される。したがって、Struct はアクセス速度が速く容量が小さく、点座標、矩形、円、色などの軽量オブジェクトにのみ適している。
  2. Struct はパラメータなしコンストラクタを宣言できないが、クラスはできる。
  3. Struct 定義時、メンバーは初期化できない。構造体を定義する際、すべてのメンバーを自分で代入して初期化する必要がある。
  4. 構造体を宣言した後、new で構築オブジェクトを作成することも、new なしで作成することもできる。new なしの場合、すべてのフィールドを初期化するまで、フィールドは未代入のままでオブジェクトは使用できない。
  5. Struct は継承できない。
  6. Struct は static で修飾できないが、クラスはできる。

リスコフの置換原則

Void 関数

デリゲートとイベント

非同期プログラミング

Async と Await キーワード

コルーチン

マルチスレッドプログラミング

例外処理

最適化

参考文献:

  1. Unity ゲーム開発クライアント面接——C#(初級):https://blog.csdn.net/Sea3752/article/details/127354146?spm=1001.2014.3001.5501
  2. C# 面接問題:https://www.cnblogs.com/xiaomandujia/p/17903172.html