Обзор обработчиков событий (Windows Forms)
Обработчик событий — это метод, привязанный к событию. При возникновении события выполняется код в обработчике событий. Каждый обработчик событий предоставляет два параметра, которые позволяют правильно обрабатывать событие. В следующем примере показан обработчик событий для события Click элемента управления Button.
Первый параметр ( sender ) предоставляет ссылку на объект, вызвавший событие. Второй параметр ( e в приведенном выше примере) передает объект, относящийся к обрабатываемому событию. Ссылаясь на свойства объекта (а иногда и его методы), можно получить такие сведения, как расположение мыши для событий мыши или данные, передаваемые в событиях перетаскивания.
Обычно каждое событие создает обработчик событий с другим типом объекта события для второго параметра. Некоторые обработчики событий, например для событий MouseDown и MouseUp, имеют один и тот же тип объекта для своего второго параметра. Для этих типов событий можно использовать один и тот же обработчик событий для обработки обоих событий.
Можно также использовать один и тот же обработчик событий для обработки одного и того же события в разных элементах управления. Например, если у вас в форме есть группа элементов управления RadioButton, можно создать один обработчик событий для события Click и привязать событие Click каждого элемента управления к этому обработчику событий. Дополнительные сведения см. в разделе Практическое руководство. Подключение нескольких событий к одному обработчику событий в Windows Forms.
События .NET в деталях
Событием в языке C# называется сущность, предоставляющая две возможности: для класса — сообщать об изменениях, а для его пользователей — реагировать на них.
Пример объявления события:
Рассмотрим, из чего состоит объявление. Сначала идут модификаторы события, затем ключевое слово event, после него — тип события, который обязательно должен быть типом-делегатом, и идентификатор события, то есть его имя. Ключевое слово event сообщает компилятору о том, что это не публичное поле, а специальным образом раскрывающаяся конструкция, скрывающая от программиста детали реализации механизма событий. Для того, чтобы понять, как работает этот механизм, необходимо изучить принципы работы делегатов.
Основа работы событий — делегаты
Можно сказать, что делегат в .NET — некий аналог ссылки на функцию в C++. Вместе с тем, такое определение неточно, т.к. каждый делегат может ссылаться не на один, а на произвольное количество методов, которые хранятся в списке вызовов делегата (invocation list). Тип делегата описывает сигнатуру метода, на который он может ссылаться, экземпляры этого типа имеют свои методы, свойства и операторы. При вызове метода Invoke() выполняется последовательный вызов каждого из методов списка. Делегат можно вызывать как функцию, компилятор транслирует такой вызов в вызов Invoke().
В C# для делегатов имеются операторы + и -, которые не существуют в среде .NET и являются синтаксическим сахаром языка, раскрываясь в вызов методов Delegate.Combine и Delegate.Remove соответственно. Эти методы позволяют добавлять и удалять методы в списке вызовов. Разумеется, форма операторов с присваиванием (+= и -=) также применима к операторам делегата, как и к определенным в среде .NET операторам + и — для других типов. Если при вычитании из делегата его список вызовов оказывается пуст, то ему присваивается null.
Рассмотрим простой пример:
События — реализация по умолчанию
- Неявная реализация события (field-like event).
- Явная реализация события.
Рассмотрим наиболее часто используемую реализацию событий — неявную. Пусть имеется следующий исходный код на языке C# 4 (это важно, для более ранних версий генерируется несколько иной код, о чем будет рассказано далее):
Эти строчки будут транслированы компилятором в код, аналогичный следующему:
Блок 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:
Помимо этого, работа с событием внутри класса ведется как с делегатом, т.е. += и -= вызывают Delegate.Combine и Delegate.Remove напрямую, в обход методов add/remove. Это изменение может привести к невозможности сборки проекта на языке C# 4! В C# 3 результатом += и -= был делегат, т.к. результатом присвоения переменной всегда является присвоенное значение. В C# 4 результатом является void, т.к. методы add/remove не возвращают значения.
Помимо изменений в работе на разных версиях языка есть еще несколько особенностей.
Особенность №1 — продление времени жизни подписчика
При подписке на событие мы добавляем в список вызовов делегата события ссылку на метод, который будет вызван при вызове события. Таким образом, память, занимаемая объектом, подписавшимся на событие, не будет освобождена до его отписки от события или до уничтожения объекта, заключающего в себе событие. Эта особенность является одной из часто встречаемых причин утечек памяти в приложениях.
Для исправления этого недостатка часто используются weak events, слабые события. Эта тема уже была освещена на Хабре.
Особенность №2 — явная реализация интерфейса
Событие, являющееся частью интерфейса, не может быть реализовано как поле при явной реализации этого интерфейса. В таких случаях следует либо скопировать стандартную реализацию события для реализации как свойство, либо реализовывать эту часть интерфейса неявно. Также, если вам не нужна потокобезопасность этого события, можно использовать самое простое и эффективное определение:
Особенность №3 — безопасный вызов
События перед вызовом следует проверять на null, что следует из описанной выше работы делегатов. От этого разрастается код, для избежания чего существует как минимум два способа. Первый способ описан Джоном Скитом (Jon Skeet) в его книге C# in depth:
Коротко и лаконично. Мы инициализируем делегат события пустым методом, поэтому он никогда не будет null. Вычесть из делегата этот метод невозможно, т.к. он определен при инициализации делегата и у него нет ни имени, ни ссылки на него из любого места программы.
Второй способ заключается в написании метода, содержащего в себе необходимую проверку на null. Этот прием особенно хорошо работает в .NET 3.5 и выше, где доступны методы расширений (extension methods). Так как при вызове метода расширений объект, на котором он вызывается, является всего лишь параметром этого метода, то этот объект может быть пустой ссылкой, что и используется в данном случае.
Таким образом, мы можем вызывать события как Changed.SafeRaise(this, EventArgs.Empty), что экономит нам строчки кода. Также можно определить третий вариант метода расширений для случая, когда у нас EventArgs.Empty, чтобы не передавать их явно. Тогда код сократится до Changed.SafeRaise(this), но я не буду рекомендовать такой подход, т.к. для других членов вашей команды это может быть не так явно, как передача пустого аргумента.
Тонкость №4 — что не так со стандартной реализацией?
Если у вас стоит ReSharper, то вы могли наблюдать следующее его сообщение. Команда решарпера правильно считает, что не все ваши пользователи достаточно осведомлены в работе событий\делегатов в плане отписки\вычитания, но тем не менее ваши события должны работать предсказуемо не для ваших пользователей, а с точки зрения событий в .NET, а т.к. там такая особенность есть, то и в вашем коде она должна остаться.
Бонус: попытка Microsoft сделать контравариантные события
В первой бете C# 4 Microsoft попытались добавить контравариантности событиям. Это позволяло подписываться на событие EventHandler<MyEventArgs> методами, имеющими сигнатуру EventHandler<EventArgs> и все работало до тех пор, пока в делегат события не добавлялось несколько методов с разной (но подходящей) сигнатурой. Такой код компилировался, но падал с ошибкой времени выполнения. По всей видимости, обойти это так и не смогли и в релизе C# 4 контравариантность для EventHandler была отключена.
Это не так заметно, если опускать явное создание делегата при подписке, например следующий код отлично скомпилируется:
События в C# и их вызов
События в C# позволяют классу или объекту уведомлять другие классы или объекты о возникновении каких-либо ситуаций. События активно используются в Windows-приложениях. Класс, который порождает (отправляет) событие, называется издателем, а классы, обрабатывающие (принимающие) событие, называются подписчиками. Соответственно, на одно и то же событие могут подписываться несколько подписчиков.
События в C#
Итак, вспомним наш класс Person, который мы использовали, когда разбирали тему использования делегатов. Итоговый класс у нас выглядел следующим образом:
Использование метода ChangeDepartmnet в данном случае хоть и возможно, но избыточно. Более логично, в данном случае, позволить пользователю класса менять свойство Department как и другие свойства и, при этом, в своей программе «отлавливать» момент изменения этого свойства и выводить результат в консоль. И здесь-то нам и помогут события в C#.
В C# событие определяется следующим образом:
- event — ключевое слово, которое сообщает нам, что перед нами событие
- delegateType — это тип делегата. Делегат описывает то, как должен выглядеть метод в подписчике, который будет обрабатывать событие, а также то, какие параметры необходимо передавать подписчику. То есть, делегат, образно выражаясь, представляет из себя некий договор между издателем и подписчиком как они будут между собой общаться.
- EventName — название события
В библиотеке классов .NET события основываются на делегате EventHandler и базовом классе EventArgs . Делегат EventHandler выглядит следующим образом:
- sender — определяет класс или объект который породил событие (издатель)
- e — класс, содержащий параметры, передаваемые подписчику.
Технически, зная то, что из себя представляет делегат в C# и как он определяется, мы можем определить делегат для нашего события в любом удобном для нас виде, хоть так:
и затем объявить вот такое событие:
И наш код будет работать, НО правильным и хорошим тоном считается придерживаться следующего правила: делегат события содержит два параметра — первый предоставляет подписчику информацию об издателе, а второй — наследник класса EventArgs передает подписчику необходимые параметры. Попробуем переписать наш класс Person и создать свое первое событие в C#
Создание события в C#
Перепишем наш класс Person следующим образом:
Обратим внимание на следующие моменты:
- Мы объявили событие типа EventHandler с названием ChangeDepartment — оно будет порождаться, когда у объекта будет изменяться название отдела
- Свойства Department вместо сокращенной формы записи теперь имеет блоки get и set . При этом, в блоке set проверяется действительно ли новое название отдела не совпадает с текущим и только, если новое название не совпадает со старым меняется значение у department и вызывается метод делегата.
Теперь мы можем подписаться на это событие и получать уведомления от издателя. Сделать это можно, например, так:
Здесь мы создали объект класс Person , и назначили в качестве обработчика события ChangeDepartment метод EventHandler . Теперь, если запустить приложение, то можно увидеть, что событие сработает ровно один раз:
Подписка на события в C#
На любое событие в C# может быть подписано любое количество подписчиков. Более того, один и тот же класс может подписаться любое количество раз на событие:
Как и в случае работы с обычными делегатами, методы EventHandler и EventHandler_2 будут вызваны последовательно:
Создание собственных делегатов событий в C#
В приведенном выше примере мы использовали уже имеющийся в .NET делегат события EventHandler , который возвращал нам, опять же, имеющийся в .NET C# класс EventArgs . По сути EventArgs — это пустой набор параметров и выглядит он следующим образом:
Но что, если нам потребуется, чтобы событие ChangeDepartment сообщало нам не только факт смены отдела, но и то, какой отдел был ранее? Здесь нам поможет создание собственного делегата события. Делается это следующим образом:
Во-первых, создаем класс-наследник EventArgs который будет содержать необходимые параметры. Например, можем определить такой класс:
здесь стоит обратить внимание на конструктор — в нем мы как раз передаем старое и новое название и присваиваем эти значения свойствам класса OldDepartment и NewDepartment .
Во-вторых, объявляем делегат для события:
В третьих, определяем событие в классе:
В-четвертых, обеспечить внутри класса вызов события:
Нам остается только подписаться на событие и получать сообщения от издателя. Ниже показан код основной программы:
Теперь у нас класс Person содержит собственное событие, используя которое мы можем получать информацию об изменении свойства Department , включая и предыдущее. название. В C# мы можем создавать события, которые могут отдавать (и получать) любое количество параметров.
Итого
Сегодня мы рассмотрели основные моменты по созданию событий у классов в C#, научились определять события в классе, подписываться на события и создавать свои собственные события в C#. В библиотеке классов .NET события основываются на делегате EventHandler и базовом классе EventArgs .