🎀

【C#】event を IDisposable で購読する

2024/06/28に公開

C#のeventIDisposableスタイルで購読できるようにするライブラリを作りました。.NET/Unity対応です。

using System;
using Disposify;

var c = new C();

using (c.Disposify().SomeEvent(v => ++v)) // 購読してIDisposableを返す
{
    c.Invoke(100);

} // Dispose()で購読解除

public class C
{
    public event Func<int, int>? SomeEvent;
    public int Invoke(int a) => SomeEvent.Invoke(a);
}

https://github.com/ruccho/Disposify

背景

C#のeventでは、購読と購読解除を一対一に対応して行いたい場合がけっこうありますが、ちゃんとやろうとすると結構ダルいコードが必要になります。

Action callback = () => Console.WriteLine("INVOKED!");
SomeEvent += callback;

try
{
    DoSomething();
}
finally
{
    // 例外が発生しても確実に購読解除
    // 購読時と同じデリゲートのインスタンスを渡す必要があるので、ラムダ式だと面倒
    SomeEvent -= callback;
}

https://ufcpp.net/study/csharp/MiscEventSubscribe.html

Rx.NETやUniRxなどでは、イベントをストリームに変換しIDisposableスタイルで購読することができます。しかしこれも購読と購読解除のコードを自分で書く必要があったりします。

// UniRx
IDisposable subscription = Observable.FromEvent(h => c.SomeEvent += h, h => c.SomeEvent -= h).Subscribe(/* */);

なぜこれをうまく短縮して書けないかというと、C#ではeventそのものを引数として渡すことができず、eventを受けとってよしなに購読と購読解除を行う、みたいなAPIを実装できないからです。一応文字列でイベント名を指定する方法はありますが、それだと基本的にリフレクションが必要になります。

Disposify

Disposifyは非常に簡潔な記述を行えるようにしました。

IDisposable subscription = c.Disposify().SomeEvent(v => ++v);

これはSource Generatorの力によるものです。上記のように、任意のオブジェクトに対してDisposify()メソッドを呼ぶと、背後で次のような拡張メソッドが生えます。

namespace Disposify
{
    partial class DisposifyInternal
    {
        public static global::Disposify.Generated.CDisposifier Disposify(this global::C? target) => new global::Disposify.Generated.CDisposifier(target);
    }
}

Disposify.Generated.CDisposifierという型が見えますが、こちらも自動生成されています。

internal readonly struct CDisposifier
{
    private readonly global::C? Target;
    public CDisposifier(global::C? target) => Target = target;

    public global::Disposify.Disposable SomeEvent(global::System.Func<int, int> @delegate)
    {
        Target!.SomeEvent += @delegate;
        return global::Disposify.Disposable.Create(Target, @delegate, static (target, @delegate) => target.SomeEvent -= @delegate);
    }
}

こんな感じでDisposify()を呼び出した対象の型にあるイベントに対して、購読・購読解除を行うコードを自動的に生成して、Disposableにまとめてくれます。

拡張メソッドのオーバーロード差し替え

Disposify()メソッドを呼ぶとそれに応じてDisposify()が生える」という説明をしましたが、これだけだと初めにDisposify()を書くときにIntelliSenseが候補を出すことができません。

そこで、任意の型に対して呼び出せるDisposify()をあらかじめ(静的なコードとして)定義しています。

namespace Disposify
{
    public static class DisposifyExtensions
    {
        public static dynamic Disposify<T>(this T? target)
        {
            // デフォルト実装(dynamicによるもの)が使われる
            return target != null ? new DefaultDisposifier(target) : new DefaultDisposifier(typeof(T));
        }
    }
}

これに被せる形で特定の型に対するオーバーロードを自動生成すると、オーバーロードの解決先が生成コード側に差しかわるという仕組みです。

生成コードによるオーバーロードの差し替えは、入力補完の快適性とコード生成による恩恵を両立するうえで非常に役に立つテクニックです。最近だとUnity Loggingのログ出力におけるパラメータ補間や、ConsoleAppFramework v5などで使われています。

https://neue.cc/2024/06/13_ConsoleAppFramework_v5.html

パフォーマンス

購読時に作成されるIDisposableのインスタンスだけがヒープアロケーションになりますが、このインスタンスもプーリングを行っており、購読解除されるとIDisposableのインスタンスはプールに戻ります。同時に大量のイベントを購読しない限りはGCゴミが発生しにくくなっています。

まとめ

C#のイベント処理に関してはRxをはじめとして様々な外部ライブラリによる拡張が行われてきましたが、言語としてのファーストクラスはいまだにeventでそれなりに出番は多く、そしてほかの言語機能とノリがちょっと違って微妙な取り回しの悪さがあります。Disposifyはそのあたりを手軽にカバーできて開発体験の向上に役に立つんじゃないでしょうか。

Discussion