web-dev-qa-db-ja.com

イベントハンドラーを正しく登録解除する方法

コードレビューで、この(簡略化された)コードフラグメントを見つけて、イベントハンドラーの登録を解除しました。

_ Fire -= new MyDelegate(OnFire);
_

これは、イベントハンドラーの登録を解除しないと考えました。これは、これまで登録されたことのない新しいデリゲートを作成するためです。しかし、MSDNを検索すると、このイディオムを使用するコードサンプルがいくつか見つかりました。

だから私は実験を始めました:

_internal class Program
{
    public delegate void MyDelegate(string msg);
    public static event MyDelegate Fire;

    private static void Main(string[] args)
    {
        Fire += new MyDelegate(OnFire);
        Fire += new MyDelegate(OnFire);
        Fire("Hello 1");
        Fire -= new MyDelegate(OnFire);
        Fire("Hello 2");
        Fire -= new MyDelegate(OnFire);
        Fire("Hello 3");
    }

    private static void OnFire(string msg)
    {
        Console.WriteLine("OnFire: {0}", msg);
    }

}
_

驚いたことに、次のことが起こりました。

  1. Fire("Hello 1");は、予想どおり2つのメッセージを生成しました。
  2. Fire("Hello 2");は1つのメッセージを生成しました!
    これにより、newデリゲートの登録解除が機能することがわかりました。
  3. Fire("Hello 3");NullReferenceExceptionをスローしました。
    コードをデバッグすると、イベントの登録を解除した後、Firenullであることが示されました。

イベントハンドラーとデリゲートについては、コンパイラーが舞台裏で多くのコードを生成することを知っています。しかし、私はまだ私の推論が間違っている理由を理解していません。

私は何が欠けていますか?

追加の質問:Firenullであるという事実から、イベントが登録されていない場合、イベントが発生するすべての場所で、nullに対するチェックが必要であると結論付けます。

64
gyrolf

イベントハンドラを追加するC#コンパイラのデフォルトの実装はDelegate.Combineを呼び出し、イベントハンドラを削除するとDelegate.Removeを呼び出します。

Fire = (MyDelegate) Delegate.Remove(Fire, new MyDelegate(Program.OnFire));

フレームワークのDelegate.Removeの実装は、MyDelegateオブジェクト自体ではなく、デリゲートが参照するメソッド(Program.OnFire)を参照します。したがって、既存のイベントハンドラのサブスクライブを解除するときに、新しいMyDelegateオブジェクトを作成してもまったく安全です。このため、C#コンパイラーでは、イベントハンドラーを追加/削除する際に、簡単な構文(舞台裏でまったく同じコードを生成する)を使用できます。new MyDelegate部分は省略できます。

Fire += OnFire;
Fire -= OnFire;

最後のデリゲートがイベントハンドラーから削除されると、Delegate.Removeはnullを返します。お気付きのとおり、イベントを発生させる前にイベントをnullと照合することが不可欠です。

MyDelegate handler = Fire;
if (handler != null)
    handler("Hello 3");

一時的なローカル変数に割り当てられ、他のスレッドでサブスクライブを解除するイベントハンドラーによる競合状態を防ぎます。 (イベントハンドラーをローカル変数に割り当てるスレッドセーフの詳細については、 私のブログ投稿 を参照してください。)この問題を防ぐ別の方法は、常にサブスクライブされる空のデリゲートを作成することです。これはもう少し多くのメモリを使用しますが、イベントハンドラーをnullにすることはできません(コードはより単純になります)。

public static event MyDelegate Fire = delegate { };
82

デリゲートを起動する前に、デリゲートにターゲットがない(値がnullである)かどうかを常に確認する必要があります。前述したように、これを行う1つの方法は、削除されないdo-nothing匿名メソッドでサブスクライブすることです。

public event MyDelegate Fire = delegate {};

ただし、これはNullReferenceExceptionsを回避するための単なるハックです。

呼び出し前にデリゲートがnullであるかどうかをチェックするだけではスレッドセーフではありません。他のスレッドはnullチェック後に登録解除でき、呼び出し時にnullにすることができます。他の解決策は、デリゲートを一時変数にコピーすることです。

public event MyDelegate Fire;
public void FireEvent(string msg)
{
    MyDelegate temp = Fire;
    if (temp != null)
        temp(msg);
}

残念ながら、JITコンパイラはコードを最適化し、一時変数を削除し、元のデリゲートを使用する場合があります。 (Juval Lowy-.NETコンポーネントのプログラミングによる)

したがって、この問題を回避するには、デリゲートをパラメーターとして受け入れるメソッドを使用できます。

[MethodImpl(MethodImplOptions.NoInlining)]
public void FireEvent(MyDelegate fire, string msg)
{
    if (fire != null)
        fire(msg);
}

MethodImpl(NoInlining)属性がないと、JITコンパイラーはメソッドをインライン化して価値がないことに注意してください。デリゲートは不変なので、この実装はスレッドセーフです。このメソッドは次のように使用できます。

FireEvent(Fire,"Hello 3");
15
user37325