Как правильно отменить регистрацию обработчика событий

В обзоре кода я наткнулся на этот (упрощенный) фрагмент кода, чтобы отменить регистрацию обработчика событий:

 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);
    }

}

К моему удивлению, произошло следующее:

  • Fire("Hello 1"); вышло два сообщения, как и ожидалось.
  • Fire("Hello 2"); создано одно сообщение!
    Это убедило меня в том, что работают дезарегистрирующие делегаты new!
  • Fire("Hello 3"); выбрал NullReferenceException.
     Отладка кода показала, что Fire является null после отмены регистрации события.

Я знаю, что для обработчиков событий и делегата компилятор генерирует много кода за сценой. Но я до сих пор не понимаю, почему мои рассуждения ошибочны.

Что мне не хватает?

Дополнительный вопрос: из-за того, что Fire есть null, когда нет зарегистрированных событий, я делаю вывод, что везде, где происходит событие, требуется проверка против null.

Ответ 1

Реализация по умолчанию компилятора С# для добавления обработчика событий вызывает Delegate.Combine, удаляя вызовы обработчика событий Delegate.Remove:

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

Реализация Framework Delegate.Remove не относится к самому объекту MyDelegate, но к методу, на который делегат ссылается (Program.OnFire). Таким образом, совершенно безопасно создавать новый объект MyDelegate при отмене подписки на существующий обработчик событий. Из-за этого компилятор С# позволяет использовать сокращенный синтаксис (который генерирует точно такой же код за кулисами) при добавлении/удалении обработчиков событий: вы можете опустить часть new MyDelegate:

Fire += OnFire;
Fire -= OnFire;

Когда последний делегат удален из обработчика события, Delegate.Remove возвращает значение null. Как вы выяснили, необходимо проверить событие против null до его повышения:

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

Он назначается временной локальной переменной для защиты от возможного состояния гонки с отменой подписки на обработчики событий на других потоках. (См. мой пост в блоге для получения подробной информации о безопасности потоков при назначении обработчика события локальной переменной.) Другой способ защиты от этой проблемы - создать пустой делегат, который всегда подписывается; в то время как это использует немного больше памяти, обработчик события никогда не может быть нулевым (и код может быть проще):

public static event MyDelegate Fire = delegate { };

Ответ 2

Вы должны всегда проверять, нет ли у делегата никаких целей (его значение равно null), прежде чем запускать его. Как было сказано ранее, один из способов сделать это - подписаться с анонимным методом do-nothing, который не будет удален.

public event MyDelegate Fire = delegate {};

Однако это просто взломать, чтобы избежать NullReferenceExceptions.

Просто просто набирая, является ли делегат нулевым до вызова, не является потокобезопасным, так как другой поток может отменить регистрацию после проверки нулевого значения и сделать его нулевым при вызове. Существует другое решение - скопировать делегат во временную переменную:

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");