1. Введение
Работать с делегатами и событиями в C# приятно и удобно — язык делает многое за вас. Однако за этой ширмой скрывается немало подводных камней: невидимые утечки памяти, странные баги от двойной подписки, рассинхронизация обработчиков и даже внезапные исключения во время рассылки уведомлений. Делегаты и события обладают мощными возможностями, но требуют внимательной работы с жизненным циклом объектов, понимания потоков и знания, как именно работают вызовы и отписки. Если ваши события работают "почти всегда", но иногда не срабатывают или вызывают странные ошибки, — вы точно не одиноки! Давайте разберёмся, где чаще всего промахиваются даже опытные программисты и как этого избежать.
Таблица основных ошибок
| Ошибка | Последствие | Как избежать |
|---|---|---|
| Двойная подписка | Обработчик вызывается несколько раз | Следить за подпиской, снимать перед добавлением |
| Неотписка (утечка памяти) | Зависание подписчиков в памяти, “зомби” | Всегда отписывать, использовать IDisposable |
| Исключение в обработчике | Оставшиеся обработчики не вызовутся | try/catch в обработчиках или обход вручную |
| Модификация подписчиков во время события | Пропуск, дублирование вызовов | Обход копии списка обработчиков (GetInvocationList()) |
| Вызов события при MyEvent == null | NullReferenceException | Проверять на null, использовать ?.Invoke |
| Сигнатура обработчика не совпадает | Ошибка компиляции | Проверять сигнатуру |
| Вызов события извне | Ошибка компиляции | Вызов только через OnEventName |
| Статические события не там | Смешение подписок | Не делать static без большой нужды |
| Проблемы closure c лямбдами | Неожиданные значения | Делать копию переменной |
2. Множественная подписка и множественные вызовы
Суть ошибки
Если вы несколько раз подписываете один и тот же обработчик на одно событие, каждый вызов += добавляет ваш метод в очередь делегата. В результате обработчик будет вызван столько раз, сколько раз его добавили.
Как это проявляется?
Представьте, что у вас есть некая кнопка и обработчик нажатия:
Button btn = new Button();
btn.Click += OnButtonClick; // Подпишемся
btn.Click += OnButtonClick; // А вот и повторная подпись!
Теперь при каждом нажатии кнопки метод OnButtonClick вызовется дважды. Если внутри обработчика вы, допустим, обновляете счётчик или добавляете запись в лог, увидите удвоенные результаты.
Как это исправить?
Обычно повторная подписка происходит из-за нарушения структуры кода — например, если += вынесено в метод, который вызывается несколько раз (в разных жизненных циклах формы).
- Следите за тем, где происходит подписка.
- Не выносите += в этапы жизненного цикла, которые могут вызываться многократно.
- Иногда полезно использовать «уникальную» подписку — перед добавлением обработчика сначала снять его:
myEvent -= MyHandler; // На всякий случай снимаем
myEvent += MyHandler; // Потом снова подписываем
Это безопасно: если обработчика ещё не было, -= ничего не изменит.
3. Зомби-подписчик
Суть ошибки
Если подписчик подписался на событие долгоживущего издателя и не отписался, сборщик мусора не сможет его собрать: издатель всё ещё держит ссылку на делегат обработчика, а значит — на весь объект подписчика. В итоге — утечки памяти.
Типовой пример
public class TemporaryPopup : IDisposable
{
private Window _hostWindow;
public TemporaryPopup(Window window)
{
_hostWindow = window;
_hostWindow.Closed += OnHostClosed;
}
private void OnHostClosed(object sender, EventArgs e)
{
// ...
}
public void Dispose()
{
_hostWindow.Closed -= OnHostClosed; // Не забудьте отписаться!
}
}
Если забыть про Dispose() или не вызвать его, то даже если вы удалите все ссылки на TemporaryPopup, объект не будет уничтожен — окно всё ещё ссылается на его обработчик.
Как этого избежать?
- Реализуйте IDisposable в подписчиках, если их жизненный цикл короче издателя.
- Используйте паттерн using или явно вызывайте Dispose():
using (var popup = new TemporaryPopup(mainWindow))
{
// ...
} // Здесь Dispose вызовется автоматически
В GUI-приложениях — отписывайтесь при закрытии окна/формы (например, в обработчиках закрытия или в Dispose() формы).
4. Обработка исключений внутри обработчиков
Суть ошибки
Когда событие вызывает десятки обработчиков, а один из них кидает исключение, остальные обработчики уже не выполнятся.
Демонстрация
public event EventHandler MyEvent;
public void Raise()
{
MyEvent?.Invoke(this, EventArgs.Empty);
}
Если в одном из методов, подписанных на MyEvent, произойдёт исключение, остальные вызваны не будут — цепочка оборвётся.
Как работать с такими ошибками?
- В обработчиках событий либо обрабатывайте исключения локально (try/catch), либо осознанно пробрасывайте их.
- В сложных сценариях — обходите список подписчиков вручную и изолируйте ошибки каждого:
var handlers = MyEvent?.GetInvocationList();
foreach (var handler in handlers)
{
try
{
handler.DynamicInvoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
// Логирование, аварийное восстановление
}
}
5. Изменение списка подписчиков во время рассылки событий
Суть ошибки
Если обработчик внутри события отписывает себя или других, это может исказить порядок вызовов: некоторые обработчики будут пропущены или вызваны повторно.
Как этого избежать?
- Не модифицируйте подписки из обработчиков.
- Если нужно — обходите копию списка делегатов:
var handlers = MyEvent?.GetInvocationList();
foreach (EventHandler handler in handlers)
{
handler(this, EventArgs.Empty);
}
6. Путаница с null-значением делегата (нет подписчиков)
Суть ошибки
Если к событию никто не подписан, его делегат равен null. Вызов без проверки приведёт к NullReferenceException.
Пример (плохо)
public event EventHandler MyEvent;
public void Raise()
{
MyEvent(this, EventArgs.Empty); // Если подписчиков нет — исключение!
}
Как правильно?
- Используйте безопасный вызов: MyEvent?.Invoke(this, EventArgs.Empty).
- Либо применяйте классический потокобезопасный приём: скопируйте делегат в локальную переменную и вызывайте её.
7. Смешивание делегатов/событий разных сигнатур
Суть ошибки
Делегаты строго типизированы. Несовпадение сигнатуры метода-обработчика и делегата события — ошибка компиляции.
Пример
public event EventHandler<string> TextChanged;
void WrongHandler(object sender, int number) { /* ... */ }
TextChanged += WrongHandler; // Ошибка компиляции!
Используйте стандартные делегаты EventHandler и EventHandler<T>, и следите за точным совпадением сигнатур.
8. Попытка вызвать событие вне класса-издателя
Суть ошибки
event — это инкапсулированный делегат: внешний код не может его вызывать напрямую (доступно только добавление/удаление обработчиков).
Пример
public class MyPublisher
{
public event EventHandler SomethingHappened;
}
var publisher = new MyPublisher();
publisher.SomethingHappened?.Invoke(publisher, EventArgs.Empty); // Ошибка!
Как правильно?
Вызов через защищённый/открытый метод издателя — обычно OnEventName. Снаружи — только +=/-=.
9. Ошибки с аксессорами add и remove
Суть ошибки
Кастомные аксессоры для событий позволяют контролировать подписку, но легко нарушить корректную очередь вызовов или потокобезопасность.
Пример
public event EventHandler MyEvent
{
add { /* ... */ }
remove { /* ... */ }
}
Если не уверены — используйте стандартную реализацию событий. При ручной реализации держите под рукой документацию и учитывайте синхронизацию.
10. Доступ к событиям из статических и нестатических контекстов
Суть ошибки
Легко случайно объявить событие static там, где оно должно быть у экземпляра. Тогда все объекты разделят общую очередь подписчиков.
Пример
public static event EventHandler GlobalEvent; // Ой!
// Экземпляры теряют индивидуальность, подписки сливаются в общую кучу
Как избежать?
Делайте событие статическим только при необходимости глобального уровня (например, глобальный лог). В остальных случаях — инкапсуляция на уровне экземпляра.
11. Проблемы с захватом переменных в лямбда-выражениях
Суть ошибки
Лямбда-выражения захватывают переменные по ссылке. В циклах это часто приводит к «последнему значению».
Пример
for (int i = 0; i < 5; i++)
{
button.Click += (s, e) => Console.WriteLine(i);
}
// Все обработчики напечатают "5"
Как правильно?
for (int i = 0; i < 5; i++)
{
int copy = i; // Локальная копия
button.Click += (s, e) => Console.WriteLine(copy);
}
12. Смешение слабых и сильных ссылок: Advanced “Weak Events”
В больших приложениях (например, WPF) используется механизм «слабых событий», где издатель хранит слабую ссылку на подписчика, чтобы не мешать сборке мусора. Слабые события помогают против утечек, но подписчик может быть собран и не получить событие.
Подробности: Weak Event Patterns (MSDN)
13. Отсутствие стандарта именования и сигнатур событий
Суть ошибки
Соблюдайте стандартные сигнатуры и имена: события — в прошедшем времени (Changed, Closed, Completed), аргументы — наследники EventArgs.
Пример “неправильно”:
public delegate void SomethingHappens(int what);
// ...
public event SomethingHappens Something;
Пример “правильно”:
public event EventHandler<EventArgs> SomethingHappened;
Для своих событий почти всегда используйте EventHandler или EventHandler<T>. Коллеги (и будущие вы) скажут спасибо.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ