От переводчика
Часто начинающие разработчики спрашивают, зачем при вызове обработчика нужно копировать его в локальную переменную, а как показывает код ревью, даже опытные разработчики забывают об этом. В 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 нет.
Теперь решение с использованием оператора ?..
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.
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.
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)
atd
07.12.2015 17:52+1> Минус данного подхода в том, что метод расширения придется писать для каждого типа обработчика
а дженерики нам на что даны?
public static void Raise<T>(this EventHandler<T> evt, object sender, T args) where T : EventArgs
JustStas
07.12.2015 18:03Да, тоже подумал, когда читал, почему-то Джон не включил это в статью.
Но и?.. все равно легче, потому что никакие дополнительные методы не нужны.
guai
07.12.2015 19:55Одноглазый Элвис
JustStas
07.12.2015 20:04+1А как же точка под вопросительным знаком? ?.
guai
07.12.2015 21:00В groovy, откуда спёрли эту конструкцию, их две.
def a = maybeSomething() ?: defaultValue // это Элвис - короткая запись тернарного оператора
И есть null-безопасная навигация:
maybeSomething?.callSomething() // и это уже не Элвис
JustStas
07.12.2015 21:18Да, в последних релизах C# старается брать что-то от динамических и функциональных языков, и я считаю, что это здорово. Например, string interpolation в C# 6 хорош (взятый из Ruby).
guai
07.12.2015 22:00если верить msdn, в шарпе есть оператор ?? — непосредственный аналог элвиса из груви. Это официально криво разрабы шарпа .? называют элвисом? А то путаница какая-то…
JustStas
07.12.2015 22:13В видео вот в этом курсе разработчики употребляют название Elvis-operator mva.microsoft.com/en-US/training-courses/developer-productivity-what-s-new-in-c-6-8733
Viacheslav01
08.12.2015 14:57+2Мне не понятно только одно, почему все постоянно так озадачены тем, что Event?.Invoke использует не наисвежайшую версию делегата?
И да не один из вышеописанных способов не гарантирует получение наисвежайшей версии, между вызовами Volatile.Read и Interlocked.CompareExchange и непосредственно вызовом делегата, оригинал может быть изменен ровно с такой же вероятностью как и без них.
Если нужна гарантия вызова актульной версии, поможет только блокировка.JustStas
08.12.2015 15:01Мне кажется, в 99.9% случаев достаточно просто вызвать текущий список и никакой проблемы не будет, чем добавлять локи и получать проблемы в производительности. Но представлять как все работает — полезно для разработчика.
Viacheslav01
08.12.2015 15:45+1Знать полезно это точно, но не раз натыкался, чуть ли не на панику, что вызовется делегат который уже успели отписать!
И каждый раз когда поднимается проблема вызова делегата события, пытаются решить эту проблему странными не эффективными методами.
Но я не понимаю зачем?dymanoid
08.12.2015 19:25Я тоже не разделяю этих панических настроений, в пример приводилась отписка в Dispose() и затем ObjectDisposedException при вызове делегата объекта. Ситуация теоретически возможная, но городить огород в общей архитектуре ради гипотетической ситуации неразумно, имхо.
gturk
08.12.2015 15:53А откуда пошло название «Элвис-оператор»?
Я вот что-то не могу найти сходства с ЭлвисомJustStas
08.12.2015 16:09Вопросительный знак — как прическа. Вроде бы, поэтому.
en.wikipedia.org/wiki/Elvis_operator
IL_Agent
А разве нельзя так, без Volatile, Read, Invoke
?
JustStas
Да, можно — это решит NullReferenceException, но:
1. Это не очень красиво.
2. Используем не самый последний список подписчиков.
IL_Agent
Почему? По-моему красивее, чем описанные в статье варианты, в т.ч. c новым оператором.
А разве вариант с?.. решает эту проблему?
JustStas
Насчет красоты — конечно, субъективно, но по задумке команды разработчиков C# новый оператор как раз для того случая. Да и для всех случаев, когда можно убрать if (smth != null).
Нет,?.. — только NRE, а вот Volatile.Read / Interlocked.CompareExchange старается решить.
dymanoid
Знающие товарищи замеряли. Это потеря производительности в несколько наносекунд на пустой делегат. Если у вас событий дёргаются тысячи, то вы теряете микросекунды. Для сравнения, присвоение и проверка на null сильно быстрее.
JustStas
Спасибо за комментарий! Да, как показывает MSIL, присвоение и проверка на null — это очень быстро.
IL_Agent
Полагаю, для подавляющего большинства проектов на C# такая потеря производительности не стоит внимания. Но вообще да, надо понимать, что пустой делегат не бесплатен.