PG日誌

各記事はブラウザの横幅を1410px以上にすると2カラムの見出しが表示されます。なるべく横に広げてみてください。

【C#】コンストラクタの挙動まとめ

C# のコンストラクターの宣言のされ方による呼び出しの基本的な動作のまとめです。暗黙のコンストラクターと継承したときの挙動を中心に確認しています。内容は自分用のメモです。久しぶりに気にすると動きを忘れていることがあったので改めて文字に起こしています。

デフォルトコンストラクターの暗黙的な追加

コンストラクターを宣言しないと引数のないデフォルトコンストラクターが暗黙的に追加されます。

public class Foo
{
    // コンストラクターを宣言しない
}

// 以下のように宣言が追加される
// ↓

public class Foo
{
    public Foo()
    {
        // これをコンパイラーが自動で追加してくれる
    }
}

また引数ありのコンストラクターを宣言すると自動で追加されません。

public class Foo
{
    // こうするとデフォルトコンストラクターが自動で追加されなくなる
    public Foo(int a) { }
}

フィールド初期化はコンストラクタが実行

C#はフィールド変数とプロパティを宣言したときに初期化できますが、これはコンパイルするとそれぞれのコンストラクター内に初期化が移動されます。所謂シンタックスシュガーですね。

public class Foo
{
    // フィールドをインラインで初期化
    private int a = 99; 
    private string b = "str";

    // プロパティをインラインで初期化
    public double V { get; private set; } = 8.88;

    // コンストラクタを2つ宣言する
    public Foo(int a) { }
    public Foo(string b) { }
}

// 以下のように宣言が移動される
// ↓

public class Foo
{
    private int a; 
    private string b;

    public double V { get; private set; };

    // こんな感じにそれぞれのコンストラクタに初期化が追加される
    public Foo(int a)
    {
        a = 99;
        b = "str";
        V = 8.88;
        
        // この後に自分で書いた処理が入る
    }
    public Foo(string b)
    {
        a = 99;
        b = "str";
        V = 8.88;
    }
}

ILで見ると以下のようにコンストラクター内に初期化が展開されていることが確認できます。

.method public hidebysig specialname rtspecialname 
    instance void .ctor (
        int32 a
    ) cil managed 
{
    // Method begins at RVA 0x2061
    // Code size 41 (0x29)
    .maxstack 8

    IL_0000: ldarg.0
    IL_0001: ldc.i4.s 99
    IL_0003: stfld int32 C::a
    IL_0008: ldarg.0
    IL_0009: ldstr "str"
    IL_000e: stfld string C::b
    IL_0013: ldarg.0
    IL_0014: ldc.r8 8.88
    IL_001d: stfld float64 C::'<V>k__BackingField'
    IL_0022: ldarg.0
    IL_0023: call instance void [System.Private.CoreLib]System.Object::.ctor()
    IL_0028: ret
} // end of method C::.ctor

.method public hidebysig specialname rtspecialname 
    instance void .ctor (
        string b
    ) cil managed 
{
    // Method begins at RVA 0x2061
    // Code size 41 (0x29)
    .maxstack 8

    IL_0000: ldarg.0
    IL_0001: ldc.i4.s 99
    IL_0003: stfld int32 C::a
    IL_0008: ldarg.0
    IL_0009: ldstr "str"
    IL_000e: stfld string C::b
    IL_0013: ldarg.0
    IL_0014: ldc.r8 8.88
    IL_001d: stfld float64 C::'<V>k__BackingField'
    IL_0022: ldarg.0
    IL_0023: call instance void [System.Private.CoreLib]System.Object::.ctor()
    IL_0028: ret
} // end of method C::.ctor

自クラス内の他のコンストラクタの呼び出し

コンストラクターから自分のクラス内の他のコンストラクター呼び出しを行うことができる。冗長なコードの重複をこれで避けることができる。

public class A
{
    public A() => Console.WriteLine("A");
    public A(string str) : this() => Console.WriteLine(str); // 自分のクラス内の他のコンストラクタ呼び出しを行う
}

var a = new A("str");
// > str
// > A

基底クラスのコンストラクタの暗黙的な呼び出し

特に指定しない場合、暗黙的に基底クラスのデフォルトコンストラクターの呼び出しが連鎖が発生します。

例えば、A → B → C のように継承関係のあるクラスで特に指定しない場合デフォルトコンストラクターが暗黙で A → B → C の順で親から順に呼び出されます。

public class A
{
    public A() => Console.WriteLine("A");
}

public class B : A
{
    public B() => Console.WriteLine("B");
}

public class C : B
{
    public C() => Console.WriteLine("C");
}

// こんな感じに呼び出すと出力が以下の通りになる
var c = new C();
// > A
// > B
// > C

自分で base(...) を指定すると任意の基底クラスのコンストラクターを指定できます。途中で指定しないとデフォルトコンストラクター呼び出しに切り替わります。

public class A
{
    public A() => Console.WriteLine("A");
}

public class B : A
{
    public B() => Console.WriteLine("B-1");
    public B(string b) => Console.WriteLine("B-2"); // 基底クラスのコンストラクタを指定しない
}

public class C : B
{
    public C() => Console.WriteLine("C-1");
    public C(string c) : base(c) => Console.WriteLine("C-2"); // 基底クラスのコンストラクタを指定
}

// この場合以下のような出力になる
C c = new C("str");
// > A
// > B-2 // ★ここからデフォルトコンストラクター呼び出しに変わる
// > C-2

コンストラクタ内での仮想メソッド呼び出し

C# はコンストラクタ内で仮想メソッドを呼び出しても正常に動作する(C++は違い保証があります)

仮想メソッドテーブル(vtable)は初期化が完了した状態でコンストラクターの処理に入ります。

public class A
{
    public virtual void Foo() => Console.WriteLine("A");
}

public class B : A
{
    public override void Foo() => Console.WriteLine("B");
}

public class C : B
{
    public override void Foo() => Console.WriteLine("C");
    public C() => this.Foo();
}

// コンストラクタ内で仮想メソッド呼び出ししても正常な動作が保証される
C c = new C();
// > C
A a = new C();
// > C

UnityのMonoBehaviorで自作コンストラクターの宣言

Unity で MonoBehavior を継承したクラスで自作コンストラクターを宣言してはいけません。というかデフォルトコンストラクターの無い自作のコンポーネントを作成してはいけません(Unity はユーザーが任意にに作成したコンストラクターの引数の事情が分からないのでデフォルトコンストラクターを固定で呼出そうとするためです)

従ってコンポーネントを Unity がインスタンス化する時には自動でデフォルトコンストラクターを呼び出そうとする → デフォルトコンストラクターを呼ぼうとする → 失敗 → 処理が失敗しているにも関わらずコンポーネントが生成された扱いになる → そのあと Awake, Update を呼び出そうとする → 初期化が済んでない不定な状態のオブジェクトの処理が開始さる → エラーが起きる、のような流れで動作がおかしくなります。

public class MyComponent : MonoBehavior
{
    // こうやって自作のコンストラクターを宣言すると
    // 暗黙のデフォルトコンストラクター生成がされずにおかしい動作になる
    public MyComponent(string a) => Debug.Log("★");
}

// 暗黙のコンストラクターで実行されるはずのフィールド初期化と
// Unity が自動で解決してる シリアライズされたフィールドへの値の設定が未完了のまま後の処理が開始される

また、Unity から呼び出されないので実際は無意味ですがいちおう以下のおとりデフォルトコンストラクターを宣言しておけばエラーになりません。

public class MyComponent : MonoBehavior
{
    // デフォルトコンストラクターを明示して宣言すればエラーは起きない
    public MyComponent() => Debug.Log("〇");
    public MyComponent(string a) => Debug.Log("★"); // ただしこっちは呼ばれないので無意味
}

なので、コンストラクター自体を一切宣言NGではなく、デフォルトコンストラクターを明示しその中であれば自由に処理を記述しても問題ありません(どうせコンパイラーがデフォルトコンストラクターを暗黙追加してメンバー初期化をコンストラクタ内で実行しているので明示して宣言しようがあまり大差ないです)

public class MyComponent : MonoBehavior
{
    public MyComponent()
    {
        // ★このコンストラクター内であれば自由に処理を記述してもいい
    }
}

但し、このコンストラクター内で例外が出るような処理を書いて、実際に例外が起きるとかなり変な事になるため初期値の設定などの基本的に失敗しない動作を書きましょう(とは言っても、シリアライズされるフィールドに値が設定されていなかったり、Editor 上で実行 → 終了する時に必ずコンストラクター(とファイナライザー)が呼び出されたりするのでそこに色々書くと考慮することが多くなって大変なことなりがちなのでやはりコンポーネントの初期化であれば Awake や Start, 自作の初期化メソッドに書くのが良いと思います。

以上です