Делегаты

Знакомство с делегатами

В .NET функции обратного вызова играют не менее важную роль, чем при неуправляемом программировании. Однако данная платформа предлагает в распоряжение разработчика делегаты (delegates) - механизм, безопасный по отношению к типам.

internal delegate void Feedback(Int32 value);

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

Обратный вызов статических методов

При передаче в метод параметром создаётся делегат, который выступает оболочкой для метода обратного вызова, позволяя выполнить его вызов. Делается это через конструктор делегата Feedback. Возвращённая оператором new ссылка передаётся в качестве аргумента метода, который будет вызывать статический метод.

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

При создании делегата компилятор убеждается в том, что сигнатуры передаваемых в конструктор методов совместимы с сигнатурой делегата. В противном случае компилятор выбросит ошибку.

Как C#, так и CLR поддерживают ковариантность и контравариантность ссылочных типов при привязке метода к делегату. Ковариантность означает, что метод может возвращать тип, производный от типа, возвращаемого делегатом. Контравариантность означает, что метод может принимать тип, являющийся базовым для типа параметра делегата (прим. Всё так же, как и в обобщениях). Стоит заметить, что ковариантность и контравариантность поддерживаются только для ссылочных типов, потому что при использовании значимых типов или void структура памяти меняется, когда для ссылочных типов всегда остаётся указатель. Благо, подобные попытки будут пресекаться компилятором.

Обратный вызов экземплярных методов

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

Тонкости использования делегатов

На первый взгляд работать с делегатами легко: определяется делегат с помощью ключевого слова delegate, с помощью оператора new создаются экземпляры, а для обратного вызова служит знакомый синтаксис (но используется не имя метода, а переменная, указывающая на делегат).

На самом деле всё обстоит сложнее, потому что часть сложности скрывается за работой компиляторов и CLR. По факту определение делегата:

internal delegate void Feedback(Int32 value);

разворачивается компилятором в полное определение класса (все типы делегатов являются производными от System.MulticastDelegate):

internal class Feedback : System.MulticastDelegate
{
  // Конструктор
  public Feedback(Object object, IntPtr method);

  // Метод, прототип которого задан в исходном тексте
  public virtual void Invoke(Int32 value);

  // Методы, обеспечивающие асинхронный обратный вызов
  public virtual IAsyncResult BeginInvoke(Int32 value, AsyncCallback callback, Object object);
  public virtual void EndInvoke(IAsyncResult result);
}

В основном используются только конструктор и метод Invoke(). Методы BeginInvoke() и EndInvoke() относятся к модели асинхронного программирования .NET Framework, которая признана устаревшей и заменена асинхронными операциями.

Класс System.MulticastDelegate является производным от System.Delegate, который в свою очередь наследуется от System.Object. Два класса делегатов появились исторически. Однако иногда приходится работать и с делегатами, реализующими методы System.Delegate.Combine() и System.Delegate.Remove(). Этим методам всегда можно передавать определяемый разработчиком делегат, так как они определены в базовом типе.

Класс делегата может быть закрытым и открытым. Ещё следует помнить, что делегаты можно объявлять как внутри класса, так и в глобальной области видимости. Ниже описаны три самых важных поля MulticastDelegate:

ПолеТипОписание
_targetSystem.ObjectПоле указывает на значение, которое следует передать параметру this экземплярного метода (для статических методов будет null)
_methodPtrSystem.IntPtrВнутреннее целочисленное значение, используемое CLR для идентификации метода обратного вызова
_invocationListSystem.ObjectОбычно null, но может ссылаться на массив делегатов при построении из них цепочки

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

image

Вызывать делегат можно как через указание имени делегата с передачей аргументов, так и через явный вызов метода Invoke(). Вызывая этот метод, он использует закрытые поля _target и _methodPtr для вызова желаемого метода на заданном объекте.

Обратный вызов нескольких методов (цепочки делегатов)

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

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

image

image

image

При передаче цепочки делегатов в качестве аргумента, все отдельные делегаты из цепочки будут вызваны последовательно. Если делегаты возвращают некоторое значение, то вернётся результат вызова последнего делегата.

Поддержка цепочек делегатов C#

Компилятор C# автоматически предоставляет перегруженные версии операторов += и -= для создания цепочки делегатов. Эти перегруженные операторы вызывают всё те же методы Delegate.Combine() и Delegate.Remove(), так что IL-код получается идентичным, отличается лишь читаемости.

Дополнительные средства управления цепочками делегатов

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

Для вызова в явном виде отдельного делегата из цепочки можно использовать метод MulticastDelegate.GetInvokationList(), который возвращает массив ссылок на отдельные делегаты, или ссылку на сам делегат, если поле _invocationList равно null.

Обобщённые делегаты

Когда .NET Framework только начинал разрабатываться, в Microsoft ввели понятие делегатов. По мере добавления в FCL классов появлялись и новые типы делегатов. Однако, многие из них были похожи: их сигнатуры были одинаковы, делегаты отличались только именами. По сути, все их можно было свести к одному типу.

Примерно по этому пути и пошла современная версия фреймворка после введения обобщений. Появилось несколько типов обобщённых делегатов:

public delegate void Action(); // Этот делегат не обобщенный
public delegate void Action<T>(T obj);
public delegate void Action<T1, T2>(T1 arg1, T2 arg2);
public delegate void Action<T1, T2, T3>(T1 arg1, T2 arg2, T3 arg3);
...
public delegate void Action<T1, ..., T16>(T1 arg1, ..., T16 arg16);

public delegate TResult Func<TResult>();
public delegate TResult Func<T, TResult>(T arg);
public delegate TResult Func<T1, T2, TResult>(T1 arg1, T2 arg2);
public delegate TResult Func<T1, T2, T3, TResult>(T1 arg1, T2 arg2, T3 arg3);
...
public delegate TResult Func<T1,..., T16, TResult>(T1 arg1, ..., T16 arg16);

Action() является делегатом, который не возвращает значений, а Func() - возвращает (прим. Частным случаем Func() является Predicate(), который возвращает булево значение). В .NET имеется по 17 делегатов каждого типа. Для определения большего числа параметров придётся определять свой собственный делегат, что маловероятно.

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

Упрощённый синтаксис работы с делегатами

Многие программисты не любят делегаты из-за достаточно сложного синтаксиса. Например, следующая строка реализует добавление делегата при щелчке по кнопке:

button1.Click += new EventHandler(button_Click);

Данный делегат нужен среду CLR, чтобы соблюсти безопасность типов, однако программисты хотели бы видеть что-то подобное:

button1.Click += button_Click;

К счастью, C# предоставляет подобный функционал, пусть он и является всего лишь синтаксическим сахаром.

Упрощение 1: не создаём объект делегата

Компилятор C# разрешает не указывать делегат, а передавать в качестве параметра там, где ожидается делегат, имя метода.

internal sealed class AClass
{
  public static void CallbackWithoutNewingADelegateObject()
  {
    ThreadPool.QueueUserWorkItem(SomeAsyncTask, 5);
  }

  private static void SomeAsyncTask(Object o)
  {
    Console.WriteLine(o);
  }
}

Упрощение 2: не определяем метод обратного вызова

C# позволяет подставить реализацию метода обратного вызова непосредственно в код, а не в отдельный метод:

internal sealed class AClass
{
  public static void CallbackWithoutNewingADelegateObject()
  {
    ThreadPool.QueueUserWorkItem(obj => Console.WriteLine(obj), 5);
  }
}

Формально, в C# подобный фрагмент кода, который передаётся в качестве делегата, называется лямбда-выражением (lambda expression) и распознаётся по наличию оператора =>. Обнаружив лямбда-выражение, компилятор автоматически определяет в классе новый закрытый метод. Этот метод называется анонимной функцией (anonymous function), так как имя, создаваемое компилятором, неизвестно. Но имя можно узнать, воспользовавшись ILDasm.exe.

Компилятор выбирает имя метода, начинающегося с <, так как идентификаторы в C# не могут содержать этот символ, что гарантирует невозможность выбора программистом такого же имени. Кроме этого, компилятор применяет к метода специальный атрибут, дающий возможность понять, что код сгенерирован, а не написан разработчиком.

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

Лямбда выражение должно соответствовать сигнатуре делегата. Имена аргументов, которые следует передать выражению, располагаются слева от оператора =>. При этом следует придерживаться следующих правил (подробнее в документацииopen in new window):

// Если делегат не содержит аргументов, используйте круглые скобки
Func<String> f = () => "Jeff";

// Если аргумент у делегата всего один, круглые скобки можно опустить
Func<Int32, String> f6 = n => n.ToString();

// Для аргументов ref/out нужно в явном виде указывать ref/out и тип
Bar b = (out Int32 n) => n = 5;

// Для делегатов с одним и более аргументами можно в явном виде указать типы
Func<Int32, String> f2 = (Int32 n) => n.ToString();
Func<Int32, Int32, String> f3 = (Int32 n1, Int32 n2) => (n1 + n2).ToString();

// Компилятор может самостоятельно определить типы для делегатов с одним и более аргументами
Func<Int32, String> f4 = (n) => n.ToString();
Func<Int32, Int32, String> f5 = (n1, n2) => (n1 + n2).ToString();

Чтобы вставить в тело лямбда-выражения несколько инструкций, стоит заключить их в фигурные скобки.

Основное преимущество лямбда-выражений в повышении читаемости кода. Если ссылка на один и тот же код требуется в разных местах кода, то лучшим решением будет объявить метод и передать его в качестве делегата. Иначе - лямбда-выражение является прекрасным решением.

Упрощение 3: не создаём обёртку для локальных переменных для передачи их методу обратного вызова

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

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

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

Делегаты и отражение

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