Делегат это тип, представляющий сигнатуру метода. Переменная типа делегата хранит ссылку на метод с этой сигнатурой, а вызов переменной вызывает метод. Так C# обращается с функциями как со значениями: их можно хранить, передавать в другие методы и вызывать позже.
Вывод:
40
70
68.00
delegate decimal PriceRule(decimal price); объявляет новый тип так же, как это делает class. Ему подходит любой метод, который принимает один decimal и возвращает decimal. Переменная rule по очереди указывала на три разных метода, и каждый вызов выполнял тот, который она хранила.
Объявление, создание и вызов
Объявление типа делегата выглядит как сигнатура метода с ключевым словом delegate впереди. Обычно оно находится на уровне пространства имён, рядом с классами, или вложено в класс.
Есть четыре способа создать экземпляр делегата:
PriceRule a = HalfPrice; // method group (most common)
PriceRule b = new PriceRule(HalfPrice); // explicit constructor, same thing
PriceRule c = p => p - 5m; // lambda expression
PriceRule d = delegate (decimal p) { return p; }; // anonymous method (C# 2 syntax)
И два одинаковых способа его вызвать: rule(80m) и rule.Invoke(80m). Компилятор проверяет сигнатуру при присваивании, поэтому неподходящий метод это ошибка компиляции, а не сюрприз во время выполнения.
Делегат может указывать и на экземплярный метод. Тогда он запоминает и метод, и объект, на котором его нужно вызвать:
Вывод:
Ana got: Your order shipped
target: Mailbox, method: Receive
Target это объект, на котором делегат вызовет метод (null для статического метода), а Method описывает сам метод. Удерживая цель, делегат также сохраняет этот объект живым, что важно для событий.
Делегаты как обратные вызовы
Главная задача делегата позволить методу вызвать код, выбранный вызывающей стороной. Метод определяет, когда что-то происходит, а вызывающий код определяет, что именно:
Вывод:
[## ] 25%
[##### ] 50%
[####### ] 75%
[##########] 100%
import finished
ImportRows ничего не знает о консолях и полосах прогресса. Один и тот же метод может управлять полосой прогресса, строкой лога или обновлением интерфейса в зависимости от того, что передал вызывающий код. List.Sort(Comparison<T>), Array.Find(Predicate<T>), Task.Run(Action) и каждый оператор LINQ следуют этому шаблону.
Многоадресные делегаты: += и -=
Делегат может хранить больше одного метода. += добавляет метод в конец его списка вызова, -= удаляет его, а вызов делегата вызывает все методы по порядку:
Вывод:
email sent for A-1001
stock updated for A-1001
warehouse notified for A-1001
3 handlers
email sent for A-1002
warehouse notified for A-1002
На практике важны три детали многоадресных делегатов:
- Возвращаемые значения: если тип делегата возвращает значение, вызов многоадресного делегата возвращает результат только последнего метода. Чтобы собрать все результаты, пройдите по
GetInvocationList()и вызовите каждый сами. - Исключения: если один метод выбрасывает исключение, остальные методы не выполняются, а исключение доходит до вызывающего кода.
- Неизменяемость: делегаты неизменяемы.
+=создаёт новый делегат с более длинным списком и присваивает его обратно переменной, поэтому удаление через-=на копии не затрагивает оригинал.
Удаление лямбды через -= работает, только если передать тот же самый экземпляр делегата. Одна и та же лямбда, написанная дважды, создаёт два разных делегата, поэтому handlers -= id => Log(id); ничего не удаляет. Сохраняйте лямбду в переменной, если собираетесь отписать её.
Делегаты null и ?.Invoke
Переменная делегата без назначенного метода равна null, и удаление последнего метода через -= тоже оставляет null. Вызов null выбрасывает NullReferenceException:
Вывод:
no handler, no crash
saved draft.txt
NullReferenceException
?.Invoke это стандартный способ вызвать необязательный обратный вызов. Оператор ?. нельзя поставить прямо перед скобками (onSaved?("x") недопустимый синтаксис), поэтому шаблон вызывает метод делегата Invoke по имени, что делает то же самое, что и сокращённый вызов.
Делегаты, Func, Action и события
Объявлять тип делегата для каждой сигнатуры было бы утомительно, поэтому .NET поставляет обобщённые:
| Встроенный тип | Эквивалентное собственное объявление |
|---|---|
Action | delegate void Action(); |
Action<T> | delegate void Action<T>(T arg); |
Func<TResult> | delegate TResult Func<TResult>(); |
Func<T, TResult> | delegate TResult Func<T, TResult>(T arg); |
Predicate<T> | delegate bool Predicate<T>(T obj); |
PriceRule из первого примера имеет ту же форму, что и Func<decimal, decimal>, но это разные типы, и они не преобразуются друг в друга. Даже два встроенных типа с одинаковыми сигнатурами не преобразуются:
Func<int, bool> isPositive = x => x > 0;
Predicate<int> p = isPositive;
// error CS0029: Cannot implicitly convert type 'System.Func<int, bool>' to 'System.Predicate<int>'
Predicate<int> ok = new Predicate<int>(isPositive); // wrap it explicitly
Predicate<int> ok2 = x => isPositive(x); // or with a lambda
Лямбда преобразуется в любой совместимый тип делегата, поэтому API обычно принимают лямбды, и вопрос не возникает. Обобщённое семейство и случаи, когда собственное объявление всё ещё оправдано, описаны на странице о Func и Action.
События построены непосредственно на делегатах. Поле event это многоадресный делегат, к которому внешний код может применять только += и -=, но не вызывать и не перезаписывать его. Список подписчиков из примера с многоадресным делегатом выше это ровно то, чем событие управляет за вас, с этой дополнительной защитой. Событиям посвящена отдельная страница.
Часто задаваемые вопросы
Что такое делегат в C#?
Делегат это типобезопасная ссылка на метод. Вы объявляете тип делегата с сигнатурой, например delegate decimal PriceRule(decimal price);, и любой метод с такой сигнатурой (статический, экземплярный или лямбда) можно сохранить в переменной этого типа и позже вызвать через неё. С помощью делегатов C# передаёт функции как значения.
Чем делегат отличается от Func в C#?
Func<T, TResult> и Action<T> это типы делегатов, которые .NET уже объявил за вас, обобщённые по типам параметров и возвращаемого значения. Собственное объявление delegate даёт типу имя и имена параметров, которые документируют его роль, и поддерживает параметры ref, out и in. Для большинства кода достаточно Func и Action.
Что такое многоадресный делегат в C#?
Делегат, который хранит несколько методов. += добавляет метод в его список вызова, а -= удаляет; вызов делегата вызывает их все по порядку. Если делегат возвращает значение, вызывающий код получает результат только последнего метода, а исключение в одном методе останавливает выполнение остальных.
Как безопасно вызвать делегат, который может быть null?
Используйте null-условный оператор: onProgress?.Invoke(50);. Переменная делегата без назначенных методов равна null, и прямой вызов выбрасывает NullReferenceException. ?.Invoke читает переменную один раз, поэтому он безопасен, даже если другой поток в тот же момент удаляет последний обработчик.
Когда объявлять собственный тип делегата вместо Func или Action?
Когда сигнатуре нужны параметры ref, out или in (Func и Action не могут их выразить), когда именованный тип делает открытый API понятнее (delegate bool Validator(string input)) или когда нужно, чтобы имена параметров показывались в IntelliSense. В остальных случаях предпочитайте Func и Action, которые узнаёт любой разработчик .NET.