Пользователь
0,0
рейтинг
27 июля 2012 в 00:29

Разработка → События .NET в деталях из песочницы

Если вы .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 объявлен как public delegate void Action();
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<MyEventArgs> методами, имеющими сигнатуру EventHandler<EventArgs> и все работало до тех пор, пока в делегат события не добавлялось несколько методов с разной (но подходящей) сигнатурой. Такой код компилировался, но падал с ошибкой времени выполнения. По всей видимости, обойти это так и не смогли и в релизе 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
карма
24,5
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (16)

  • +1
    Отличная статья!

    Могу добавить к ней свой код очистки всех подписок на событие через Reflection:

            private static FieldInfo GetEventField(this Type type, string eventName)
            {
                FieldInfo field = null;
                while (type != null)
                {
                    /* Find events defined as field */
                    field = type.GetField(eventName, BindingFlags.Static | BindingFlags.Instance | BindingFlags.NonPublic);
                    if (field != null && (field.FieldType == typeof(MulticastDelegate) || field.FieldType.IsSubclassOf(typeof(MulticastDelegate))))
                        break;
                    
                    /* Find events defined as property { add; remove; } */
                    field = type.GetField("EVENT_" + eventName.ToUpper(), BindingFlags.Static | BindingFlags.Instance | BindingFlags.NonPublic);
                    if (field != null)
                        break;
                    type = type.BaseType;
                }
                return field;
            }
    
            public static void ClearEventInvocations(this object obj, string eventName)
            {
                var fi = obj.GetType().GetEventField(eventName);
                if (fi == null) return;
                fi.SetValue(obj, null);
            }
    
    
    


    • +7
      Постарайтесь никогда им не пользоваться.
      • +1
        Честно скажу, было написано в академических целях.

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

        Спустя время, когда выдалась возможность, я наткнулся на недоделанное расширение и с помощью рефлектора и бутылки пива таки его сделал.

        И, знаете, до сих пор ни разу не использовал! )))
    • 0
      В phoneGap нашёл следующий код:
              public void InvokeCustomScript(ScriptCallback script)
              {
                  if (this.OnCustomScript != null)
                  {
                      this.OnCustomScript(this, script);
                      this.OnCustomScript = null;
                  }
              }
      

      Похоже они зачем-то решили, что бы событие исполнялось единожны
      • 0
        Ну наверняка реализация в каком-нибудь классе как раз ориентированном на единичную обработку событий. У меня лично тоже несколько раз возникала необходимость в подобного рода реализациях.
        • 0
          Возможно, у них там хитрая архитектура и этот класс используется для наследования как служебных сущностей, так и сторонних расширений. Не знаю как в первом случае, а такое поведение во втором случае оказалось для меня весьма неожиданным.
  • 0
    «Если при вычитании из делегата его список вызовов оказывается пуст, то ему присваивается null.»

    Отчего выбрано такое странное поведение?

    Если вдруг список подписчиков на события опустел — это вызовет фатальную ошибку при отправке им события?

    Зачем так сделано?

    Гуры, поясните, если можете.

    • 0
      Чтоб не было фатальной ошибки и надо делать проверку на null перед вызовом. Ну или вариант в статье. Это проще, чем проверять ещё и количество, к примеру.
    • 0
      > Если вдруг список подписчиков на события опустел — это вызовет фатальную ошибку при отправке им события?
      Подписчиками являются непосредственно методы, и при «отправке им события» мы вызываем все методы по порядку. Соответственно когда список методов пуст, есть два пути: не делать ничего или вызвать исключительную ситуацию. Второе обычно лучше, вызывает меньше непонимания в процессе отладки. Хуже, когда какие-то ошибки «проглатываются».
      И вообще, делегат используется не только в механизме событий и отсутствие «ссылки на метод» как-то иначе кроме как пустой ссылкой показывать странно.
      • 0
        и еще — такой вопрос уже задавался команде разработчиков C#. Ответом было «мы не можем изменить имеющийся синтаксис вызова события через делегат, т.к. на это могли повязаться. Получается, что для решения этой проблемы нужно добавлять такую возможность к языку.» Также там было написано, что хотели сделать ключевое слово для вызова, но после обсуждения отказались от этой идеи.
  • 0
    Довольно распространенное заблуждение состоит в том, что порядок уведомления подписчиков не гарантируется.
    Однако, это не так, ведь подписка на событие приводит в конечном счете к Delegate.Combine, который как раз гарантирует порядок вызова. То есть подписчики будут уведомляться в том порядке, в котором они подписывались.
    Безусловно, рассчитывать на конкретный порядок — плохой стиль и надо пересмотреть дизайн приложения, но иногда бывают случаи, когда эта особенность полезна.
    • +1
      Мне кажется, что часто имеют в виду, что порядок подписки не может быть гарантирован. Во-первых, в большинстве случаев, когда подписчиков более 1-2, становится сложно проследить и гарантировать логический порядок подписки (особенно если они отписываются и снова подписываются в процессе работы). А во-вторых, в многопоточном окружении мы не можем знать, какой поток первым осуществит подписку.
      Я бы не стал использовать эту особенность даже для простых случаев — такие порой становятся сложнее, а править «хаки» в них забывают. Не говоря уже о приятной отладке для других членов команды.
  • 0
    Хорошая статья, много нового узнал! Однако, чтобы статья была более полной, нужно упомянуть single-cast delegates.
    • 0
      В C# все делегаты — multicast. См. Simple Delegate (delegate) vs. Multicast delegates
      • 0
        Это мне известно. Однако в статье нет ни слова об этом.
        • 0
          Не очень вас понимаю. В статье написано, что все делегаты могут иметь произвольное количество ссылок на методы. Это не одно ли и то же, что они все — multicast? В любом случае теперь эти слова есть в комментариях.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.