От переводчика


Часто начинающие разработчики спрашивают, зачем при вызове обработчика нужно копировать его в локальную переменную, а как показывает код ревью, даже опытные разработчики забывают об этом. В C# 6 разработчики языка добавили много синтаксического сахара, в том числе null-conditional operator (null-условный оператор или Элвис-оператор — ?.), который позволяет нам избавиться от ненужного (на первый взгляд) присваивания. Под катом объяснения от Джона Скита — одного из самых известных дот нет гуру.

Проблема


Вызов обработчика в языке C# всегда сопровождался не самым очевидным кодом, потому что событие, у которого нет подписчиков, представлено в виде null ссылки. Из-за этого мы обычно писали так:
public event EventHandler Foo;
 
public void OnFoo()
{
    EventHandler handler = Foo;
    if (handler != null)
    {
        handler(this, EventArgs.Empty);
    }   
}

Локальную переменную handler нужно использовать потому, что без нее к обработчику события Foo доступ идет 2 раза (при проверке на null и при самом вызове). В таком случае есть вероятность, что последний подписчик удалится как раз между этими доступами к Foo.
// Плохой код, не делайте так!
if (Foo != null)
{
    // Foo может быть null, если доступ
    // к классу идет из нескольких потоков.
    Foo(this, EventArgs.Empty);
}

Этот код можно упростить, создав метод расширения:
public static void Raise(this EventHandler handler, object sender, EventArgs args)
{
    if (handler != null)
    {
        handler(sender, args);
    }   
}

Тогда используя этот метод расширения, первый вызов перепишется:
public void OnFoo()
{
    Foo.Raise(this, EventArgs.Empty);
}

Минус данного подхода в том, что метод расширения придется писать для каждого типа обработчика.

C# 6 нас спасет!


Null-условный оператор (?.), появившийся в C# 6, может использоваться не только для доступа к свойствам, но и для вызова методов. Компилятор вычисляет выражение только один раз, поэтому код можно писать без использования метода расширения:
public void OnFoo()
{
    Foo?.Invoke(this, EventArgs.Empty);
}

Ура! Этот код никогда не выбросит NullReferenceException, и нам не нужны вспомогательные классы.

Конечно, было бы лучше, если бы мы могли написать Foo?(this, EventArgs.Empty), но тогда это был бы уже не ?. оператор, что немного усложнило бы язык. Поэтому дополнительный вызов Invoke меня не сильно беспокоит.

Что это за штука — потокобезопасность?


Написанный нами код является «потокобезопасным» в том смысле, что ему все равно, что делают другие потоки — мы никогда не получим NullReferenceException. Однако, если другие потоки подписываются или отменяют подписку на событие, мы можем не увидеть самые последние изменения в списке подписчиков события. Это происходит из-за сложностей в реализации общей модели памяти.

В C# 4 события реализованы с помощью метода Interlocked.CompareExchange, поэтому мы просто можем использовать правильный метод Interlocked.CompareExchange, чтобы убедиться, что получим самое последнее значение. Теперь мы можем объединить эти 2 подхода и написать:
public void OnFoo()
{
    Interlocked.CompareExchange(ref Foo, null, null)?.Invoke(this, EventArgs.Empty);
}

Теперь без написания дополнительного кода мы можем уведомить самый последний набор подписчиков, без риска свалиться с NullReferenceException. Спасибо David Fowler за напоминание о такой возможности.

Конечно, вызов CompareExchange выглядит некрасиво. Начиная с .NET 4.5 и выше существует метод Volatile.Read, который может решить нашу проблему, но мне не до конца ясно (если читать документацию), делает ли этот метод то, что нужно. (В описании метода говорится, что он запрещает ставить последующие операции чтения/записи до этого метода, в нашем же случае нужно запретить ставить предшествующие операции записи после этого изменяемого чтения).
public void OnFoo()
{
    // .NET 4.5+, может быть потокобезопасно, а может и не быть...
    Volatile.Read(ref Foo)?.Invoke(this, EventArgs.Empty);
}

Такой подход мне не нравится, потому что я не уверен, что все предусмотрел. Продвинутые читатели, возможно, смогут подсказать, почему такой подход не верен и не попал в BCL.

Альтернативный подход


В прошлом я пользовался таким альтернативным решением: создаем пустой фиктивный обработчик события, используя одно преимущество анонимных методов, которое у них есть по сравнению с лямбда-выражениями — возможность не указывать список параметров:
public event EventHandler Foo = delegate {}
 
public void OnFoo()
{
    // Foo will never be null
    Volatile.Read(ref Foo).Invoke(this, EventArgs.Empty);   
}

При таком подходе все еще остаются проблемы с тем, что мы можем вызывать не самый последний список подписчиков, но зато нам не надо волноваться о проверке на null и NullReferenceException.

Исследуем MSIL


От переводчика: этой части нет в статье Джона, это мои личный изыскания в ildasm'е.
Посмотрим, какой MSIL код генерируется в разных случаях.
Плохой код
public event EventHandler Foo; 
public void OnFoo()
{
    if (Foo != null)
    {
        Foo(this, EventArgs.Empty);
    }
}

.method public hidebysig instance void  OnFoo() cil managed
{
  // Code size       35 (0x23)
  .maxstack  3
  .locals init ([0] bool V_0)
  IL_0000:  nop
  IL_0001:  ldarg.0  // кладем this в стек  
  IL_0002:  ldfld      class [mscorlib]System.EventHandler A::Foo  // кладем в стек поле Foo
  IL_0007:  ldnull  // кладем в стек null
  IL_0008:  cgt.un  // сравниваем 2 верхних значения в стеке (Foo и null) - если равны, то кладем в стек 0 (false)
  IL_000a:  stloc.0  // сохраняем результат во временную локальную переменную типа bool
  IL_000b:  ldloc.0  // кладем ее в стек
  IL_000c:  brfalse.s  IL_0022  // если в стеке лежит false, то переходим к IL_0022 (return)
  IL_000e:  nop
  IL_000f:  ldarg.0   // кладем в стек this
  IL_0010:  ldfld      class [mscorlib]System.EventHandler A::Foo  // кладем в стек поле Foo - !!!Вот тут можем положить уже null
  IL_0015:  ldarg.0  // кладем в стек this
  IL_0016:  ldsfld     class [mscorlib]System.EventArgs [mscorlib]System.EventArgs::Empty  // кладем в стек System.EventArgs::Empty
  IL_001b:  callvirt   instance void [mscorlib]System.EventHandler::Invoke(object,
                                                                           class [mscorlib]System.EventArgs)  // вызываем Foo(this, EventArgs.Empty)
  IL_0020:  nop
  IL_0021:  nop
  IL_0022:  ret
} // end of method A::OnFoo


В этом коде мы дважды обращаемся к полю Foo: для сравнения с null (IL_0002: ldfld) и собственно вызова (IL_0010: ldfld). Между тем, как мы проверили Foo на равенство null, и тем, как заново получили к нему доступ, положили в стек и вызвали метод, от события могли отписаться последние подписчики, и второй раз загружен будет null — здравствуй, NullReferenceException.

Посмотрим, как решится проблема с помощью использования дополнительной локальной переменной.
Используя переменную
public event EventHandler Foo; 
public void OnFoo()
{
    EventHandler handler = Foo;
    if (handler != null)
    {
        handler(this, EventArgs.Empty);
    }   
}

.method public hidebysig instance void  OnFoo() cil managed
{
  // Code size       32 (0x20)
  .maxstack  3
  .locals init ([0] class [mscorlib]System.EventHandler 'handler',
           [1] bool V_1)
  IL_0000:  nop
  IL_0001:  ldarg.0  // кладем this в стек                        
  IL_0002:  ldfld      class [mscorlib]System.EventHandler A::Foo  //ищем поле Foo, теперь оно наверху стека
  IL_0007:  stloc.0  // сохраняем Foo в переменную handler
  IL_0008:  ldloc.0  // кладем в стек handler
  IL_0009:  ldnull  // кладем в стек null
  IL_000a:  cgt.un  // сравниваем 2 верхних значения в стеке (handler и null) - если равны, то кладем в стек 0 (false)
  IL_000c:  stloc.1  // сохраняем результат во временную локальную переменную типа bool
  IL_000d:  ldloc.1 // кладем ее в стек
  IL_000e:  brfalse.s  IL_001f // если в стеке лежит false, то переходим к IL_001f (return)
  IL_0010:  nop
  IL_0011:  ldloc.0  // кладем в стек handler
  IL_0012:  ldarg.0  // кладем в стек this
  IL_0013:  ldsfld     class [mscorlib]System.EventArgs [mscorlib]System.EventArgs::Empty  // кладем в стек System.EventArgs::Empty
  IL_0018:  callvirt   instance void [mscorlib]System.EventHandler::Invoke(object,
                                                                           class [mscorlib]System.EventArgs) // вызываем handler(this, EventArgs.Empty)
  IL_001d:  nop
  IL_001e:  nop
  IL_001f:  ret
} // end of method A::OnFoo


В этом случае все просто: доступ к Foo происходит один раз (IL_0002: ldfld), потом вся работа идет с переменной handler, поэтому опасности получить NullReferenceException нет.

Теперь решение с использованием оператора ?..
C# 6
public event EventHandler Foo;
public void OnFoo()
{
    Foo?.Invoke(this, EventArgs.Empty);
}

.method public hidebysig instance void  OnFoo() cil managed
{
  // Code size       26 (0x1a)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  ldarg.0  // кладем в стек this
  IL_0002:  ldfld      class [mscorlib]System.EventHandler A::Foo  // кладем в стек поле Foo
  IL_0007:  dup  // дублируем в стеке Foo
  IL_0008:  brtrue.s   IL_000d  // если в стеке лежит true или не null и не 0, то переходим к IL_000d (вызов метода)
  IL_000a:  pop  // очищаем стек - вытаскиваем из него Foo (мы попали сюда, если Foo == null)
  IL_000b:  br.s       IL_0019  // выходим из метода
  IL_000d:  ldarg.0  // кладем в стек this (мы пришли сюда, если Foo != null)
  IL_000e:  ldsfld     class [mscorlib]System.EventArgs [mscorlib]System.EventArgs::Empty  // кладем в стек EventArgs::Empty
  IL_0013:  callvirt   instance void [mscorlib]System.EventHandler::Invoke(object,
                                                                           class [mscorlib]System.EventArgs)  // вызываем Invoke
  IL_0018:  nop
  IL_0019:  ret
} // end of method A::OnFoo


В C# 6 с использованием оператора ?. все становится интереснее. Мы кладем в стек поле Foo, дублируем его (IL_0007: dup — вся магия тут), потом если оно не null — то идем к IL_000d и вызываем метод Invoke. Если же Foo == null, то очищаем стек и выходим (IL_000b: br.s IL_0019). Мы действительно всего один раз считываем Foo, поэтому NullReferenceException не произойдет.

Используем оператор ?. и Interlocked.CompareExchange.
Interlocked.CompareExchange
public event EventHandler Foo;
public void OnFoo()
{
    Interlocked.CompareExchange(ref Foo, null, null)?.Invoke(this, EventArgs.Empty);
}

.method public hidebysig instance void  OnFoo() cil managed
{
  // Code size       33 (0x21)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  ldarg.0  // кладем в стек this
  IL_0002:  ldflda     class [mscorlib]System.EventHandler A::Foo  // кладем в стек адрес поля Foo
  IL_0007:  ldnull  // кладем в стек null
  IL_0008:  ldnull  // кладем в стек null
  IL_0009:  call       !!0 [mscorlib]System.Threading.Interlocked::CompareExchange<class [mscorlib]System.EventHandler>(!!0&,
                                                                                                                        !!0,
                                                                                                                        !!0)  // вызываем Interlocked::CompareExchange
  IL_000e:  dup  // дублируем в стеке Foo - последнюю версию, полученную через Interlocked::CompareExchange
  IL_000f:  brtrue.s   IL_0014  // если в стеке лежит true или не null и не 0, то переходим к IL_0014 (вызов метода)
  IL_0011:  pop  // очищаем стек - вытаскиваем из него Foo (мы попали сюда, если Foo == null)
  IL_0012:  br.s       IL_0020  // выходим из метода
  IL_0014:  ldarg.0  // кладем в стек this
  IL_0015:  ldsfld     class [mscorlib]System.EventArgs [mscorlib]System.EventArgs::Empty  // кладем в стек EventArgs::Empty
  IL_001a:  callvirt   instance void [mscorlib]System.EventHandler::Invoke(object,
                                                                           class [mscorlib]System.EventArgs)  // вызываем Invoke
  IL_001f:  nop
  IL_0020:  ret
} // end of method A::OnFoo


Этот код отличается от предыдущего только вызовом Interlocked.CompareExchange (IL_0009: call !!0 [mscorlib]System.Threading.Interlocked::CompareExchange), потом код точно такой же, как и в предыдущем методе (начиная с IL_000e).

Используем оператор ?. и Volatile.Read.
Volatile.Read
public event EventHandler Foo;
public void OnFoo()
{
    Volatile.Read(ref Foo)?.Invoke(this, EventArgs.Empty);
}

.method public hidebysig instance void  OnFoo() cil managed
{
  // Code size       31 (0x1f)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  ldarg.0  // кладем в стек this
  IL_0002:  ldflda     class [mscorlib]System.EventHandler A::Foo  // кладем в стек адрес поля Foo
  IL_0007:  call       !!0 [mscorlib]System.Threading.Volatile::Read<class [mscorlib]System.EventHandler>(!!0&)  // вызываем Volatile::Read
  IL_000c:  dup  // дублируем в стеке Foo - последнюю версию, полученную через Volatile::Read
  IL_000d:  brtrue.s   IL_0012  // если в стеке лежит true или не null и не 0, то переходим к IL_0012 (вызов метода)
  IL_000f:  pop  // очищаем стек - вытаскиваем из него Foo (мы попали сюда, если Foo == null)
  IL_0010:  br.s       IL_001e  // выходим из метода
  IL_0012:  ldarg.0  // кладем в стек this
  IL_0013:  ldsfld     class [mscorlib]System.EventArgs [mscorlib]System.EventArgs::Empty  // кладем в стек EventArgs::Empty
  IL_0018:  callvirt   instance void [mscorlib]System.EventHandler::Invoke(object,
                                                                           class [mscorlib]System.EventArgs)  // вызываем Invoke
  IL_001d:  nop
  IL_001e:  ret
} // end of method A::OnFoo


В этом случае вызов Interlocked.CompareExchange меняется на вызов Volatile.Read, а потом (начиная с IL_000c: dup) все без изменений.

Все решения с использованием оператора ?. отличаются тем, что доступ к полю происходит один раз, для вызова обработчика используется его копия (MSIL команда dup), поэтому мы вызываем Invoke для точной копии объекта, который и сравнивали с null — NullReferenceException произойти не может. В остальном методы отличаются только тем, насколько быстро они подхватывают изменения в многопоточной среде.

Заключение


Да, C# 6 рулит — и не в первый раз. И нам уже доступна стабильная версия!

Комментарии (26)


  1. IL_Agent
    07.12.2015 17:45
    +2

    А разве нельзя так, без Volatile, Read, Invoke

    public event EventHandler Foo = delegate {}
    
    public void OnFoo()
    {    
        Foo(this, EventArgs.Empty);
    }
    

    ?


    1. JustStas
      07.12.2015 18:01
      +1

      Да, можно — это решит NullReferenceException, но:
      1. Это не очень красиво.
      2. Используем не самый последний список подписчиков.


      1. IL_Agent
        07.12.2015 18:10
        +3

        1. Это не очень красиво.

        Почему? По-моему красивее, чем описанные в статье варианты, в т.ч. c новым оператором.
        2. Используем не самый последний список подписчиков.

        А разве вариант с?.. решает эту проблему?


        1. JustStas
          07.12.2015 18:15

          Насчет красоты — конечно, субъективно, но по задумке команды разработчиков C# новый оператор как раз для того случая. Да и для всех случаев, когда можно убрать if (smth != null).

          Нет,?.. — только NRE, а вот Volatile.Read / Interlocked.CompareExchange старается решить.


    1. dymanoid
      07.12.2015 21:57
      +1

      Знающие товарищи замеряли. Это потеря производительности в несколько наносекунд на пустой делегат. Если у вас событий дёргаются тысячи, то вы теряете микросекунды. Для сравнения, присвоение и проверка на null сильно быстрее.


      1. JustStas
        07.12.2015 22:08
        +1

        Спасибо за комментарий! Да, как показывает MSIL, присвоение и проверка на null — это очень быстро.


      1. IL_Agent
        07.12.2015 22:52
        +1

        Полагаю, для подавляющего большинства проектов на C# такая потеря производительности не стоит внимания. Но вообще да, надо понимать, что пустой делегат не бесплатен.


  1. atd
    07.12.2015 17:52
    +1

    > Минус данного подхода в том, что метод расширения придется писать для каждого типа обработчика

    а дженерики нам на что даны?

    public static void Raise<T>(this EventHandler<T> evt, object sender, T args) where T : EventArgs
    


    1. JustStas
      07.12.2015 18:03

      Да, тоже подумал, когда читал, почему-то Джон не включил это в статью.
      Но и?.. все равно легче, потому что никакие дополнительные методы не нужны.


      1. Viacheslav01
        08.12.2015 14:51

        Не все события используют EventHandler


        1. JustStas
          08.12.2015 14:56

          Да, точно, некоторые старые обработчики до .NET 2 используют свои классы.


          1. IL_Agent
            08.12.2015 15:20

            если нужен лишь факт срабатывания события без каких-либо параметров, можно написать

            public event Action Foo = delegate {}
            

            и рейзить его очень просто, без метода-обёртки
            Foo();
            


  1. guai
    07.12.2015 19:55

    Одноглазый Элвис


    1. JustStas
      07.12.2015 20:04
      +1

      А как же точка под вопросительным знаком? ?.


      1. guai
        07.12.2015 21:00

        В groovy, откуда спёрли эту конструкцию, их две.

        def a = maybeSomething() ?: defaultValue // это Элвис - короткая запись тернарного оператора
        

        И есть null-безопасная навигация:
        maybeSomething?.callSomething() // и это уже не Элвис
        


        1. JustStas
          07.12.2015 21:18

          Да, в последних релизах C# старается брать что-то от динамических и функциональных языков, и я считаю, что это здорово. Например, string interpolation в C# 6 хорош (взятый из Ruby).


          1. guai
            07.12.2015 22:00

            если верить msdn, в шарпе есть оператор ?? — непосредственный аналог элвиса из груви. Это официально криво разрабы шарпа .? называют элвисом? А то путаница какая-то…


            1. JustStas
              07.12.2015 22:13

              В видео вот в этом курсе разработчики употребляют название Elvis-operator mva.microsoft.com/en-US/training-courses/developer-productivity-what-s-new-in-c-6-8733


            1. dymanoid
              07.12.2015 22:15

              Нет ли путаницы между "??" и "?." у вас? В C# 6.0 оба оператора доступны, в 5.0 и ниже только "??".


              1. guai
                07.12.2015 23:18

                http://docs.groovy-lang.org/latest/html/documentation/index.html#_elvis_operator


  1. Viacheslav01
    08.12.2015 14:57
    +2

    Мне не понятно только одно, почему все постоянно так озадачены тем, что Event?.Invoke использует не наисвежайшую версию делегата?
    И да не один из вышеописанных способов не гарантирует получение наисвежайшей версии, между вызовами Volatile.Read и Interlocked.CompareExchange и непосредственно вызовом делегата, оригинал может быть изменен ровно с такой же вероятностью как и без них.
    Если нужна гарантия вызова актульной версии, поможет только блокировка.


    1. JustStas
      08.12.2015 15:01

      Мне кажется, в 99.9% случаев достаточно просто вызвать текущий список и никакой проблемы не будет, чем добавлять локи и получать проблемы в производительности. Но представлять как все работает — полезно для разработчика.


      1. Viacheslav01
        08.12.2015 15:45
        +1

        Знать полезно это точно, но не раз натыкался, чуть ли не на панику, что вызовется делегат который уже успели отписать!
        И каждый раз когда поднимается проблема вызова делегата события, пытаются решить эту проблему странными не эффективными методами.
        Но я не понимаю зачем?


        1. dymanoid
          08.12.2015 19:25

          Я тоже не разделяю этих панических настроений, в пример приводилась отписка в Dispose() и затем ObjectDisposedException при вызове делегата объекта. Ситуация теоретически возможная, но городить огород в общей архитектуре ради гипотетической ситуации неразумно, имхо.


  1. gturk
    08.12.2015 15:53

    А откуда пошло название «Элвис-оператор»?
    Я вот что-то не могу найти сходства с Элвисом


    1. JustStas
      08.12.2015 16:09

      Вопросительный знак — как прическа. Вроде бы, поэтому.
      en.wikipedia.org/wiki/Elvis_operator