Слабые события в C# - Часть 1: Слабые события стороны слушателя

ОГЛАВЛЕНИЕ


Часть 1: Слабые события стороны слушателя

В этой части предполагается, что событие – нормальное событие C# (жесткие ссылки на обработчики событий), и любая очистка должна выполняться на слушающей стороне.

Решение 0: Только снятие с регистрации

void RegisterEvent()
{
    eventSource.Event += OnEvent;
}
void DeregisterEvent()
{
    eventSource.Event -= OnEvent;
}
void OnEvent(object sender, EventArgs e)
{
    ...
}
Простое и эффективное, это то, что нужно использовать при наличии возможности. Но часто заведомо невозможно гарантировать, что метод DeregisterEvent будет вызываться всегда, когда объект больше не используется. Можно попробовать использовать шаблон удалить, хотя он обычно предназначен для неуправляемых ресурсов. Сборщик мусора не будет работать: программа чистки памяти не вызовет его, так как источник события все еще хранит ссылку на наш объект!

Плюсы
Простой, если проект уже имеет пометку, что он удаляется.

Минусы
Явное управление памятью трудное, код может забыть вызвать Dispose (удалить).

Решение 1: Снятие с регистрации, когда событие вызвано

void RegisterEvent()
{
    eventSource.Event += OnEvent;
}

void OnEvent(object sender, EventArgs e)
{
    if (!InUse) {
        eventSource.Event -= OnEvent;
        return;
    }
    ...
}
Теперь не требуется, чтобы кто-то сообщал нам, когда слушатель больше не используется: он просто проверяет это сам, когда вызывается событие. Однако если нельзя использовать решение 0, то обычно также невозможно определить "InUse(используемый)" изнутри объекта слушателя. И с учетом того, что вы читаете эту статью, вы, скорее всего, столкнетесь с одним из этих случаев.
Но данное решение уже имеет существенный недостаток по сравнению с решением 0: если событие вообще не возбуждается, то происходит утечка объектов-слушателей. Представьте, что большое количество объектов регистрирует статическое событие "SettingsChanged (параметры измерены)" – все эти объекты нельзя будет удалить до тех пор, пока параметр не изменится – что может вообще не случиться за все время жизни программы.

Плюсы
Отсутствуют.

Минусы
Утечки, если событие вообще не возбуждается; обычно "InUse" трудно определить.

Решение 2: Обертка со слабой ссылкой

Это решение почти совпадает с предыдущим, за исключением того, что код обработки события перемещен в класс-обертку, передающий вызовы экземпляру слушателя, на который ссылается слабая ссылка. Эта слабая ссылка позволяет легко определить, существует ли еще слушатель.


EventWrapper ew;
void RegisterEvent()
{
    ew = new EventWrapper(eventSource, this);
}
void OnEvent(object sender, EventArgs e)
{
    ...
}
sealed class EventWrapper
{
    SourceObject eventSource;
    WeakReference wr;
    public EventWrapper(SourceObject eventSource,
                        ListenerObject obj) {
        this.eventSource = eventSource;
        this.wr = new WeakReference(obj);
        eventSource.Event += OnEvent;
   }
   void OnEvent(object sender, EventArgs e)
   {
        ListenerObject obj = (ListenerObject)wr.Target;
        if (obj != null)
            obj.OnEvent(sender, e);
        else
            Deregister();
    }
    public void Deregister()
    {
        eventSource.Event -= OnEvent;
    }
}

Плюсы
Позволяет очистку памяти от объекта слушателя.

Минусы
Утечка экземпляра обертки, если событие вообще не возбуждается; написание класса-обертки для каждого обработчика события создает много повторяющегося кода.

Решение 3: Снятие с регистрации в сборщике мусора

Имейте в виду, что мы сохранили ссылку на EventWrapper и имеем открытый метод Deregister. Мы можем добавить сборщик мусора в слушатель и использовать его, чтобы снять с регистрации из события.
~ListenerObject() {
    ew.Deregister();
}
Это должно предотвратить утечку памяти, но имеет свою цену: финализируемые объекты накладны для программы чистки памяти. Если нет ссылок на объект слушателя (не считая слабой ссылки), он переживет первую сборку мусора (и перейдет в более высокое поколение), запустит финализатор, и затем будет удален только после следующей сборки мусора (нового поколения).

Финализаторы выполняются в потоке финализатора; это может вызвать проблемы, если регистрация/снятие с регистрации событий на источнике события не поточно-ориентирована. Помните: стандартные события, генерируемые компилятором C#, не поточно-ориентированы!

Плюсы
Позволяет очистку памяти от объекта слушателя; нет утечки экземпляров обертки.

Минусы
Финализатор задерживает удаление слушателя; требует поточно-ориентированного источника события; много повторяющегося кода.

Решение 4: Многократно используемая обертка

Загруженный код содержит многократно используемую версию класса-обертки. Он работает путем вычисления лямбда-выражений для частей кода, которые нужно приспособить для конкретного использования: регистрация обработчика события, снятие с регистрации обработчика событий, передача события закрытому методу.
eventWrapper = WeakEventHandler.Register(
    eventSource,
    (s, eh) => s.Event += eh, // регистрирующий код
    (s, eh) => s.Event -= eh, // код, снимающий с регистрации
    this, // слушатель события
    (me, sender, args) => me.OnEvent(sender, args) // перенаправляющий код
);

Возвращенный eventWrapper предоставляет единственный открытый метод: Deregister (снять с регистрации). Нужно быть осторожным с лямбда-выражениями, так как они собираются в делегаты, которые могут содержать дополнительные ссылки на объект. Вот почему слушатель события передается обратно как "me". Если бы мы написали (me, sender, args) => this.OnEvent(sender, args), то лямбда-выражение захватило бы переменную "this", вызвав генерацию замкнутого объекта. Так как WeakEventHandler хранит ссылку на перенаправляемый делегат, это привело бы к созданию жесткой ссылки из обертки на слушатель. К счастью, можно проверить, захватывает ли делегат какие-либо переменные: компилятор создаст метод экземпляра для лямбда-выражений, захватывающих переменные, и статический метод для лямбда-выражений, не захватывающих переменные. WeakEventHandler проверяет это при помощи Delegate.Method.IsStatic, и генерирует исключение в случае его неправильного использования.

Этот подход допускает многократное использование, но все же требует класс-обертку для каждого типа делегата. Хотя можно значительно продвинуться путем использования System.EventHandler и System.EventHandler<T>, вы можете захотеть автоматизировать работу с множеством разных типов делегатов. Это можно сделать во время компиляции при помощи генерации кода, или во время выполнения с помощью System.Reflection.Emit.

Плюсы
Позволяет очистку памяти от объекта слушателя; не слишком много лишнего кода.

Минусы
Происходит утечка экземпляра обертки, если событие вообще не возбуждается.

Решение 5: Менеджер слабых событий

WPF имеет встроенную поддержку для слабых событий на стороне слушателя с использованием класса WeakEventManager. Он работает подобно предыдущим решениям с оберткой, за исключением того, что единственный экземпляр WeakEventManager служит в качестве обертки между несколькими передатчиками и несколькими слушателями. Поэтому единственный экземпляр WeakEventManager может предотвратить утечку, если событие вообще не вызывается: регистрация другого события в WeakEventManager может запустить удаление старых событий. Эти удаления программируются при помощи диспетчера WPF, они происходят только в потоках, выполняющих цикл обработки сообщений WPF.

Также WeakEventManager имеет ограничение, отсутствующее у предыдущих решений: он требует, чтобы параметры передатчика были заданы правильно. Если он используется для прикрепления к button.Click, будут переданы только события с sender==button. Некоторые реализации событий могут просто прикреплять обработчики к другому событию:
public event EventHandler Event {
    add { anotherObject.Event += value; }
    remove { anotherObject.Event -= value; }
}
Такие события нельзя использовать с WeakEventManager.

Есть один класс WeakEventManager в расчете на событие, каждый с экземпляром в расчете на поток. Рекомендуемый шаблон для определения этих событий содержит много шаблонного кода: смотрите "Шаблоны слабых событий" в MSDN [^].
К счастью, это можно упростить при помощи обобщенных шаблонов:
public sealed class ButtonClickEventManager
    : WeakEventManagerBase<ButtonClickEventManager, Button>
{
    protected override void StartListening(Button source)
    {
        source.Click += DeliverEvent;
    }

    protected override void StopListening(Button source)
    {
        source.Click -= DeliverEvent;
    }
}
DeliverEvent принимает (object, EventArgs), тогда как событие Click (щелчок мышью) предоставляет (object, RoutedEventArgs). Несмотря на то что отсутствует преобразование между типами делегатов, C# поддерживает создании делегатов из групп методов [^].

Плюсы
Позволяет очистку памяти от объекта слушателя; не происходит утечка экземпляров обертки.

Минусы
Связан с диспетчером WPF, нельзя свободно использовать в потоках без пользовательского интерфейса.