Делегаты и события в .NET

От переводчика. Судя по своему опыту, а также по опыту знакомых коллег-программистов, могу сказать, что для начинающего разработчика среди всех базовых функций языка C# и платформы .NET делегаты и события являются одними из наиболее сложных. Возможно, это из-за того, что необходимость делегатов и событий на первый взгляд кажется неочевидной, или же из-за некоторой путаницы в терминах. Поэтому я решил перевести статью Джона Скита, рассказывающую о делегатах и событиях на самом базовом уровне, «на пальцах». Она идеальна для тех, кто знаком с C# .NET, однако испытывает затруднение в понимании делегатов и событий.
Представленный здесь перевод является вольным. Однако если под «вольным», как правило, понимают сокращённый перевод, с упущениями, упрощениями и пересказами, то здесь всё наоборот. Данный перевод является немного расширенной, уточнённой и обновлённой версией оригинала. Я выражаю огромную благодарность Сергею Теплякову aka, который внёс неоценимый вклад в перевод и оформление данной статьи.

Люди часто испытывают затруднения в понимании различий между событиями и делегатами. И C# ещё больше запутывает ситуацию, так как позволяет объявлять field-like события, которые автоматически преобразуются в переменную делегата с таким же самым именем. Эта статья призвана прояснить данный вопрос. Ещё одним моментом является путаница с термином «делегат», который имеет несколько значений. Иногда его используют для обозначения типа делегата (delegate type), а иногда — для обозначения экземпляра делегата (delegate instance). Чтобы избежать путаницы, я буду явно использовать эти термины — тип делегата и экземпляр делегата, а когда буду использовать слово «делегат» — значит, я говорю о них в самом широком смысле.

Типы делегатов

В каком-то смысле вы можете думать о типе делегата как о некоем интерфейсе, в котором определён лишь один метод с чётко заданной сигнатурой (в этой статье под сигнатурой метода я буду понимать все его входные и выходные (ref и out) параметры, а также возвращаемое значение). Тогда экземпляр делегата — это объект, реализующий этот интерфейс. В этом понимании, имея экземпляр делегата, вы можете вызвать любой существующий метод, сигнатура которого будет совпадать с сигнатурой метода, определённого в «интерфейсе». Делегаты обладают и другой функциональностью, но возможность делать вызовы методов с заранее определёнными сигнатурами — это и есть самая суть делегатов. Экземпляр делегата хранит ссылку (указатель, метку) на целевой метод и, если этот метод является экземплярным, то и ссылку на экземпляр объекта (класса или структуры), в котором «находится» целевой метод.

Тип делегата объявляется при помощи ключевого слова delegate. Типы делегатов могут существовать как самостоятельные сущности, так и быть объявленными внутри классов или структур. Например:

namespace DelegateArticle
 {
     public delegate string FirstDelegate (int x);
     
     public class Sample
     {
         public delegate void SecondDelegate (char a, char b);
     }
 }

В этом примере объявлены два типа делегата. Первый — DelegateArticle.FirstDelegate, который объявлен на уровне пространства имён. Он «совместим» с любым методом, который имеет один параметр типа int и возвращает значение типа string. Второй — DelegateArticle.Sample.SecondDelegate, который объявлен уже внутри класса и является его членом. Он «совместим» с любым методом, который имеет два параметра типа char и не возвращает ничего, так как возвращаемый тип помечен как void.

Обратите внимание, что оба типа делегата имеют модификатор доступа public. Вообще, по отношению модификаторов доступа типы делегатов ведут себя так же, как классы и структуры. Если для типа делегата явно не указан модификатор доступа и этот тип объявлен внутри пространства имён, то он будет доступен для всех объектов, также находящихся внутри этого пространства имён. Если же тип делегата без модификатора объявлен внутри класса или структуры, то он будет закрытым, аналогично действию модификатора private.

При объявлении типа делегата нельзя использовать модификатор static.

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

Оба типа делегата, объявленные в этом примере, наследуются от System.MulticastDelegate, который, в свою очередь, наследуется от System.Delegate. На практике учитывайте наследование только от MulticastDelegate — различие между Delegate и MulticastDelegate лежит прежде всего в историческом аспекте. Эти различия были существенны в бета-версиях .NET 1.0, но это было неудобно, и Microsoft решила объединить два типа в один. К сожалению, решение было сделано слишком поздно, и когда оно было сделано, делать такое серьёзное изменение, затрагивающее основу .NET, не решились. Поэтому считайте, что Delegate и MulticastDelegate — это одно и то же.

Каждый тип делегата, созданный вами, наследует члены от MulticastDelegate, а именно: один конструктор с параметрами Object и IntPtr, а также три метода: Invoke, BeginInvoke и EndInvoke. К конструктору мы вернёмся чуточку позже. Вообще-то эти три метода не наследуются в прямом смысле, так как их сигнатура для каждого типа делегата своя — она «подстраивается» под сигнатуру метода в объявленном типе делегата. Глядя на пример кода выше, выведем «наследуемые» методы для первого типа делегата FirstDelegate:

public string Invoke (int x);
public System.IAsyncResult BeginInvoke(int x, System.AsyncCallback callback, object state);
public string EndInvoke(IAsyncResult result);

Как вы видите, возвращаемый тип методов Invoke и EndInvoke совпадает с таковым, указанным в сигнатуре делегата, так же, как и параметр метода Invoke и первый параметр BeginInvoke. Мы рассмотрим цель метода Invoke далее в статье, а BeginInvoke и EndInvoke рассмотрим в разделе, описывающем продвинутое использование делегатов. Сейчас ещё рано об этом говорить, так как мы ещё даже не знаем, как создавать экземпляры делегатов. Вот об этом и поговорим в следующем разделе.

Экземпляры делегатов: основы

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

Создание экземпляров делегатов

Прежде всего замечу, что эта статья не рассказывает о новых функциональных возможностях C# 2.0 и 3.0, связанных с созданием экземпляров делегатов, так же, как и не покрывает обобщённые делегаты, появившиеся в C# 4.0. Моя отдельная статья о замыканиях «The Beauty of Closures» повествует о новых возможностях делегатов, которые появились в C# 2.0 и 3.0; кроме того, много информации по этой теме содержится в главах 5, 9 и 13 моей книги «C# in Depth». Я буду придерживаться явного стиля создания экземпляров делегатов, который появился в C# 1.0/1.1, так как полагаю, что такой стиль проще для понимания того, что происходит «под капотом». Вот когда вы постигнете основы, то можно будет приступать к освоению новых возможностей из C# 2.0, 3.0 и 4.0; и наоборот, без твёрдого понимания основ, изложенных в этой статье, «новый» функционал делегатов может быть для вас неподъёмным.

Как говорилось ранее, каждый экземпляр делегата обязательно содержит ссылку на целевой метод, который может быть вызван через этот экземпляр делегата, и ссылку на экземпляр объекта (класса или структуры), в котором объявлен целевой метод. Если целевой метод является статическим, то, естественно, ссылка на экземпляр отсутствует. CLR поддерживает и другие, немного различные формы делегатов, где первый аргумент, передаваемый в статический метод, хранится в экземпляре делегата, или же ссылка на целевой экземплярный метод передаётся как аргумент при вызове метода. Более подробно об этом можно прочитать в документации к System.Delegate на MSDN, однако сейчас, на данном этапе, эти дополнительные сведения не существенны.

Итак, мы знаем, что для создания экземпляра нам нужны две «единицы» данных (ну и сам тип делегата, конечно), однако как дать знать об этом компилятору? Мы используем то, что в спецификации к C# называется «выражение создания делегата» (delegate-creation-expression), что является одной из форм new delegate-type (expression). Выражение (expression) должно быть или другим делегатом с таким же самым типом (или с совместимым типом делегата в C# 2.0), или же «группой методов» (method group), которая состоит из названия метода и опциональной ссылки на экземпляр объекта. Группа методов указывается точно также, как и обычный вызов метода, но без каких-либо аргументов и круглых скобок. Необходимость в создании копий делегата возникает довольно редко, поэтому мы сосредоточимся на более общих формах. Примеры ниже.

/* Два выражения создания экземпляров делегатов d1 и d2 эквивалентны. Здесь InstanceMethod является экземплярным методом, который объявлен в классе, в котором также объявлены нижеприведённые выражения (базовый класс). Соответственно, ссылка на экземпляр объекта — this, и именно поэтому эти выражения эквивалентны. */
FirstDelegate d1 = new FirstDelegate(InstanceMethod);
 FirstDelegate d2 = new FirstDelegate(this.InstanceMethod);

/* Здесь (d3) мы создаём экземпляр делегата, ссылающийся на тот же метод, что и в предыдущих двух выражениях, но на этот раз с другим экземпляром класса. */
FirstDelegate d3 = new FirstDelegate(anotherInstance.InstanceMethod);

/* В этом (d4) экземпляре делегата используется уже другой метод, тоже экземплярный, который объявлен в другом классе; мы указываем экземпляр этого класса и сам метод. */
FirstDelegate d4 = new FirstDelegate(instanceOfOtherClass.OtherInstanceMethod);

/* А вот этот (d5) экземпляр делегата использует статический метод, который расположен в том же классе, где и это выражение (базовом классе). */
FirstDelegate d5 = new FirstDelegate(StaticMethod);

/* Здесь (d6) экземпляр делегата использует другой статический метод, объявленный на этот раз в стороннем классе. */
FirstDelegate d6 = new FirstDelegate(OtherClass.OtherStaticMethod);

Конструктор делегата, о котором мы говорили ранее, имеет два параметра — ссылку на вызываемый метод типа System.IntPtr (в документации MSDN этот параметр называется method) и ссылку на экземпляр объекта типа System.Object (в документации MSDN этот параметр называется target), которая принимает значение null, если метод, указанный в параметре method, является статическим.

Необходимо сделать важное замечание: экземпляры делегатов могут ссылаться на методы и экземпляры объектов, которые будут невидимыми (вне области видимости) по отношению к тому месту в коде, где будет произведён вызов экземпляра делегата. Например, при создании экземпляра делегата может быть использован приватный (private) метод, а потом этот экземпляр делегата может быть возвращён из другого, публичного (public) метода или свойства. С другой стороны, экземпляр объекта, указанный при создании экземпляра делегата, может быть объектом, который при вызове будет неизвестным по отношению к тому объекту, в котором был совершен вызов. Важно то, что и метод, и экземпляр объекта должны быть доступны (находиться в области видимости) на момент создания экземпляра делегата. Другими словами, если (и только если) в коде вы можете создать экземпляр определённого объекта и вызвать определённый метод из этого экземпляра, то вы можете использовать этот метод и экземпляр объекта для создания экземпляра делегата. А вот во время вызова ранее созданного экземпляра делегата права доступа и область видимости игнорируются. Кстати, о вызовах…

Вызов экземпляров делегатов

Экземпляры делегатов вызываются так же само, как если бы это были те методы, на которые экземпляры делегатов ссылаются. К примеру, вызов экземпляра делегата d1, тип которого определён в самом верху как delegate string FirstDelegate (int x), будет следующим:

string result = d1(10);

Метод, ссылку на который хранит экземпляр делегата, вызывается «в рамках» (или «в контексте», если другими словами) экземпляра объекта, если такой есть, после чего возвращается результат. Написание полноценной программы, демонстрирующей работу делегатов, и при этом компактной, не содержащей «лишнего» кода, является непростой задачей. Тем не менее, ниже приведена подобная программа, содержащая один статический и один экземплярный метод. Вызов DelegateTest.StaticMethod эквивалентен вызову StaticMethod — я включил название класса, чтобы сделать пример более понимаемым.

using System;

public delegate string FirstDelegate (int x);
     
class DelegateTest
 {    
     string name;
     
     static void Main()
     {
         FirstDelegate d1 = new FirstDelegate(DelegateTest.StaticMethod);
         
         DelegateTest instance = new DelegateTest();
         instance.name = "My instance";
         FirstDelegate d2 = new FirstDelegate(instance.InstanceMethod);
         
         Console.WriteLine (d1(10)); // Выводит на консоль "Static method: 10"
         Console.WriteLine (d2(5));  // Выводит на консоль "My instance: 5"
     }
     
     static string StaticMethod (int i)
     {
         return string.Format ("Static method: {0}", i);
     }

     string InstanceMethod (int i)
     {
         return string.Format ("{0}: {1}", name, i);
     }
 }

Синтаксис C# по вызову экземпляров делегатов является синтаксическим сахаром, маскирующим вызов метода Invoke, который есть у каждого типа делегата. Делегаты могут выполняться асинхронно, если предоставляют методы BeginInvoke/EndInvoke, но об этом позже.

Комбинирование делегатов

Делегаты могут комбинироваться (объединяться и вычитаться) таким образом, что когда вы вызываете один экземпляр делегата, то вызывается целый набор методов, причём эти методы могут быть из различных экземпляров различных классов. Когда я раньше говорил, что экземпляр делегата хранит ссылки на метод и на экземпляр объекта, я немного упрощал. Это справедливо для тех экземпляров делегатов, которые представляют один метод. Для ясности в дальнейшем я буду называть такие экземпляры делегатов «простыми делегатами» (simple delegate). В противовес им, существуют экземпляры делегатов, которые фактически являются списками простых делегатов, все из которых основываются на одном типе делегата (т.е. имеют одинаковую сигнатуру методов, на которые ссылаются). Такие экземпляры делегатов я буду называть «комбинированными делегатами» (combined delegate). Несколько комбинированных делегатов могут быть скомбинированы между собой, фактически становясь одним большим списком простых делегатов. Список простых делегатов в комбинированном делегате называется «списком вызовов» или «списком действий» (invocation list). Т.о., список вызовов — это список пар ссылок на методы и экземпляры объектов, которые (пары) расположены в порядке вызова.

Важно знать, что экземпляры делегатов всегда неизменяемы (immutable). Каждый раз при объединении экземпляров делегатов (а также при вычитании – это мы рассмотрим чуть ниже) создаётся новый комбинированный делегат. В точности, как и со строками: если вы применяете String.PadLeft к экземпляру строки, то метод не изменяет этот экземпляр, а возвращает новый экземпляр с проделанными изменениями.

Объединение (также встречается термин «сложение») двух экземпляров делегатов обычно производится при помощи оператора сложения, как если бы экземпляры делегатов были числами или строками. Аналогично, вычитание (также встречается термин «удаление») одного экземпляра делегата из другого производится при помощи оператора вычитания. Имейте ввиду, что при вычитании одного комбинированного делегата из другого вычитание производится в рамках списка вызовов. Если в оригинальном (уменьшаемом) списке вызовов нет не одного из тех простых делегатов, которые находятся в вычитаемом списке вызовов, то результатом операции (разностью) будет оригинальный список. В противном случае, если в оригинальном списке присутствуют простые делегаты, присутствующие и в вычитаемом, то в результирующем списке будут отсутствовать лишь последние вхождения простых делегатов. Впрочем, это легче показать на примерах, нежели описать на словах. Но вместо очередного исходного кода я продемонстрирую работу объединения и вычитания на примере нижеследующей таблицы. В ней литералами d1, d2, d3 обозначены простые делегаты. Далее, обозначение [d1, d2, d3] подразумевает комбинированный делегат, который состоит из трёх простых именно в таком порядке, т.е. при вызове сначала будет вызван d1, потом d2, а затем d3. Пустой список вызовов представлен значением null.

Выражение Результат
null + d1 d1
d1 + null d1
d1 + d2 [d1, d2]
d1 + [d2, d3] [d1, d2, d3]
[d1, d2] + [d2, d3] [d1, d2, d2, d3]
[d1, d2] — d1 d2
[d1, d2] — d2 d1
[d1, d2, d1] — d1 [d1, d2]
[d1, d2, d3] — [d1, d2] d3
[d1, d2, d3] — [d2, d1] [d1, d2, d3]
[d1, d2, d3, d1, d2] — [d1, d2] [d1, d2, d3]
[d1, d2] — [d1, d2] null

Кроме оператора сложения, экземпляры делегатов могут объединяться при помощи статического метода Delegate.Combine; аналогично ему, операция вычитания имеет альтернативу в виде статического метода Delegate.Remove. Вообще говоря, операторы сложения и вычитания — это своеобразный синтаксический сахар, и компилятор C#, встречая их в коде, заменяет на вызовы методов Combine и Remove. И именно потому, что данные методы являются статическими, они легко справляются с null-экземплярами делегатов.

Операторы сложения и вычитания всегда работают как часть операции присваивания d1 += d2, которая полностью эквивалента выражению d1 = d1+d2; то же самое для вычитания. Снова-таки, напоминаю, что экземпляры делегатов, участвующие в сложении и вычитании, не изменяются в процессе операции; в данном примере переменная d1 просто сменит ссылку на новосозданный комбинированный делегат, состоящий из «старого» d1 и d2.

Обратите внимание, что добавление и удаление делегатов происходит с конца списка, поэтому последовательность вызовов x += y; x -= y; эквивалентна пустой операции (переменная x будет содержать неизменный список подписчиков, прим. перев.).

Если сигнатура типа делегата объявлена такой, что возвращает значение (т.е. возвращаемое значение не является void) и «на основе» этого типа создан комбинированный экземпляр делегата, то при его вызове в переменную будет записано возвращаемое значение, «предоставленное» последним простым делегатом в списке вызовов комбинированного делегата.

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

События

Перво-наперво: события (event) не являются экземплярами делегатов. А теперь снова:
События — это НЕ экземпляры делегатов.

В некотором смысле жаль, что язык C# позволяет использовать события и экземпляры делегатов в определённых ситуациях одинаковым образом, однако очень важно понимать разницу.

Я пришел к выводу, что самый лучший способ понять события, это думать о них как о «как бы» свойствах (properties). Свойства, хотя и выглядят «типа как» поля (fields), на самом деле ими определённо не являются — вы можете создать свойства, которые вообще никак не используют поля. Подобным образом ведут себя и события — хотя и выглядят как экземпляры делегатов в плане операций добавления и вычитания, но на самом деле не являются ими.

События являются парами методов, соответствующе «оформленные» в IL (CIL, MSIL) и связанные между собой так, чтобы языковая среда чётко знала, что она «имеет дело» не с «простыми» методами, а с методами, которые представляют события. Методы соответствуют операциям добавления (add) и удаления (remove), каждая из которых принимает один параметр с экземпляром делегата, который имеет тип, одинаковый с типом события. То, что вы будете делать с этими операциями, в значительной степени зависит от вас, но обычно операции добавления и удаления используются для добавления и удаления экземпляров делегатов в/из списка обработчиков события. Когда событие срабатывает (и не важно, что было причиной срабатывания — щелчок по кнопке, таймер или необработанное исключение), происходит поочерёдный (один за другим) вызов обработчиков. Знайте, что в C# вызов обработчиков события не является частью самого события.

Методы добавления и удаления вызываются в C# так eventName += delegateInstance; и так eventName -= delegateInstance;, где eventName может быть указано по ссылке на экземпляр объекта (например, myForm.Click) или по называнию типа (например, MyClass.SomeEvent). Впрочем, статические события встречаются довольно редко.

События сами по себе могут быть объявлены двумя способами. Первый способ — с явной (explicit) реализацией методов add и remove; этот способ очень похож на свойства с явно объявленными геттерами (get) и сеттерами (set), но с ключевым словом event. Ниже представлен пример свойства для типа делегата System.EventHandler. Обратите внимание, что в методах add и remove не происходит никаких операций с экземплярами делегатов, которые туда передаются — методы просто выводят в консоль сообщения о том, что они были вызваны. Если вы выполните этот код, то увидите, что метод remove будет вызван, невзирая на то, что мы ему передали для удаления значение null.

using System;

class Test
 {
     public event EventHandler MyEvent //публичное событие MyEvent с типом EventHandler
     {
         add
         {
             Console.WriteLine ("add operation");
         }
         
         remove
         {
             Console.WriteLine ("remove operation");
         }
     }       
     
     static void Main()
     {
         Test t = new Test();
         
         t.MyEvent += new EventHandler (t.DoNothing);
         t.MyEvent -= null;
     }
     
    //Метод-заглушка, сигнатура которого совпадает с сигнатурой типа делегата EventHandler
     void DoNothing (object sender, EventArgs e)
     {
     }
 }

Моменты, когда приходится игнорировать полученное значение value, возникают довольно редко. И хотя случаи, когда мы можем игнорировать передаваемое таким образом значение, крайне редки, бывают случаи, когда нам не подойдет использование простой переменной делегата для содержания подписчиков. Например, если класс содержит множество событий, но подписчики будут использовать лишь некоторый из них, мы можем создать ассоциативный массив, в качестве ключа которой будет использовать описание событие, а в качестве значения — делегат с его подписчиками. Именно эта техника используется в Windows Forms — т.е. класс может содержать огромное количество событий без напрасного использования памяти под переменные, которые в большинстве случаев будут равными null.

Field-like события

C# обеспечивает простой способ объявления переменной делегата и события в один и тот же момент. Этот способ называется «field-like событием» (field-like event) и объявляется очень просто — так же, как и «длинная» форма объявления события (приведённая выше), но без «тела» с методами add и remove.

public event EventHandler MyEvent;

Эта форма создает переменную делегата и событие с одинаковым типом. Доступ к событию определяется в объявлении события с помощью модификатора доступа (т.о., в примере выше создаётся публичное событие), но переменная делегата всегда приватна. Неявное (implicit) тело события разворачивается компилятором во вполне очевидные операции добавления и удаления экземпляров делегата к/из переменной делегата, причём эти действия выполняются под блокировкой (lock). Для C# 1.1 событие MyEvent из примера выше эквивалентно следующему коду:

private EventHandler _myEvent;
     
public event EventHandler MyEvent
 {
     add
     {
         lock (this)
         {
             _myEvent += value;
         }
     }
     remove
     {
         lock (this)
         {
             _myEvent -= value;
         }
     }        
 }

Это что касается экземплярных членов. Что касается статических событий, то переменная тоже является статической и блокировка захватывается на типе вида typeof(XXX), где XXX — это имя класса, в котором объявлено статическое событие. Язык C# 2.0 не дает никаких гарантий по поводу того, что используется для захвата блокировок. Он говорит лишь о том, что для блокировки экземплярных событий используется единственный объект, связанный с текущим экземпляром, а для блокировки статических событий — единственный объект, связанный с текущим классом. (Обратите внимание, что это справедливо лишь для событий, объявленных в классах, но не в структурах. Существуют проблемы с блокировками событий в структурах; и на практике я не помню ни одного примера структуры, в которой было объявлено событие.) Но ничего из этого не является таким уж полезным, как вы могли бы подумать, подробности см. в секции о многопоточности.

Итак, что происходит, когда вы в коде ссылаетесь на MyEvent? Внутри тела самого типа (включая вложенные типы) компилятор генерирует код, который ссылается на переменную делегата (_myEvent в примере выше). Во всех остальных контекстах компилятор генерирует код, который ссылается на событие.

Какой в этом смысл?

Теперь, когда мы знаем и о делегатах, и о событиях, возникает вполне закономерный вопрос: зачем в языке нужны и те, и другие? Ответ — из-за инкапсуляции. Предположим, что в некоем вымышленном C#/.NET событий не существует. Как тогда сторонний класс может подписаться на событие? Три варианта:

  1. Публичная переменная (поле) с типом делегата.
  2. Приватная переменная (поле) с типом делегата с оболочкой в виде публичного свойства.
  3. Приватная переменная (поле) с типом делегата с публичными методами AddXXXHandler и RemoveXXXHandler.

Вариант №1 ужасен — мы, как правило, ненавидим публичные поля. Вариант №2 немного лучше, но позволяет подписчикам эффективно переопределять (override) один одного — будет слишком лёгким написать выражение someInstance.MyEvent = eventHandler;, в результате которого все существующие обработчики будут заменены на eventHandler, вместо того, чтобы добавить к существующим eventHandler. Плюс к этому, вам всё равно нужно явно прописывать код свойств.

Вариант №3 — это, собственно, то, что и предоставляют вам события, но с гарантированным соглашением (генерируемым компилятором и резервируемым специальными флагами в IL) и со «свободной» реализацией, если только вы будете счастливы от семантики field-like событий. Подписка и отписка к/от событий инкапсулируется без предоставления произвольного доступа к списку обработчиков события, благодаря чему удаётся упростить код для операций подписки и отписки.

Потокобезопасные события

(Примечание: с выходом C# 4 этот раздел несколько устарел. От переводчика: подробнее см. раздел «От переводчика»)

Ранее мы коснулись блокировки (locking), которая происходит в field-like событиях во время операций add и remove, которые автоматически реализуются компилятором. Это делается ради предоставления некой гарантии потокобезопасности (thread safety). К сожалению, оно не столь уж и полезное. Прежде всего, даже в C# 2.0 спецификации позволяют установить блокировку на ссылку к this-объекту или на сам тип в статических событиях. Это противоречит принципам блокировки на приватных ссылках, что необходимо для недопущения взаимных блокировок (deadlock).

По иронии, вторая проблема является полной противоположностью первой — из-за того, что в C# 2.0 вы не можете гарантировать, какая блокировка будет использована, вы сами тоже не можете её использовать, когда вызываете событие, чтобы удостовериться, что вы видите самое новое (актуальное) значение в данном потоке. Вы можете применять блокировку на что-либо другое или воспользоваться специальными методами, работающими с барьерами памяти, но всё это оставляет неприятное послевкусие1↓.

Если вы хотите, чтобы ваш код был истинно потокобезопасным, таким, что когда вы вызываете событие, то всегда используете наиболее актуальное значение переменной делегата, а также таким, что вы можете убедиться, что операции add/remove не мешают одна другой, то для достижения такой «железобетонной» потокобезопасности вам необходимо писать тело операций add/remove самому. Пример ниже:

/// <summary>
/// Переменная типа делегата SomeEventHandler, являющаяся «фундаментом» события.
/// </summary>
 SomeEventHandler someEvent;

/// <summary>
/// Примитив блокировки для доступа к событию SomeEvent.
/// </summary>
readonly object someEventLock = new object();

/// <summary>
/// Само событие
/// </summary>
public event SomeEventHandler SomeEvent
 {
     add
     {
         lock (someEventLock)
         {
             someEvent += value;
         }
     }
     remove
     {
         lock (someEventLock)
         {
             someEvent -= value;
         }
     }
 }

/// <summary>
/// Вызов события SomeEvent
/// </summary>
protected virtual void OnSomeEvent(EventArgs e)
 {
     SomeEventHandler handler;
     lock (someEventLock)
     {
         handler = someEvent;
     }
     if (handler != null)
     {
         handler (this, e);
     }
 }

Вы можете использовать единую блокировку для всех ваших событий, и даже использовать эту блокировку для чего-либо ещё — это уже зависит от конкретной ситуации. Обратите внимание, что вам нужно «записать» текущее значение в локальную переменную внутри блокировки (для того, чтобы получить самое актуальное значение), а затем проверить это значение на null и выполнить вне блокировки: удерживание блокировки во время вызова события является очень плохой идеей, легко приводящей к взаимоблокировке. Чтобы объяснить это, представьте, что некий обработчик событий должен дождаться, пока другой поток выполнит какую-то свою работу, и если во время неё этот другой поток вызовет операцию add/remove для вашего события, то вы получите взаимоблокировку.

Вышеприведённый код работает корректно потому, что как только локальной переменной handler будет присвоено значение someEvent, то значение handler уже не изменится даже в том случае, если изменится сам someEvent. Если все обработчики событий отпишутся от события, то список вызовов будет пуст, someEvent станет null, но handler будет хранить своё значение, которое будет таким, каковым оно было на момент присвоения. На самом деле, экземпляры делегатов являются неизменяемыми (immutable), поэтому любые подписчики, подписавшиеся между присваиванием (handler = someEvent) и вызовом события (handler (this, e);), будут проигнорированы.

Кроме этого, нужно определить, нужна ли вам вообще потокобезопасность. Собираетесь ли вы добавлять и удалять обработчики событий из других потоков? Собираетесь ли вы вызывать события из разных потоков? Если вы полностью контролируете своё приложение, то очень правильным и легким в реализации ответом будет «нет». Если же вы пишете библиотеку классов, то, скорее всего, обеспечение потокопезопасности пригодится. Если же вам определённо не нужна потокобезопасность, то хорошей идеей будет самостоятельно реализовать тело операций add/remove, чтобы они явно не использовали блокировки; ведь, как мы помним, C# при автогенерации этих операций использует «свой» «неправильный» механизм блокировки. В этом случае ваша задача очень проста. Ниже — пример вышеприведённого кода, но без потокобезопасности.

/// <summary>
/// Переменная типа делегата SomeEventHandler, являющаяся «фундаментом» события.
/// </summary>
 SomeEventHandler someEvent;

/// <summary>
/// Само событие
/// </summary>
public event SomeEventHandler SomeEvent
 {
     add
     {
         someEvent += value;
     }
     remove
     {
         someEvent -= value;
     }
 }

/// <summary>
/// Вызов события SomeEvent
/// </summary>
protected virtual void OnSomeEvent(EventArgs e)
 {
     if (someEvent != null)
     {
         someEvent (this, e);
     }
 }

Если на момент вызова метода OnSomeEvent переменная делегата someEvent не содержит списка экземпляров делегатов (вследствие того, что они не были добавлены через метод add или же были удалены через метод remove), то значение этой переменной будет null, и чтобы избежать её вызова с таким значением, и была добавлена проверка на null. Подобную ситуацию можно решить и другим путём. Можно создать экземпляр делегата-заглушку (no-op), который будет привязан к переменной «по умолчанию» и не будет удаляться. В этом случае в методе OnSomeEvent нужно просто получить и вызвать значение переменной делегата. Если «реальные» экземпляры делегатов так и не были добавлены, то будет просто-напросто вызвана заглушка.

Экземпляры делегатов: другие методы

Ранее в статье я показал, что вызов someDelegate(10) — это просто сокращение для вызова someDelegate.Invoke(10). Кроме Invoke, типы делегатов имеют и асинхронное поведение посредством пары методов BeginInvoke/EndInvoke. В CLI они являются опциональными, но в C# они всегда есть. Они придерживаются той же модели асинхронного выполнения, что и остальная часть .NET, позволяя указывать обработчик обратного вызова (callback handler) вместе с объектом, хранящим информацию о состоянии. В результате асинхронного вызова код выполняется в потоках, созданных системой и находящихся в пуле потоков (thread-pool) .NET.

В первом примере, представленном ниже, нет обратных вызовов, здесь просто используются BeginInvoke и EndInvoke в одном потоке. Такой шаблон кода иногда полезен, когда один поток используется для синхронных в целом операций, но вместе с тем содержит элементы, которые могут быть выполнены параллельно. Ради простоты кода все методы в примере статические, но вы, конечно же, можете использовать «асинхронные» делегаты вместе с экземплярными методы, и на практике это будет происходить даже чаще. Метод EndInvoke возвращает то значение, которое возвращается в результате вызова экземпляра делегата. Если во время вызова экземпляра делегата возникнет исключение, то это исключение выбросит и EndInvoke.

using System;
using System.Threading;

delegate int SampleDelegate(string data);

class AsyncDelegateExample1
{
     static void Main()
     {
          SampleDelegate counter = new SampleDelegate(CountCharacters);
          SampleDelegate parser = new SampleDelegate(Parse);
		
          IAsyncResult counterResult = counter.BeginInvoke("hello", null, null);
          IAsyncResult parserResult = parser.BeginInvoke("10", null, null);
          Console.WriteLine("Основной поток с  ID = {0} продолжает выполняться",
               Thread.CurrentThread.ManagedThreadId);

          Console.WriteLine("Счётчик вернул '{0}'", counter.EndInvoke(counterResult));
          Console.WriteLine("Парсер вернул '{0}'", parser.EndInvoke(parserResult));

          Console.WriteLine("Основной поток с  ID = {0} завершился",
               Thread.CurrentThread.ManagedThreadId);
     }

     static int CountCharacters(string text)
     {
          Thread.Sleep(2000);
          Console.WriteLine("Подсчёт символов в строке '{0}' в потоке с ID = {1}",
               text, Thread.CurrentThread.ManagedThreadId);
          return text.Length;
     }

     static int Parse(string text)
     {
          Thread.Sleep(100);
          Console.WriteLine("Парсинг строки '{0}' в потоке с ID = {1}",
               text, Thread.CurrentThread.ManagedThreadId);
          return int.Parse(text);
     }
}

Вызовы метода Thread.Sleep вставлены только ради того, чтобы продемонстрировать, что методы CountCharacters и Parse действительно выполняются параллельно с основным потоком. Сон в CountCharacters в 2 секунды достаточно большой для того, чтобы принудить пул потоков выполнить задачи в других потоках — пул потоков сериализует запросы, которые не требуют много времени для выполнения, чтобы таким образом избежать чрезмерного создания новых потоков (создание новых потоков является относительно ресурсоёмкой операцией). «Усыпляя» поток на долгое время, мы таким образом имитируем «тяжелую», требующую много времени на выполнение задачу. А вот и вывод нашей программы:

	Основной поток с  ID = 9 продолжает выполняться
	Парсинг строки '10' в потоке с ID = 10
	Подсчёт символов в строке 'hello' в потоке с ID = 6
	Счётчик вернул '5'
	Парсер вернул '10'
	Основной поток с  ID = 9 завершился

Если процесс выполнения делегата в стороннем потоке ещё не завершился, то вызов метода EndInvoke в основном потоке будет иметь схожий эффект с вызовом Thread.Join для вручную создаваемых потоков — основной поток будет ждать, пока завершится задача в стороннем потоке. Значение IAsyncResult, которое возвращается методом BeginInvoke и передаётся на вход EndInvoke, может использоваться для передачи состояния из BeginInvoke (через последний параметр — Object state), однако необходимость в такой передаче при использовании делегатов возникает не часто.

Программный код, приведённый выше, довольно прост, но недостаточно эффективный в сравнении с моделью обратных вызовов (callback model), вызываемых после окончания выполнения делегата. Как правило, именно в методе обратного вызова (callback) происходит вызов метода EndInvoke, возвращающего результат выполнения делегата. Хотя чисто теоретически этот вызов всё так же блокирует основной поток (как в вышеприведённом примере), но на практике блокировки потока не произойдёт, так как метод обратного вызова выполняется лишь тогда, когда делегат выполнил свою задачу. Метод обратного вызова может использовать состояние с некими дополнительными данными, которое будет передано ему из метода BeginInvoke. Пример ниже использует те же самые делегаты для парсинга и подсчёта количества символов в строке, что и в примере выше, но на сей раз с методом обратного вызова, в котором результат выводится на консоль. Параметр state типа Object используется для передачи информации о том, в каком формате выводить результат на консоль, и благодаря этому мы можем использовать один метод обратного вызова DisplayResult для обработки обоих асинхронных вызовов делегатов. Обратите внимание на приведение типа IAsyncResult к типу AsyncResult: значение, принимаемое методом обратного вызова, всегда является экземпляром AsyncResult, и через него мы можем получить оригинальный экземпляр делегата, результат из которого получим, используя EndInvoke. Здесь немного странным является то, что тип AsyncResult объявлен в пространстве имён System.Runtime.Remoting.Messaging (которое вам надо подключить), тогда как все остальные типы объявлены в System или System.Threading.

using System;
using System.Threading;
using System.Runtime.Remoting.Messaging;

delegate int SampleDelegate(string data);

class AsyncDelegateExample2
{
     static void Main()
     {
          SampleDelegate counter = new SampleDelegate(CountCharacters);
          SampleDelegate parser = new SampleDelegate(Parse);
	
          AsyncCallback callback = new AsyncCallback(DisplayResult);

          counter.BeginInvoke("hello", callback, "Счётчик вернул '{0}' в потоке с ID = {1}");
          parser.BeginInvoke("10", callback, "Парсер вернул '{0}' в потоке с ID = {1}");

          Console.WriteLine("Основной поток с  ID = {0} продолжает выполняться", 
               Thread.CurrentThread.ManagedThreadId);

          Thread.Sleep(3000);
          Console.WriteLine("Основной поток с  ID = {0} завершился", 
               Thread.CurrentThread.ManagedThreadId); 
     }

     static void DisplayResult(IAsyncResult result)
     {
          string format = (string)result.AsyncState;
          AsyncResult delegateResult = (AsyncResult)result;
          SampleDelegate delegateInstance = 
               (SampleDelegate)delegateResult.AsyncDelegate;        
          Int32 methodResult = delegateInstance.EndInvoke(result);
          Console.WriteLine(format, methodResult, Thread.CurrentThread.ManagedThreadId);
     }

     static int CountCharacters(string text)
     {
          Thread.Sleep(2000);
          Console.WriteLine("Подсчёт символов в строке '{0}' в потоке с ID = {1}", 
               text, Thread.CurrentThread.ManagedThreadId);
          return text.Length;
     }

     static int Parse(string text)
     {
          Thread.Sleep(100);
          Console.WriteLine("Парсинг строки '{0}' в потоке с ID = {1}", 
               text, Thread.CurrentThread.ManagedThreadId);
          return int.Parse(text);
     }
}

На этот раз почти вся работа выполняется в потоках из пула потоков. Основной поток просто инициирует асинхронные задачи и «засыпает» до тех пор, пока все эти задачи не выполнятся. Все потоки из пула потоков являются фоновыми (background) потоками, которые не могут «удерживать» приложение (т.е. они не могут предотвратить его закрытие), и чтобы приложение не завершилось до того, как в фоновых потоках завершится работа делегатов, мы и применили вызов Thread.Sleep(3000) в основном потоке — можно надеяться, что 3-х секунд хватит для выполнения и завершения делегатов. Вы можете это проверить, закомментировав строчку Thread.Sleep(3000) — программа завершится почти мгновенно после запуска.

Результат работы нашей программы представлен ниже. Обратите внимание на порядок вывода результатов на консоль — результат работы парсера появился до результата работы счётчика, так как среда не гарантирует сохранение порядка при вызове EndInvoke. В предыдущем примере парсинг завершался значительно быстрее (100 мс), чем счётчик (2 сек), однако основной поток ждал их обоих, чтобы вывести прежде всего результат счётчика, а лишь потом парсера.

	Основной поток с  ID = 9 продолжает выполняться
	Парсинг строки '10' в потоке с ID = 11
	Парсер вернул '10' в потоке с ID = 11
	Подсчёт символов в строке 'hello' в потоке с ID = 10
	Счётчик вернул '5' в потоке с ID = 10
	Основной поток с  ID = 9 завершился

Помните, что при использовании данной асинхронной модели вы должны вызвать EndInvoke; это нужно для того, чтобы гарантировать отсутствие утечек памяти и обработчиков. В некоторых случаях при отсутствии EndInvoke утечек может и не быть, но не надо на это надеяться. Для более детальной информации вы можете обратиться к моей статье «Multi-threading in .NET: Introduction and suggestions», посвящённой многопоточности, в частности, к разделу «The Thread Pool and Asynchronous Methods».

Заключение

Делегаты предоставляют простой способ вызова методов с учётом экземпляров объектов и с возможностью передачи неких данных. Они являются основой для событий, которые сами по себе являются эффективным механизмом для добавления и удаления обработчиков, которые будут вызваны в соответствующее время.

Примечания

1. Прим. перев. Признаюсь, объяснение Джона Скита довольно невнятное и скомканное. Чтобы детально разобраться, почему блокировка на текущем экземпляре и на типе — это плохо, и почему следует вводить отдельное приватное поле только для чтения, я крайне советую воспользоваться книгой «CLR via C#» за авторством Джеффри Рихтера, которая уже пережила 4 издания. Если говорить о втором издании 2006 года выпуска, переведённое в 2007 на русский язык, то информация о данной проблеме расположена в «Часть 5. Средства CLR» – «Глава 24. Синхронизация потоков» — раздел «Почему же «отличная» идея оказалась такой неудачной».

2. Прим. перев. Этот и следующий примеры кода, как и их вывод на консоль, были немного изменены по сравнению с оригинальными примерами от Дж. Скита. Помимо перевода, я добавил вывод идентификаторов потоков, чтобы было чётко видно, какой код в каком потоке исполняется.

От переводчика

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

Алексей Дубовцев. Делегаты и события (RSDN).
Хотя статья не новая (датируется 2006 годом) и рассматривает лишь основы делегатов и событий, уровень «рассмотрения основ» намного глубже: здесь и более пристальное рассмотрение типа MulticastDelegate, особенно в плане комбинированных делегатов, и описание принципа работы на уровне MSIL, и описание класса EventHandlerList, и многое другое. В общем, если вы хотите рассмотреть основы делегатов и событий на более глубоком уровне, то данная статья определённо для вас.

coffeecupwinner. События .NET в деталях.
Надеюсь, вы обратили внимание на заметку об устаревшем материале вначале раздела «Потокобезопасные события»? В C# 4 внутренняя реализация field-like событий, которую критикуют Скит и Рихтер, была полностью переработана: теперь потокобезопасность реализуется через Interlocked.CompareExchange, безо всяких блокировок. Об этом, в числе прочего, и рассказывает эта статья. Вообще, статья скрупулёзно рассматривает только события, однако на намного более глубоком уровне, чем у Джона Скита.

Daniel Grunwald. andreycha. Слабые события в C#.
Когда говорят о преимуществах между C#/.NET с одной стороны и C++ с другой, то в преимущество первым помимо прочего записывают автоматическую сборку мусора, которая ликвидирует утечки памяти как класс ошибок. Однако не всё так радужно: события могут приводить к утечкам памяти, и именно решению этих проблем посвящена данная очень детальная статья.

rroyter. Зачем нужны делегаты в C#?
Как я упоминал во вступлении, в начинающих разработчиков недопонимания с делегатами связаны с отсутствием видимых причин, требующих их использования. Данная статья очень доходчиво демонстрирует некоторые ситуации, где делегаты будут крайне уместны. Кроме того, здесь продемонстрированы новые возможности делегатов, введённые в C# 2-й и 3-й версий.