C#の自動実装のイベントは解放されるのか?

まず、おさらいで、C#では、自動実装プロパティのイベント版ともいえる自動実装イベントを以下のように宣言することができます。

public event EventHandler Event;

上述のコードですが、イベントを張ったことによってオブジェクトが破棄されないのでは?と、考えたことありませんか?

イベント貼りっぱなしのオブジェクトが破棄されないことによってメモリリークのような状態が発生しないかなと心配になったので検証してみました。

検証コード

先に結論を書いておきますが、以下のコード例は普通に解放されます。

3ケーステストコードを書いてみました。

  • Case1 : イベントを設定しない普通のオブジェクトの生成と破棄
  • Case2 : イベントを設定したオブジェクトの生成と破棄の確認
  • Case3 : イベントを設定してリソース解放処理(Dispose)を自前で実装したオブジェクトの生成と破棄の確認

なんとなくCase2では解放されないかなと考えていたので期待値は

  • Case1 : 全部解放される
  • Case2 : 解放されない
  • Case3 : 全て解放される

です。

public class Program
{
    public static void Main(string[] args)
    {
        // イベントを設定しないオブジェクトの生成と破棄の確認
        for (int i = 0; i < 50000; i++)
        {
            var case0 = new Case0();
        }
        
        // イベントを設定したオブジェクトの生成と破棄の確認
        for (int i = 0; i < 50000; i++)
        {
            var case1 = new Case1();
            case1.TheEvent += case1_TheEvent;
        }
        
        // イベントを設定してリソース解放処理を自前で実装したオブジェクトの生成と破棄の確認
        for (int i = 0; i < 50000; i++)
        {
            var case2 = new Case2();
            case2.TheEvent += case1_TheEvent;
            case2.Dispose(); // 自前の解放処理を呼ぶ
        }
        
        // GC.Collect(0);
        // GC.WaitForPendingFinalizers();
        
        Console.WriteLine($"Case0のコンストラクタの呼ばれた回数 = {Case0.ConstructorCount}");
        Console.WriteLine($"Case0のデストラクタの呼ばれた回数   = {Case0.DestructorCount}");
        Console.WriteLine("");
        Console.WriteLine($"Case1のコンストラクタの呼ばれた回数 = {Case1.ConstructorCount}");
        Console.WriteLine($"Case1のデストラクタの呼ばれた回数   = {Case1.DestructorCount}");
        Console.WriteLine("");
        Console.WriteLine($"Case2のコンストラクタの呼ばれた回数 = {Case2.ConstructorCount}");
        Console.WriteLine($"Case2のデストラクタの呼ばれた回数   = {Case2.DestructorCount}");
        
        Console.ReadLine();
    }
    private static void case1_TheEvent(object arg1, EventArgs arg2) { }
}

実行結果は以下の通り。

Case0のコンストラクタの呼ばれた回数 = 50000
Case0のデストラクタの呼ばれた回数   = 49997

Case1のコンストラクタの呼ばれた回数 = 50000
Case1のデストラクタの呼ばれた回数   = 49999

Case2のコンストラクタの呼ばれた回数 = 50000
Case2のデストラクタの呼ばれた回数   = 2802

全部のケースで解放されています。Case2は最後の処理のためやや残っているようです。

この程度ならば、参照が残っていてもルートから捨てられたことを.NETのGCが検出して上手いこと解決してくれているみたいです。進行中にコンソール出力したのか実行回数がおかしいですが、解放できている確認できたと思います。同様にGC.Collect()をコメントインした場合、全ての回数が49999となりオブジェクトが解放されている事も確認しました。

各クラスのソースコード

検証に使用したCase1~Case3のクラスは以下の通りです。

イベントを設定しない普通のクラス

public class Case0
{
    // コンストラクタの呼ばれた回数
    public static uint ConstructorCount;
    // デストラクタの呼ばれた回数
    public static uint DestructorCount;

    public Case0()
    {
        Case0.ConstructorCount++;
    }

    ~Case0()
    {
        Case0.DestructorCount++;
    }

    private Random r = new Random();
    public int Foo() { return r.Next(); }
}

自動実装イベントで解放処理を書かないケース

public class Case1
{
    // コンストラクタの呼ばれた回数
    public static uint ConstructorCount;
    // デストラクタの呼ばれた回数
    public static uint DestructorCount;

    // 自動実装イベント
    public event System.EventHandler TheEvent;

    public Case1()
    {
        Case1.ConstructorCount++;
    }

    ~Case1()
    {
        Case1.DestructorCount++;
    }

    public void Foo()
    {
        this.TheEvent?.Invoke(this, new EventArgs());
    }
}

イベントプロパティ + 明示して解放処理を書くケース

実際は自動実装イベントの方が以下のコードより高度なコード(マルチスレッド操作を考慮)をコンパイル時に自動生成するため以下のような実装はあまりよくないみたいです。

public class Case2 : IDisposable
{
    // コンストラクタの呼ばれた回数
    public static uint ConstructorCount;

    // デストラクタの呼ばれた回数
    public static uint DestructorCount;

    // 自動実装じゃないプロパティ
    private Action<object, EventArgs> _TheEvent;
    public event Action<object, EventArgs> TheEvent
    {
        add { this._TheEvent += value; }
        remove { this._TheEvent -= value; }
    }

    public Case2()
    {
        Case2.ConstructorCount++;
    }

    ~Case2()
    {
        Case2.DestructorCount++;
    }

    public void Foo()
    {
        this._TheEvent?.Invoke(this, new EventArgs());
    }

    // 保持しているイベント(デリゲートの参照)の解放
    public void Dispose()
    {
        this._TheEvent = null;
    }
}
ご注意

このケースは、いちばん簡単な例なのでオブジェクトが破棄されましたが、オブジェクトのメソッドの他のオブジェクトのイベントに設定している場合、メソッドを貸している側のオブジェクトを破棄しても想定通り解放されません。(参照が残っているため)

従って以下コード例ではオブジェクト(Observerのインスタンス)は解放(GC)されません。

internal class Program
{
    public static void Main(string[] args)
    {
        // このオブジェクトのメソッドを他のクラスのイベントに設定する
        var observer = new Observer(); 

        var sub = new Subject();
        sub.Update += observer.Update;

        observer = null; // イベントを解放しないでオブジェクトを破棄

        GC.Collect();
        GC.WaitForPendingFinalizers();

        Console.ReadLine(); // デストラクタ呼び出しが来ない
    }
}

public class Subject
{
    public event EventHandler Update;
}

public class Observer
{
    public Observer()
    {
        Console.WriteLine("コンストラクターが呼ばれました。");
    }

    ~Observer()
    {
        Console.WriteLine("デストラクターが呼ばれました。");
    }

    public void Update(object sender, EventArgs e)
    {
        Console.WriteLine("called to Update(...).");
    }
}

こういったときは、イベント解放処理を素直に書くとか、サブスクライブしている側に死亡を通知(し、Subject側で解放)する、イベントに弱参照を使うなど工夫が必要です。GUIで動的にコンテンツを扱い始めると割と簡単にリークするためプログラミング時に意識していた方が良いと思います。

弱参照については以下を確認してください。

http://ufcpp.net/study/csharp/RmWeakReference.html