JavaRush /Курсы /C# SELF /Типовые ошибки с делегатами и событиями

Типовые ошибки с делегатами и событиями

C# SELF
54 уровень , 0 лекция
Открыта

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>. Коллеги (и будущие вы) скажут спасибо.

2
Задача
C# SELF, 54 уровень, 0 лекция
Недоступна
Обработка исключений в обработчиках событий
Обработка исключений в обработчиках событий
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ