sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

C# Disposable な実装にしてイベントのメモリーリークを防ぐ

2022 年に「C# DI コンテナと CompositeDisposable の組み合わせ」の記事を書きました。DI コンテナの利用有無は、ほとんどの場合、プロジェクトの大きいところになりますが、利用できるならこのほうが楽だし見やすいと思いますがどうでしょうか。

C# のメモリーリークといえば、イベントの購読/解除。(一発のミスがでかいのは、また別かもだけど)

C#ガベージコレクションは、ざっくりと「誰からも参照されていないオブジェクトがあったら消す」という仕組みなので、だれかから参照を受けていると、いつまでもメモリーが開放されないです。

このあたりは「++C++; - イベントの購読とその解除」などを参照してみてください。

特に、このケースに該当するのはイベントを発生させる側の寿命が長くて、イベントを受信する側の寿命が短いときに、実際的なメモリーリークが発生してしまいます。C++ポインター管理に近い設計をしておけばいいんだけど、このとき Reactive Extensions を活用すると、イベント購読解除する側を IDisposable にすることができるので、より似たような(便利な)設計が可能です。

ただ、Reactive Extensions をフルスペックで活用すると、独特の構文・コードに浸食されてしまうのもわかるので、この便利部分だけを抽出・練習してみた一例の記事です。

自分の技術力・練習になるなら車輪の再開発を推奨する人です。

実装(イベントの使い方)

イベントの登録と解除の仕組みは、次の2つのメソッドでできるようにします。Subscribe メソッドでイベントの購読を設定して、最後に Dispose メソッドで購読を解除することにします。

  • Subscripbe((sender, args) => { ... })
  • Dispose()

通常でもイベントの購読は += (sender, args) => { ... } のようにすればいいので、違いがありません。

しかし、解除は -= handler のようにしなければ、購読解除できないのが C# のイベントの辛いところでした。ここがポイントですね。なので、Dispose を呼び出すと引数なしでイベントの購読解除ができるとなると、便利そうだな、という設計にしましょうか。そんなわけで実装。

Disposable のコーディング

まず、IDisposable なオブジェクトにラッパー化する AnonymousDisposable を作成します。この部分は Reactive Extension のコードを参考にします。

/// <summary>
/// <see cref="ICancelable"/> インターフェースは、廃棄状態を調べるプロパティを定義します。
/// </summary>
public interface ICancelable : IDisposable
{
    /// <summary>
    /// オブジェクトが破棄されているかどうかを示す値を取得します。
    /// </summary>
    bool IsDisposed { get; }
}
/// <summary>
/// <see cref="AnonymousDisposable"/> クラスは、<see cref="Action"/> をベースにした <see cref="IDisposable"/> オブジェクトを表現したクラスです。
/// </summary>
public class AnonymousDisposable : ICancelable
{
    #region Fields

    private volatile Action _Dispose;

    #endregion

    #region Properties

    /// <summary>
    /// オブジェクトが破棄されているかどうかを示す値を取得します。
    /// </summary>
    public bool IsDisposed => _Dispose == null;

    #endregion

    #region Initializes

    /// <summary>
    /// <see cref="AnonymousDisposable"/> クラスの新しいインスタンスを初期化します。
    /// </summary>
    /// <param name="dispose"><see cref="IDisposable.Dispose"/> の発生時に実行されるアクション。</param>
    public AnonymousDisposable(Action dispose)
    {
        System.Diagnostics.Debug.Assert(dispose != null);

        _Dispose = dispose;
    }

    #endregion

    #region Public Methods

    /// <summary>
    /// 現在のインスタンスが破棄されていないなら、設定されている匿名アクションを実行します。
    /// </summary>
    public void Dispose()
    {
        // スレッドセーフで実行する
        Interlocked.Exchange(ref _Dispose, null)?.Invoke();
    }

    #endregion
}

/// <summary>
/// <see cref="AnonymousDisposable"/> クラスは、<see cref="Action"/> をベースにした <see cref="IDisposable"/> オブジェクトを表現したクラスです。
/// </summary>
public class AnonymousDisposable<T> : ICancelable
{
    #region Fields

    private T _State;
    private volatile Action<T> _Dispose;

    #endregion

    #region Properties

    /// <summary>
    /// オブジェクトが破棄されているかどうかを示す値を取得します。
    /// </summary>
    public bool IsDisposed => _Dispose == null;

    #endregion

    #region Initializes

    /// <summary>
    /// <see cref="AnonymousDisposable"/> クラスの新しいインスタンスを初期化します。
    /// </summary>
    /// <param name="state"></param>
    /// <param name="dispose"><see cref="IDisposable.Dispose"/> の発生時に実行されるアクション。</param>
    public AnonymousDisposable(T state, Action<T> dispose)
    {
        System.Diagnostics.Debug.Assert(dispose != null);

        _State = state;
        _Dispose = dispose;
    }

    #endregion

    #region Public Methods

    /// <summary>
    /// 現在のインスタンスが破棄されていないなら、設定されている匿名アクションを実行します。
    /// </summary>
    public void Dispose()
    {
        // スレッドセーフで実行する
        Interlocked.Exchange(ref _Dispose, null)?.Invoke(_State);
        _State = default;
    }

    #endregion
}

つぎに、IDisposable なオブジェクトを生成するジェネレーターを作成します。

/// <summary>
/// <see cref="Disposable"/> クラスは、<see cref="IDisposable"/> オブジェクトを作成するための静的メソッドを提供するクラスです。
/// </summary>
public class Disposable
{
    private sealed class EmptyDisposable : IDisposable
    {
        public static readonly EmptyDisposable Instance = new EmptyDisposable();

        private EmptyDisposable(){ }

        public void Dispose()
        {

        }
    }

    #region Properties

    /// <summary>
    /// 廃棄するときに何もしない <see cref="IDisposable"/> を取得します。
    /// </summary>
    public static IDisposable Empty => EmptyDisposable.Instance;

    #endregion

    #region Initializes

    /// <summary>
    /// <see cref="Disposable"/> クラスの新しいインスタンスを初期化します。
    /// </summary>
    public Disposable() { }

    #endregion

    #region Public Methods

    /// <summary>
    /// 廃棄するときに指定したアクションを呼び出す <see cref="IDisposable"/> オブジェクトを作成します。
    /// </summary>
    /// <param name="dispose"><see cref="IDisposable.Dispose"/> が最初に呼び出されたときに実行するアクション。</param>
    /// <returns>廃棄するときに与えられたアクションを実行する <see cref="IDisposable"/> オブジェクト。</returns>
    /// <exception cref="ArgumentNullException">指定した <paramref name="dispose"/><c>null</c> です。</exception>
    public static IDisposable Create(Action dispose)
    {
        if (dispose == null)
        {
            throw new ArgumentNullException(nameof(dispose));
        }

        return new AnonymousDisposable(dispose);
    }

    /// <summary>
    /// 廃棄するときに指定したアクションを呼び出す <see cref="IDisposable"/> オブジェクトを作成します。
    /// </summary>
    /// <param name="state"></param>
    /// <param name="dispose"><see cref="IDisposable.Dispose"/> が最初に呼び出されたときに実行するアクション。</param>
    /// <returns>廃棄するときに与えられたアクションを実行する <see cref="IDisposable"/> オブジェクト。</returns>
    /// <exception cref="ArgumentNullException">指定した <paramref name="dispose"/><c>null</c> です。</exception>
    public static IDisposable Create<T>(T state, Action<T> dispose)
    {
        if (dispose == null)
        {
            throw new ArgumentNullException(nameof(dispose));
        }

        return new AnonymousDisposable<T>(state, dispose);
    }

    #endregion
}

これで、Disposable なオブジェクトを作成するために必要な Reactive Extension のコードを取り出せました。Reactive Extension は大きなプロジェクトですが、こうやって切り出せるのはえらいですね。

イベントを Disposable なオブジェクトに詰め込む

ここは、新しくコーディングします。

ポイントは Subscribe するときに、EventHandler を購読/購読解除の両方を設定して管理していることです。このイベントハンドラーのインスタンスを両方に設定する書き方を楽にできないのが困るところですよね。

/// <summary>
/// <see cref="DisposableEventHandler"/> クラスは、<see cref="EventHandler"/><see cref="IDisposable"/> に対応させ、イベントの登録解除をやりやすくしたクラスです。
/// </summary>
public class DisposableEventHandler : IDisposable
{
    #region Properties

    private List<IDisposable> _Disposables = new List<IDisposable>();

    #endregion

    #region Initializes

    /// <summary>
    /// <see cref="DisposableEventHandler"/> クラスの新しいインスタンスを初期化します。
    /// </summary>
    public DisposableEventHandler() { }

    #endregion

    #region Events

    private event EventHandler Handler;

    #endregion

    #region Public Methods

    /// <summary>
    /// イベントの購読をすべて開放します。
    /// </summary>
    public void Dispose()
    {
        foreach (var disposable in _Disposables)
        {
            disposable?.Dispose();
        }

        _Disposables.Clear();
    }

    /// <summary>
    /// 指定した <see cref="EventHandler"/> を購読します。
    /// <para>
    /// 購読されたイベントハンドラーは、イベントを実行した際に実行されます。
    /// </para>
    /// </summary>
    /// <param name="handler"></param>
    public void SubScribe(EventHandler handler)
    {
        Handler += handler;

        // イベントの購読を終了する
        var disposable = Disposable.Create(() =>
        {
            // Console.WriteLine($"{handler} の購読を解除します。");

            Handler -= handler;
        });

        _Disposables.Add(disposable);
    }

    /// <summary>
    /// <see cref="EventHandler"/> のイベントを実行します。
    /// </summary>
    /// <param name="sender">実行オブジェクト。</param>
    /// <param name="args">イベント引数。</param>
    public void Raise(object sender, EventArgs args) => Handler?.Invoke(sender, args);

    /// <summary>
    /// <see cref="EventHandler"/> のイベントを実行します。
    /// </summary>
    /// <param name="sender">実行オブジェクト。</param>
    /// <param name="args">イベント引数。</param>
    public void Raise(object sender) => Handler?.Invoke(sender, EventArgs.Empty);

    #endregion
}

/// <summary>
/// <see cref="DisposableEventHandler"/> クラスは、<see cref="EventHandler{TEventArgs}"/><see cref="IDisposable"/> に対応させ、イベントの登録解除をやりやすくしたクラスです。
/// </summary>
/// <typeparam name="TEventArgs">イベントによって生成されるイベント データの型。</typeparam>
public class DisposableEventHandler<TEventArgs> : IDisposable
{
    #region Properties

    private List<IDisposable> _Disposables = new List<IDisposable>();

    #endregion

    #region Initializes

    /// <summary>
    /// <see cref="DisposableEventHandler"/> クラスの新しいインスタンスを初期化します。
    /// </summary>
    public DisposableEventHandler() { }

    #endregion

    #region Events

    private event EventHandler<TEventArgs> Handler;

    #endregion

    #region Public Methods

    /// <summary>
    /// イベントの購読をすべて開放します。
    /// </summary>
    public void Dispose()
    {
        foreach (var disposable in _Disposables)
        {
            disposable?.Dispose();
        }

        _Disposables.Clear();
    }

    /// <summary>
    /// 指定した <see cref="EventHandler{TEventArgs}"/> を購読します。
    /// <para>
    /// 購読されたイベントハンドラーは、イベントを実行した際に実行されます。
    /// </para>
    /// </summary>
    /// <param name="handler">イベントによって生成されるイベント データの型。</param>
    public void SubScribe(EventHandler<TEventArgs> handler)
    {
        Handler += handler;

        // イベントの購読を終了する
        var disposable = Disposable.Create(() =>
        {
            // Console.WriteLine($"{handler} の購読を解除します。");

            Handler -= handler;
        });

        _Disposables.Add(disposable);
    }

    /// <summary>
    /// <see cref="EventHandler{TEventArgs}"/> のイベントを実行します。
    /// </summary>
    /// <param name="sender">実行オブジェクト。</param>
    /// <param name="args">イベント引数。</param>
    public void Raise(object sender, TEventArgs args) => Handler?.Invoke(sender, args);

    #endregion
}

サンプルコード

予定通り、こんな感じで使います。public event EventHandler ... という宣言ではなくなって、クラスを定義する形になりました。

EventHandler の実態は delegate なんで、構文をそのままってわけには、どうしてもいかないと思います。

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Hello World!");

        var program = new Program();

        program.Run();
    }

    public DisposableEventHandler<int> Sample = new DisposableEventHandler<int>();

    public void Run()
    {
        Console.WriteLine("---");

        Test1();
        Sample.Dispose();

        Console.WriteLine("---");

        Test2();
    }

    public void Test1()
    {
        Sample.SubScribe((sender, args) =>
        {
            Console.WriteLine($"{sender}.{args}");
        });

        Sample.Raise(this, 1);
        Sample.Raise(this, 2);
    }

    public void Test2()
    {
        using (var sample = new DisposableEventHandler<string>())
        {
            sample.SubScribe((sender, args) =>
            {
                Console.WriteLine($"{sender}={args}");
            });

            sample.SubScribe((sender, args) =>
            {
                Console.WriteLine($"{sender}+{args}");
            });

            sample.Raise(this, "ランス");
            sample.Raise(this, "シィル");
        }
    }
}

実行結果はこちら。

予定通りの感じで動作してそうです。

サンプル

GitHub に「Sample」をあげています。今回のプロジェクトは「DisposableTest」です。

参考