Основы, лучшие методы и соглашения реализации событий в C# - Делегаты. Связь между делегатами и событиями
ОГЛАВЛЕНИЕ
3. Делегаты
Чтобы понять события, реализованные в приложениях .NET, нужно ясно понимать тип делегата .NET и роль, которую он играет в реализации событий.
3.1 Определение и использование делегатов
Делегаты являются разумными контейнерами, хранящими ссылки на методы, в отличие от контейнеров, хранящих ссылки на объекты. Делегаты могут содержать ссылки на ноль, один или много методов. Чтобы метод был вызван конкретным экземпляром делегата, этот метод должен быть зарегистрирован в экземпляре делегата. При регистрации метод добавляется во внутреннюю коллекцию делегата, хранящую ссылки на методы (список вызова делегата). Делегаты могут хранить ссылки на статические методы или методы экземпляров в любом классе, видимом для экземпляра делегата. Экземпляры делегатов могут синхронно или асинхронно вызывать методы, на которые экземпляры ссылаются. При асинхронном вызове методы выполняются в отдельном потоке, потоке пула. Когда вызывается экземпляр делегата, все методы, на которые ссылается делегат, автоматически вызываются делегатом.
Делегаты не могут содержать ссылки на любой метод. Делегаты могут хранить ссылки только на методы, определенные с помощью сигнатуры метода, точно совпадающей с сигнатурой делегата.
Рассмотрим следующее объявление делегата:
public delegate void MyDelegate(string myString);
Заметьте, что объявление делегата выглядит как объявление метода, но без тела метода.
Сигнатура делегата определяет сигнатуру методов, на которые может ссылаться делегат. Пример делегата выше (MyDelegate) может хранить ссылки только на методы, возвращающие void, при этом принимая единственный строковый аргумент. Следовательно, следующий метод может быть зарегистрирован в экземпляре MyDelegate:
private void MyMethod(string someString)
{
// тело метода здесь.
}
Однако на следующие методы экземпляр MyDelegate не может ссылаться, так как их сигнатуры не совпадают с сигнатурой MyDelegate.
private string MyOtherMethod(string someString)
{
// тело метода здесь.
}
private void YetAnotherMethod(string someString, int someInt)
{
// тело метода здесь.
}
После объявления нового типа делегата должен быть создан экземпляр этого делегата, чтобы методы могли быть зарегистрированы в нем и в итоге вызваны экземпляром делегата.
// создается экземпляр делегата и регистрируется метод в новом экземпляре.
MyDelegate del = new MyDelegate(MyMethod);
После создания экземпляра делегата в нем могут быть зарегистрированы дополнительные методы, таким образом:
del += new MyDelegate(MyOtherMethod);
Теперь делегат можно вызвать так:
del("my string value");
Так как MyMethod и MyOtherMethod зарегистрированы в экземпляре MyDelegate (названным del), этот экземпляр вызовет MyMethod и MyOtherMethod, когда выполнится строка кода выше, передав каждому из методов строковое значение "мое строковое значение".
Делегаты и Перегруженные методы
В случае перегруженного метода делегат может ссылаться (или зарегистрировать в себе) только на конкретную перегрузку, имеющую сигнатуру, точно совпадающую с сигнатурой делегата. Когда вы пишете код, который регистрирует перегруженный метод в экземпляре делегата, компилятор C# автоматически выбирает и регистрирует конкретную перегрузку с совпадающей сигнатурой.
Так, например, если ваше приложение объявило следующий тип делегата...
public delegate int MyOtherDelegate(); // возвращает целое число, параметров нет
... и вы зарегистрировали перегруженный метод под названием MyOverloadedMethod в экземпляре MyOtherDelegate, таким образом...
anotherDel += new MyOtherDelegate(MyOverloadedMethod);
... компилятор C# зарегистрирует только конкретную перегрузку с совпадающей сигнатурой. Из следующих двух перегрузок только первая была бы зарегистрирована в экземпляре anotherDel типа MyOtherDelegate:
// не требует параметров – поэтому может быть зарегистрирована в экземпляре MyOtherDelegate
private int MyOverloadedMethod()
{
// тело метода здесь.
}
// требует строковый параметр – поэтому не может быть зарегистрирован в экземпляре MyOtherDelegate.
private int MyOverloadedMethod(string someString)
{
// тело метода здесь.
}
Единственный делегат не может избирательно регистрировать или вызывать обе (несколько) перегрузок. Если вам нужно вызывать обе (несколько) перегрузок, то вам понадобятся дополнительные типы делегатов – один тип делегата на каждую сигнатуру. Ваша относящаяся к приложению логика тогда определяла бы, какой делегат вызывать, и какая перегрузка вызывается (делегатом с соответствующей подписью).
3.2 В чем необходимость делегатов?
Если это ваше первое знакомство с делегатами, вы можете задаться вопросом: "Зачем утруждаться, не проще ли вызвать метод напрямую? Какая польза от применения делегата?"
Необходимая косвенность
Краткий ответ (на вопрос выше "зачем утруждаться?") заключается в том, что написанный нами код или используемые нами компоненты не могут всегда знать, какой конкретный метод вызывать в конкретный момент времени. Одна важная перспектива делегатов в том, что они предоставляют компонентам .NET способ вызова вашего кода без необходимости для компонента .NET знать что-либо о вашем коде, кроме сигнатуры метода (требуемой типом делегата). Например, компоненты среды разработки .NET, такие как компонент таймера, часто должны выполнять написанный вами код. Так как компонент таймера не может знать, какой конкретный метод вызывать, он задает тип делегата (и, стало быть, сигнатуру метода), который будет вызываться. Затем вы подключаете ваш метод – с требуемой сигнатурой – к компоненту таймера, регистрируя ваш метод в экземпляре делегата типа делегата, ожидаемого компонентом таймера. Компонент таймера затем может запустить ваш код, вызвав делегат, который, в свою очередь, вызовет ваш метод.
Заметьте, что компонент таймера по-прежнему ничего не знает о вашем конкретном методе. Компонент таймера знает только о делегате. Делегат, в свою очередь, знает о вашем методе, так как вы зарегистрировали ваш метод в этом делегате. Итог в том, что компонент таймера запускает ваш метод, ничего не зная о вашем конкретном методе.
Точно так же, как и пример компонента таймера выше, можно использовать делегаты таким образом, который позволяет вам писать ваш собственный код без необходимости для нашего кода знать конкретный метод, который в конечном счете будет вызван в конкретной точке. Вместо того чтобы вызывать метод в той точке, код может вызвать экземпляр делегата - который, в свою очередь, вызывает все методы, зарегистрированные в экземпляре делегата. Итог в том, что совместимый метод вызывается, хотя конкретный метод, который будет вызываться, не был непосредственно записан в нашем коде.
Синхронный и асинхронный вызов метода
Все делегаты, по сути, обеспечивают синхронный и асинхронный вызов метода. Еще одна распространенная причина вызывать методы через экземпляры делегата – вызывать методы асинхронно – при этом вызванный метод выполняется в отдельном потоке, потоке пула.
Основа события
Делегаты играют ключевую роль в реализации событий в среде разработки .NET. Вкратце, делегаты обеспечивают необходимый слой косвенности между публикаторами событий и их подписчиками. Данная косвенность нужна для сохранения полного разделения между публикатором и подписчиком(ами) – означает, что подписчики могут быть добавлены и удалены без необходимости каким-либо образом изменять публикатор. В случае публикации события использование делегата позволяет публикатору события ничего не знать ни об одном из его подписчиков, при этом транслируя события и связанные с событиями данные любому/всем подписчикам.
Другие применения
Делегаты играют важную роль в приложениях .NET, кроме уже перечисленных. Эти другие роли не будут дальше излагаться здесь, так как цель данной статьи – сосредоточиться только на основной роли, которую делегаты играют в реализации событий в приложениях .NET.
3.3 Внутреннее устройство делегатов
Объявление делегата приводит к созданию нового класса
Объявление делегата, которое вы напишете, достаточно для определения полного нового класса делегата. Компилятор C# берет ваше объявление делегата и вставляет новый класс делегата в выходную сборку. Имя этого нового класса – имя типа делегата, заданное вами в объявлении делегата. Сигнатура, заданная в вашем объявлении делегата, становится сигнатурой методов в новом классе, используемом для вызова любого/всех из методов, на которые ссылается делегат (особенно методы Invoke и BeginInvoke). Этот новый класс расширяет (наследует) System.MulticastDelegate. Поэтому большинство из методов и свойств, доступных в вашем новом классе делегата, взяты из System.MulticastDelegate. Методы Invoke, BeginInvoke и EndInvoke вставляются компилятором C#, когда он создает новый класс в выходной сборке (это методы, которые можно вызвать, чтобы заставить делегат вызвать любой/все методы, на которые он ссылается, - Invoke для синхронного вызова, и BeginInvoke и EndInvoke, используемые в асинхронных вызовах).
Новый класс, созданный на основе вашего объявления делегата, можно рассматривать как законченную и полноценную реализацию MulticastDelegate, имеющую имя типа, заданное в вашем объявлении делегата, и способную вызывать методы с конкретной сигнатурой, указанной в вашем объявлении делегата.
Например, когда компилятор C# встречает следующее объявление делегата...
public delegate string MyFabulousDelegate(int myIntParm);
... компилятор вставляет новый класс по имени MyFabulousDelegate в выходную сборку. Методы Invoke, BeginInvoke и EndInvoke класса MyFabulousDelegate содержат параметр int и возвращаемое значение string в своих соответствующих сигнатурах методов.
Нужно отметить, что MulticastDelegate – особый класс в том смысле, что компиляторы могут наследовать от него, но вы не можете явно наследовать от него. Ваше использование ключевого слова C# delegate и связанный с ним синтаксис являются методом приказания компилятору C# расширить MulticastDelegate для ваших целей.
Смысл многоадресности
Смысл многоадресности в System.MulticastDelegate в том, что делегат способен хранить ссылки на несколько методов, а не только на один метод. В случае экземпляров делегата, хранящих ссылки на несколько методов, все методы, на которые хранятся ссылки, вызываются, когда вызывается экземпляр делегата.
Делегаты неизменяемые
Экземпляры делегатов неизменяемые – то есть, как только экземпляр делегата создан, он не может быть изменен. Когда вы регистрируете метод в делегате, создается новый экземпляр делегата, включающий дополнительный метод в свой список вызова. Если вы разрегистрируете метод из экземпляра делегата, возвращается новый экземпляр делегата, из списка вызова которого исключен разрегистрированный метод. Если вам нужно создать новую объектную переменную конкретного типа делегата, приравняйте его к существующему экземпляру делегата (данного конкретного типа) – вы получите полноценную и отдельную копию делегата. Изменения в копии (например, регистрация дополнительного метода) повлияют только на копию. Список вызова исходного экземпляра останется неизменным.
Делегаты не являются указателями функций
Наконец, программисты C и C++ признают, что делегаты похожи на указатели функций в стиле C. Но важное отличие в том, что делегат – не просто указатель на непосредственный адрес памяти. Наоборот, экземпляры делегатов являются типизированными объектами, управляемыми .NET CLR(общеязыковая среда исполнения) и ссылаются на один или более методов (в отличие от адресов памяти).
3.4 Все делегаты одинаковые (отсутствуют радикально различающиеся типы делегатов)
Все эти утверждения верны:
"Если вы видели один делегат, вы видели их все".
или
"Все делегаты созданы равными"
или
"Делегат является делегатом является делегатом"
Когда вы читаете о разных типах делегатов, вы должны понимать, что внутри все делегаты одинаковы. Это верно для делегатов, предоставленных средой разработки .NET, и для делегатов, создаваемых вами для ваших собственных целей. Утверждение "все они одинаковые", в частности, означает, что все делегаты (1) наследуются от System.MulticastDelegate, который, в свою очередь, наследуется от System.Delegate; и (2) предоставляют одинаковый набор членов, включая методы Invoke,BeginInvoke, и EndInvoke() и т.д.
Типы делегатов различаются только по таким параметрам,как:
1. Имя типа делегата.
2. Сигнатура делегата - включая тип возвращаемой переменной и количество и типы параметров.
3. Предусмотренное применение или роль делегата.
Возьмем, например, обобщенный делегат предикат (System.Predicate<T>). Ниже перечислено то, что делает его "делегатом предикат":
1. Имя типа: Predicate
2. Сигнатура: возвращает bool, принимает единственный параметр типа object, для которого тип, будучи обобщенным, может быть установлен на этапе проектирования.
3. Предусмотренное применение или роль: этот делегат будет ссылаться на метод, определяющий набор критериев и устанавливающий, соответствует ли заданный объект данным критериям.
Кроме имени типа, сигнатуры и предусмотренного применения, делегат Predicate<T> имеет такой же набор членов, что и у любого другого делегата, включая Invoke, BeginInvoke, и т.д. Следовательно, это и означает утверждение, что "все делегаты одинаковые".
Дело не в том, что делегат Predicate<T> имеет какие-то дополнительные методы или свойства, помогающие ему выполнять его предусмотренную роль. Если бы некоторые делегаты имели свойства или методы, которые не имеют другие делегаты, то эти делегаты имели бы необычные или уникальные возможности, поэтому невозможно было бы сказать, что все они одинаковые.
Касательно перспективы предусмотренного применения: вы имеете право использовать любой делегат для целей, не предусмотренных создателями делегата – так как делегаты не связаны с каким-то конкретным применением. Вы можете, например, использовать делегат Predicate<T> для вызова любого метода, возвращающего bool и принимающего единственный параметр типа object – даже если эти методы не определяют, соответствует ли заданный объект какому-либо критерию (что является предусмотренным применением делегата Predicate<T>). Вы не должны использовать делегаты для целей, отличных от тех, которым они предназначены служить, так как ценность среды разработки .NET, предоставляющей предварительно подготовленные делегаты (такие как Predicate<T>) в том, что можно понять выполняемую ими роль без необходимости перерывать кучу кода, чтобы выяснить, что они на самом деле делают.
Имя типа делегата сообщает его предусмотренное применение в вашем коде. Поэтому обязательно используйте подходящий тип делегата, или создайте ваш собственный с информативным именем типа, даже если доступен другой делегат - с нужной сигнатурой, но с потенциально обманчивым именем с учетом вашего конкретного применения.
4. Связь между делегатами и событиями
События в программировании .NET основаны на делегатах. В частности, событие можно рассматривать как предоставляющее концептуальную обертку вокруг конкретного делегата. Событие контролирует доступ к этому лежащему под ним делегату. Когда клиент подписывается на событие, событие в конечном счете регистрирует подписывающийся метод в лежащем под ним делегате. Затем, когда событие возбуждается, лежащий под ним делегат вызывает каждый метод, зарегистрированный в нем (делегат). В случае событий делегаты играют роль посредников между кодом, вызывающим события, и кодом, выполняемым в ответ на них – тем самым отделяя публикаторов событий от их подписчиков.
События сами по себе не хранят список подписчиков. Наоборот, события контролируют доступ к некоторому лежащему под ними списку подписчиков – и этот список обычно реализован как делегат (хотя другие объекты или коллекции типа списка могут использоваться вместо делегатов).
4.1 Обработчики событий (в целом)
Делегат, существующий для обеспечения выполнения события, называется обработчиком события. Обработчик события является делегатом, хотя делегаты часто не являются обработчиками событий.
К сожалению, многие авторы, пишущие о событиях, используют термин "обработчик события" в отношении (1) делегата, на котором основано событие, и (2) метода, вызываемого делегатом, когда событие возбуждается. Чтобы избежать путаницы, обусловленной таким положением дел, эта статья использует выражение "обработчик события" только в отношении делегата, при этом используя выражение "метод обработки события" в отношении любого метода, зарегистрированного в делегате.
Пользовательские обработчики событий
Вы можете определить свои собственные обработчики событий (делегаты), или вы можете использовать один из обработчиков событий, предоставленных средой разработки .NET (т.е.System.EventHandler или обобщенный System.EventHandler<TEventArgs>). Следующий пример объявления события использует пользовательский обработчик события, а не предоставленный средой разработки обработчик события.
Рассмотрите следующее:
Line 1: public delegate void MyDelegate(string whatHappened);
Line 2: public event MyDelegate MyEvent;
Строка 1 объявляет тип делегата, для которого может быть назначен любой метод - при условии, что метод возвращает void и принимает единственный аргумент string:
• public – область видимости, указывающая, что объекты вне нашего класса могут ссылаться на делегат. Если тип делегата объявлен внутри класса публикации события, то он должен иметь общедоступную область видимости, чтобы подписчики события могли видеть его и объявлять его экземпляры, в которых они должны регистрировать свои методы обработки события (подробнее об этом позже).
• delegate – ключевое слово, применяемое для объявления пользовательских делегатов в среде разработки .NET.
• void - тип возвращаемой переменной. Это часть сигнатуры делегата, и соответственно тип возвращаемой переменной, который регистрирующие методы должны задавать.
• MyDelegate – имя типа делегата.
• (string whatHappened) – остальная часть сигнатуры. Любой метод, регистрирующийся в событии, должен принимать единственный аргумент string (наряду с возвращением void).
Строка 2 объявляет событие в виде типа делегата. Заметьте, что событие (названное MyEvent) объявлено очень похоже на объявление метода – но его тип данных задан как тип делегата:
• public - область видимости, указывающая, что объекты вне нашего класса могут подписываться на событие.
• event – ключевое слово, применяемое для определения события.
• MyDelegate – тип данных события (это пользовательский тип делегата, определенный в строке 1.)
• MyEvent – имя события.
Делегат, объявленный в строке 1, - всего лишь обычный делегат (как и все делегаты), и может использоваться для любой цели, которую делегаты могут выполнить. Строка 2 (т.е., использование типа делегата) превращает делегат в обработчик события. Чтобы сообщить, что конкретный тип делегата применяется в качестве обработчика события, появился способ именования, при котором имя типа делегата заканчивается на "обработчик" (подробнее об этом позднее).
Стандартизированные обработчики событий
Хотя вы можете создавать свои собственные обработчики событий (и иногда вам может это понадобиться), вы должны использовать один из делегатов EventHandler, предоставляемых средой разработки .NET, в случаях, когда один из обработчиков событий среды разработки может работать с вашей конкретной реализацией события. Многие события используют обработчики событий, имеющие общие или одинаковые сигнатуры. Поэтому не загромождайте ваш исходный код кучей делегатов, различающихся лишь по имени типа, а используйте встроенные обработчики событий, так как такой подход сокращает объем кода, который вам понадобится написать и обслуживать, и делает ваш код более понятным. Если некто, читающей ваш код, видит, что вы основываете событие на делегате System.EventHandler , то он автоматически много узнает о вашей реализации события без необходимости смотреть дальше.
4.2 Необобщенный делегат System.EventHandler
Доступный в версии 1.x среды разработки .NET, необобщенный делегат System.EventHandler навязывает условное обозначение (подробнее описано ниже) обработчиков событий, не возвращающих никакого значения, в то же время принимающих два параметра: первый является параметром типа object (чтобы хранить ссылку на класс, возбуждающий событие), второй параметр типа System.EventArgs или его подкласс (чтобы хранить любые данные о событии). System.EventArgs представлен далее.
Так среда разработки .NET объявляет делегат System.EventHandler.
public delegate void EventHandler(object sender, EventArgs e);
4.3 Обобщенный делегат System.EventHandler<TEventArgs>
Доступный начиная с версии 2.0 среды разработки .NET, обобщенный делегат System.EventHandler навязывает такое же соглашение сигнатуры, какое навязывает необобщенная версия, но принимает параметр обобщенного типа в качестве второго параметра System.EventArgs.
Объявление этого встроенного делегата навязывает ограничение, чтобы тип TEventArgs принадлежал к типу System.EventArgs (включая его подклассы):
public delegate void EventHandler<TEventArgs>(object sender, TEventArgs e)
where TEventArgs : EventArgs;
Предположим, вам нужно строго задать тип отправителя, а не оставлять в качестве его типа object. Вы можете использовать обобщенные, чтобы создать ваш собственный обобщенный обработчик события:
public delegate void MyGenericEventHandler<T, U>(T sender,
U u) where U : EventArgs;
Вы можете использовать этот пользовательский обобщенный обработчик событий, чтобы дополнительно задать типизированный параметр sender (т.е. тем самым ограничивая тип объекта, который может быть передан в качестве возбудившего событие):
public event MyGenericEventHandler<MyPublisher, MyEventArgs> MyEvent;
Смысл в том, что это событие будет возбуждаться только объектами типа MyPublisher. Следовательно, подписчики события смогут подписываться только на события, опубликованные классом MyPublisher.