Событие позволяет классу объявить, что что-то произошло, не зная, кто слушает. Другие объекты подписываются через +=, и когда класс вызывает событие, выполняется метод каждого подписчика.
Вывод:
Order A-17 saved
receipt emailed for A-17
Order A-18 saved
receipt emailed for A-18
free gift added to A-18
Shop не знает о существовании чеков или подарков. Он вызывает OrderPlaced, и реагирует тот, кто подписался. Добавление новой реакции (бонусных баллов, аналитики) означает добавление подписчика, а не правку PlaceOrder.
Объявление события
Объявление события это член с типом делегата и ключевым словом event:
public event EventHandler<OrderPlacedEventArgs> OrderPlaced;
Тип делегата задаёт сигнатуру обработчика. Код .NET почти всегда использует один из двух встроенных типов, и следование этому соглашению делает ваши события знакомыми любому другому разработчику .NET:
EventHandler:void (object sender, EventArgs e), для событий без данных. Вызывайте его сEventArgs.Empty.EventHandler<TEventArgs>:void (object sender, TEventArgs e), гдеTEventArgsэто ваш класс с данными.
Соглашения вокруг них:
senderэто объект, вызвавший событие, обычноthis.- Класс данных называется
{Something}EventArgs, наследуется отEventArgs(необязательно начиная с .NET 4.5, но принято) и предоставляет свойства только для чтения. - Событие называют по тому, что произошло:
OrderPlaced,Closed,PriceChanged. Имя на...ing(Closing) используют для событий, вызываемых до действия, часто со способом его отменить. - В классах, рассчитанных на наследование, вызов идёт через метод
protected virtual void OnOrderPlaced(OrderPlacedEventArgs e), чтобы производные классы могли встроиться.
Подписка и отписка
+= добавляет обработчик, а -= удаляет его. Обработчиком может быть метод или лямбда. Чтобы потом удалить лямбду, храните её в переменной, потому что повторно написанная та же лямбда создаёт другой делегат, который -= не найдёт:
Вывод:
set to 32
display shows 32 C
alarm: too hot
set to 35
display shows 35 C
set to 20
display shows 20 C
Удаление обработчика, который не добавлялся, не является ошибкой; оно ничего не делает. EventHandler<int> показывает, что в современном .NET аргумент типа не обязан наследоваться от EventArgs, хотя отдельный класс оставляет возможность позже добавить поля, не ломая подписчиков.
Обработчики выполняются синхронно, в порядке подписки, в потоке, который вызвал событие. Если один обработчик выбрасывает исключение, оставшиеся обработчики не выполняются, а исключение передаётся коду, вызвавшему событие.
Безопасный вызов события
Событие без подписчиков хранит null. Прямой вызов выбросил бы NullReferenceException, поэтому вызывайте его через null-условный оператор:
OrderPlaced?.Invoke(this, args);
До C# 6 шаблон был такой: скопировать поле в локальную переменную, проверить её и вызвать копию. Копия важна в многопоточном коде: проверка OrderPlaced != null и последующий вызов OrderPlaced(...) читают поле дважды, и другой поток мог бы удалить последний обработчик между ними. ?. читает его один раз, поэтому он и короче, и корректен.
Почему event, а не открытое поле-делегат
Без ключевого слова event открытое поле-делегат годится для подписки, но даёт каждому подписчику полный контроль:
Вывод:
analytics ping
analytics ping
Один =, набранный вместо +=, молча удалил два предыдущих обработчика, а внешний код смог вызвать «щелчок» без всякого щелчка. Если пометить поле как event, обе строки станут ошибками компиляции:
error CS0070: The event 'Button.Clicked' can only appear on the left hand side of += or -= (except when used from within the type 'Button')
Внутри Button событие по-прежнему ведёт себя как обычное поле-делегат, поэтому класс может его вызывать и проверять на null.
Утечка из-за забытой отписки
При подписке событие сохраняет делегат, а делегат держит ссылку на объект-подписчик. Пока издатель жив и обработчик подключён, подписчика нельзя собрать сборщиком мусора, и он продолжает получать события после того, как вы с ним закончили:
Вывод:
closed widget shows 101.5
open widget shows 101.5
subscribers left: 1
closed widget shows 99.0
Присваивание leaky значения null ничего не изменило для ленты: её список обработчиков по-прежнему ссылается на этот виджет, поэтому «закрытый» виджет продолжает обновляться и остаётся в памяти. Виджет в блоке using отписался в Dispose и перестал получать события. Это одна из самых частых утечек памяти в настольном и серверном коде .NET, особенно со статическими событиями и сервисами уровня приложения, которые живут до завершения процесса.
Правило: тот, кто подписывается на событие объекта, живущего дольше него, должен отписаться, обычно в Dispose (см. оператор using). Когда у издателя и подписчика одинаковое время жизни, например у формы и её собственных кнопок, отписка не нужна.
Собственные методы доступа add и remove
Событие может определять, что делают += и -=, как get и set у свойства. Это редкость, но именно так фреймворки хранят обработчики в общем словаре или перенаправляют их другому объекту:
private EventHandler closed;
public event EventHandler Closed
{
add { Console.WriteLine("subscriber added"); closed += value; }
remove { Console.WriteLine("subscriber removed"); closed -= value; }
}
С собственными методами доступа класс вызывает событие через поле хранения (closed?.Invoke(this, EventArgs.Empty)), поскольку у самого события больше нет хранилища.
Часто задаваемые вопросы
Что такое событие в C#?
Событие это член класса, который позволяет другим объектам запросить уведомление, когда что-то происходит. В основе события лежит многоадресный делегат, но внешний код может только добавлять обработчики (+=) и удалять их (-=). Вызвать событие может только класс, который его объявляет.
Чем событие отличается от делегата в C#?
Делегат это тип, который ссылается на методы. Событие это член, объявленный с типом делегата и ключевым словом event, которое ограничивает доступ: снаружи класса нельзя вызвать событие, прочитать список его обработчиков или присвоить его через =, можно только подписаться и отписаться. Открытое поле-делегат позволяет всё это, поэтому любой подписчик мог бы стереть остальных или вызвать событие.
Как передать данные с событием C#?
Унаследуйте класс от EventArgs с данными в виде свойств только для чтения (public class OrderPlacedEventArgs : EventArgs { public decimal Total { get; } }) и объявите событие как EventHandler<OrderPlacedEventArgs>. Вызывайте его через OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(total));.
Как безопасно вызвать событие в C#?
Используйте MyEvent?.Invoke(this, args);. Событие без подписчиков равно null, поэтому прямой вызов выбрасывает NullReferenceException. Оператор ?. читает поле один раз, что заодно исключает гонку, при которой другой поток удаляет последний обработчик между проверкой на null и вызовом.
Могут ли события в C# вызывать утечки памяти?
Да. Подписка сохраняет делегат, который ссылается на подписчика, поэтому издатель держит подписчика живым, пока живёт сам. Долгоживущий издатель (статическое событие, сервис уровня приложения) с короткоживущими подписчиками, которые никогда не отписываются, держит в памяти их всех. Отписывайтесь через -=, когда подписчик больше не нужен, обычно в Dispose.