События .NET в деталях

в 20:29, , рубрики: .net, Events, Песочница, Программирование, события, метки: , , ,

Если вы .NET программист, то вы наверняка объявляли и использовали события в своем коде. Несмотря на это, не все знают, как события работают внутри и какие особенности связаны с их применением. В этой статье я попытался описать работу событий как можно более подробно, включая некоторые частные случаи, с которыми редко приходится иметь дело, но про которые важно иили интересно знать.

Что такое событие?

Событием в языке C# называется сущность, предоставляющая две возможности: для класса — сообщать об изменениях, а для его пользователей — реагировать на них.
Пример объявления события:

public event EventHandler Changed;

Рассмотрим, из чего состоит объявление. Сначала идут модификаторы события, затем ключевое слово event, после него — тип события, который обязательно должен быть типом-делегатом, и идентификатор события, то есть его имя. Ключевое слово event сообщает компилятору о том, что это не публичное поле, а специальным образом раскрывающаяся конструкция, скрывающая от программиста детали реализации механизма событий. Для того, чтобы понять, как работает этот механизм, необходимо изучить принципы работы делегатов.

Основа работы событий — делегаты

Можно сказать, что делегат в .NET — некий аналог ссылки на функцию в C++. Вместе с тем, такое определение неточно, т.к. каждый делегат может ссылаться не на один, а на произвольное количество методов, которые хранятся в списке вызовов делегата (invocation list). Тип делегата описывает сигнатуру метода, на который он может ссылаться, экземпляры этого типа имеют свои методы, свойства и операторы. При вызове метода Invoke() выполняется последовательный вызов каждого из методов списка. Делегат можно вызывать как функцию, компилятор транслирует такой вызов в вызов Invoke().
В C# для делегатов имеются операторы + и -, которые не существуют в среде .NET и являются синтаксическим сахаром языка, раскрываясь в вызов методов Delegate.Combine и Delegate.Remove соответственно. Эти методы позволяют добавлять и удалять методы в списке вызовов. Разумеется, форма операторов с присваиванием (+= и -=) также применима к операторам делегата, как и к определенным в среде .NET операторам + и — для других типов. Если при вычитании из делегата его список вызовов оказывается пуст, то ему присваивается null.
Рассмотрим простой пример:

Action a = () => Console.Write("A"); //Action описывает сигнатуру void Method(void)
Action b = a;
Action c = a + b;
Action d = a - b;
a(); //выведет A
b(); //выведет A
c(); //выведет AA
d(); //произойдет исключение NullReferenceException, т.к. d == null

События — реализация по умолчанию

События в языке C# могут быть определены двумя способами:

  1. Неявная реализация события (field-like event).
  2. Явная реализация события.

Уточню, что слова “явная” и “неявная” в данном случае не являются терминами, определенными в спецификации, а просто описывают способ реализации по смыслу.

Рассмотрим наиболее часто используемую реализацию событий — неявную. Пусть имеется следующий исходный код на языке C# 4 (это важно, для более ранних версий генерируется несколько иной код, о чем будет рассказано далее):

class Class {
    public event EventHandler Changed;
}

Эти строчки будут транслированы компилятором в код, аналогичный следующему:

class Class {
    EventHandler сhanged;
    public event EventHandler Changed {
        add {
            EventHandler eventHandler = this.Changed;
            EventHandler comparand;
            do {
                comparand = eventHandler;
                eventHandler = Interlocked.CompareExchange<EventHandler>(ref this.Changed,
                    comparand + value, comparand);
            } while(eventHandler != comparand);
        }
        remove {
            EventHandler eventHandler = this.Changed;
            EventHandler comparand;
            do {
                comparand = eventHandler;
                eventHandler = Interlocked.CompareExchange<EventHandler>(ref this.Changed,
                    comparand - value, comparand);
            } while (eventHandler != comparand);
        }
    }
}

Блок add вызывается при подписке на событие, блок remove — при отписке. Эти блоки компилируются в отдельные методы с уникальными именами. Оба этих метода принимают один параметр — делегат типа, соответствующего типу события и не имеют возвращаемого значения. Имя параметра всегда ”value”, попытка объявить локальную переменную с таким именем приведет к ошибке компиляции. Область видимости, указанная слева от ключевого слова event определяет область видимости этих методов. Также создается делегат с именем события, который всегда приватный. Именно поэтому мы не можем вызвать событие, реализованное неявным способом, из наследника класса.

Interlocked.CompareExchange выполняет сравнение первого аргумента с третьим и если они равны, заменяет первый аргумент на второй. Это действие потокобезопасно. Цикл используется для случая, когда после присвоения переменной comparand делегата события и до выполнения сравнения другой поток изменяет этот делегат. В таком случае Interlocked.CompareExchange не производит замены, граничное условие цикла не выполняется и происходит следующая попытка.

Объявление с указанием add и remove

При явной реализации события программист объявляет делегат-поле для события и вручную добавляет или удаляет подписчиков через блоки add/remove, оба из которых должны присутствовать. Такое объявление часто используется для создания своего механизма событий с сохранением удобств языка C# в работе с ними.
Например, одна из типичных реализаций заключается в отдельном хранении словаря делегатов событий, в котором присутствуют только те делегаты, на события которых была осуществлена подписка. Доступ к словарю осуществляется по ключам, которыми обычно являются статические поля типа object, используемые только для сравнения их ссылок. Это делается для того, чтобы уменьшить количество памяти, занимаемое экземпляром класса (в случае, если он содержит большое количество нестатических событий). Эта реализация применяется в WinForms.

Как происходит подписка на событие и его вызов?

Все действия по подписке и отписке (обозначаются как += и -=, можно легко спутать с операторами делегатов) компилируются в вызовы методов add и remove. Вызовы внутри класса, отличные от вышеуказанных, компилируются в простую работу с делегатом. Следует заметить, что при неявной (и при правильной явной) реализации события невозможно получить доступ к делегату извне класса, работать можно лишь с событием как с абстракцией — подписываясь и отписываясь. Так как нет способа определить, подписались ли мы на какое-либо событие (если не использовать рефлексию), то кажется логичным, что отписка от него никогда не вызовет ошибок — можно смело отписываться, даже если делегат события пуст.

Модификаторы событий

Для событий могут использоваться модификаторы области видимости (public, protected, private, internal), они могут быть перекрыты (virtual, override, sealed) или не реализованы (abstract, extern). Событие может перекрывать событие с таким же именем из базового класса (new) или быть членом класса (static). Если событие объявлено и с модификатором override и с модификатором abstract одновременно, то наследники класса должны будут переопределить его (равно как и методы или свойства с этими двумя модификаторами).

Какие типы событий бывают?

Как уже было отмечено, тип события всегда должен быть типом делегата. Стандартными типами для событий являются типы EventHandler и EventHandler<TEventArgs> где TEventArgs — наследник EventArgs. Тип EventHandler используется когда аргументов события не предусмотрено, а тип EventHandler<TEventArgs> — когда аргументы события есть, тогда для них создается отдельный класс — наследник от EventArgs. Также можно использовать любые другие типы делегатов, но применение типизированного EventHandler<TEventArgs> выглядит более логичным и красивым.

Как все обстоит в C# 3?

Реализация field-like события, которая описана выше, соответствует языку C# 4 (.NET 4.0). Для более ранних версий существуют весьма существенные отличия.
Неявная реализация использует lock(this) для обеспечения потокобезопасности вместо Interlocked.CompareExchange с циклом. Для статических событий используется lock(typeof(Class)). Вот код, аналогичный раскрытому компилятором неявному определению события в C# 3:

class Class {
    EventHandler changed;
    public event EventHandler Changed {
        add {
            lock(this) { changed = changed + value; }
        }
        remove {
            lock(this) { changed = changed - value; }
        }
    }
}

Помимо этого, работа с событием внутри класса ведется как с делегатом, т.е. += и -= вызывают Delegate.Combine и Delegate.Remove напрямую, в обход методов add/remove. Это изменение может привести к невозможности сборки проекта на языке C# 4! В C# 3 результатом += и -= был делегат, т.к. результатом присвоения переменной всегда является присвоенное значение. В C# 4 результатом является void, т.к. методы add/remove не возвращают значения.

Помимо изменений в работе на разных версиях языка есть еще несколько особенностей.

Особенность №1 — продление времени жизни подписчика

При подписке на событие мы добавляем в список вызовов делегата события ссылку на метод, который будет вызван при вызове события. Таким образом, память, занимаемая объектом, подписавшимся на событие, не будет освобождена до его отписки от события или до уничтожения объекта, заключающего в себе событие. Эта особенность является одной из часто встречаемых причин утечек памяти в приложениях.
Для исправления этого недостатка часто используются weak events, слабые события. Эта тема уже была освещена на Хабре.

Особенность №2 — явная реализация интерфейса

Событие, являющееся частью интерфейса, не может быть реализовано как поле при явной реализации этого интерфейса. В таких случаях следует либо скопировать стандартную реализацию события для реализации как свойство, либо реализовывать эту часть интерфейса неявно. Также, если вам не нужна потокобезопасность этого события, можно использовать самое простое и эффективное определение:

EventHandler changed;
event EventHandler ISomeInterface.Changed {
    add { changed += value; }
    remove { changed -= value; }
}

Особенность №3 — безопасный вызов

События перед вызовом следует проверять на null, что следует из описанной выше работы делегатов. От этого разрастается код, для избежания чего существует как минимум два способа. Первый способ описан Джоном Скитом (Jon Skeet) в его книге C# in depth:

public event EventHandler Changed = delegate { };

Коротко и лаконично. Мы инициализируем делегат события пустым методом, поэтому он никогда не будет null. Вычесть из делегата этот метод невозможно, т.к. он определен при инициализации делегата и у него нет ни имени, ни ссылки на него из любого места программы.

Второй способ заключается в написании метода, содержащего в себе необходимую проверку на null. Этот прием особенно хорошо работает в .NET 3.5 и выше, где доступны методы расширений (extension methods). Так как при вызове метода расширений объект, на котором он вызывается, является всего лишь параметром этого метода, то этот объект может быть пустой ссылкой, что и используется в данном случае.

public static class EventHandlerExtensions {
    public static void SafeRaise(this EventHandler handler, object sender, EventArgs e) {
        if(handler != null)
            handler(sender, e);
    }
    public static void SafeRaise<TEventArgs>(this EventHandler<TEventArgs> handler,
        object sender, TEventArgs e) where TEventArgs : EventArgs {
        if(handler != null)
            handler(sender, e);
    }
}

Таким образом, мы можем вызывать события как Changed.SafeRaise(this, EventArgs.Empty), что экономит нам строчки кода. Также можно определить третий вариант метода расширений для случая, когда у нас EventArgs.Empty, чтобы не передавать их явно. Тогда код сократится до Changed.SafeRaise(this), но я не буду рекомендовать такой подход, т.к. для других членов вашей команды это может быть не так явно, как передача пустого аргумента.

Тонкость №4 — что не так со стандартной реализацией?

Если у вас стоит ReSharper, то вы могли наблюдать следующее его сообщение. Команда решарпера правильно считает, что не все ваши пользователи достаточно осведомлены в работе событийделегатов в плане отпискивычитания, но тем не менее ваши события должны работать предсказуемо не для ваших пользователей, а с точки зрения событий в .NET, а т.к. там такая особенность есть, то и в вашем коде она должна остаться.

Бонус: попытка Microsoft сделать контрвариантные события

В первой бете C# 4 Microsoft попытались добавить контрвариантности событиям. Это позволяло подписываться на событие EventHandler методами, имеющими сигнатуру EventHandler и все работало до тех пор, пока в делегат события не добавлялось несколько методов с разной (но подходящей) сигнатурой. Такой код компилировался, но падал с ошибкой времени выполнения. По всей видимости, обойти это так и не смогли и в релизе C# 4 контрвариантность для EventHandler была отключена.
Это не так заметно, если опускать явное создание делегата при подписке, например следующий код отлично скомпилируется:

public class Tests {
    public event EventHandler<MyEventArgs> Changed;
    public void Test() {
        Changed += ChangedMyEventArgs;
        Changed += ChangedEventArgs;
    }
    void ChangedMyEventArgs(object sender, MyEventArgs e) { }
    void ChangedEventArgs(object sender, EventArgs e) { }
}

Это происходит потому, что компилятор сам подставит new EventHandler<MyEventArgs>(...) к обеим подпискам. Если же хотя бы в одном из мест использовать new EventHandler<EventArgs>(...), то компилятор сообщит об ошибке — невозможно сконвертировать тип EventHandler<System.EventArgs> в EventHandler<Events.MyEventArgs> (здесь Events — пространство имен моего тестового проекта).

Источники

Далее приведен список источников, часть материала из которых была использована при составлении статьи. Рекомендую к прочтению книгу Джона Скита (Jon Skeet), в которой в деталях описаны не только делегаты, но и многие другие средства языка.

Автор: coffeecupwinner

* - обязательные к заполнению поля