Изображение для статьи создано при помощи нейросети Kandinsky
Делегаты являются одним из ключевых инструментов в языке программирования C#, позволяя передавать методы как параметры, хранить ссылки на методы и вызывать методы динамически. Давайте детальнее рассмотрим, что такое делегаты, посмотрим на их ключевые особенности, ограничения и примеры использования в коде.
В конце этой статьи вы найдёте ссылку на архив с готовым примером проекта для среды Microsoft Visual Studio, который содержит демонстрацию всех основных особенностей делегатов, которые мы разберём в рамках статьи.
Что такое делегат? Примеры объявления делегатов в C#
Делегат в C# - это тип данных, который представляет собой ссылку на метод с заданным списком параметров и типом возвращаемого значения. Он позволяет передавать методы как параметры другим методам, сохранять ссылки на методы и вызывать методы динамически во время выполнения программы.
При создании экземпляра делегата этот экземпляр можно связать с любым методом, сигнатура и тип возвращаемого значения которого совместимы с сигнатурой и типом возвращаемого значения самого делегата.
Давайте рассмотрим небольшой пример объявления делегата:
public delegate void InputStringParamDelegate(string param);
В данном случае мы объявили делегат с именем InputStringParamDelegate, которому можно впоследствии установить ссылку на какой-то метод, у которого будет тип возвращаемого значения void и который принимает единственный параметр с типом string. Обратите внимание на использование ключевого слова delegate в объявлении делегата.
Представим теперь, что у нас есть два метода PrintRegularMessage и PrintErrorMessage:
public static void PrintRegularMessage(string message) {
Console.WriteLine(string.Format("Обычное сообщение: {0}", message));
}
public static void PrintErrorMessage(string errorMessage) {
Console.WriteLine(string.Format("Сообщение об ошибке: {0}", errorMessage));
}
Как видим, первый предназначен для вывода обычного сообщения на экран консольного приложения C#, второй - для вывода сообщения об ошибке.
Также у обоих методов тип возвращаемого значения void, и оба метода принимают на вход единственный параметр с типом string - что полностью совместимо с типом нашего делегата InputStringParamDelegate.
Теперь посмотрим, как можно использовать делегат для вызова указанных методов:
// Объявили переменную myDelegate с типом делегата InputStringParamDelegate
InputStringParamDelegate myDelegate;
// Установили переменной ссылку на метод PrintRegularMessage
myDelegate = PrintRegularMessage;
// Вызвали делегат, передав ему в параметре строку "Моё обычное сообщение"
myDelegate("Моё обычное сообщение");
// Теперь установили переменной ссылку на другой метод PrintErrorMessage
myDelegate = PrintErrorMessage;
// Снова вызываем делегат, на этот раз с другим сообщением
myDelegate("Моё сообщение об ошибке");
Если мы запустим этот небольшой пример, оформив его в виде консольного приложения на C#, то мы увидим в выводе консоли следующие два сообщения:
Обычное сообщение: Моё обычное сообщение
Сообщение об ошибке: Моё сообщение об ошибке
Как можно видеть из вывода на консоль, фактически мы последовательно вызвали сначала метод PrintRegularMessage, передав ему на вход строку "Моё обычное сообщение", а затем вызвали метод PrintErrorMessage, передав ему на вход строку "Моё сообщение об ошибке", но вызвали мы эти методы не напрямую, а через наш делегат, а именно переменную myDelegate с типом делегата InputStringParamDelegate.
Рассмотрим ещё несколько абстрактных примеров объявления делегата - уже с другими типами возвращаемых значений и типами входных параметров.
К примеру, такой делегат мог бы использоваться для вызова различных методов с типом int в качестве типа возвращаемого значения и принимающих на вход три параметра с типами string, int и double:
public delegate int ReturnIntValueDelegate(string message, int value, double precision);
Мы также можем не ограничиваться лишь примитивными типами данных, доступными в языке C#. Давайте предположим, что у нас где-то в программе объявлен собственный класс с именем MyClass, и мы бы теперь хотели объявить другой делегат, который может ссылаться на методы, возвращающие экземпляры данного класса и не принимающие никаких входных параметров. Тогда объявление делегата могло бы выглядеть следующим образом:
public delegate MyClass ReturnMyClassObjectDelegate();
Теперь мы могли бы создать объект делегата и предоставить ему имя нужного нам метода, возвращающего тип MyClass и для которого делегат будет служить оболочкой, например:
// Статический метод CreateMyClassObject, который умеет создавать экземпляр класса MyClass, проводить с ним какую-то работу и затем возвращать этот экземпляр из метода
public static MyClass CreateMyClassObject() {
MyClass myClassObject = new MyClass();
// ... здесь можно провести необходимую работу с объектом myClassObject...
return myClassObject;
}
// Объявить переменную returnMyClassObjectDelegate и инициализировать её ссылкой на метод CreateMyClassObject:
ReturnMyClassObjectDelegate returnMyClassObjectDelegate = CreateMyClassObject;
// Вызывать делегат (фактически через делегат будет вызван метод CreateMyClassObject)
returnMyClassObjectDelegate();
Делегаты и лямбда-выражения
Другой интересной возможностью при использовании делегатов в программах на C# является возможность преобразования лямбда-выражений, создающих анонимную функцию, в тип нужного нам делегата. Другими словами, нам необязательно заранее иметь/создавать какой-то полноценный метод, совместимый по типу возвращаемого значения и сигнатуре с типом нашего делегата, а затем устанавливать ссылку на этот метод переменной с типом делегата - достаточно присвоить переменной с типом делегата лямбда-выражение, которое создаст анонимную функцию.
Давайте рассмотрим это на примере. Пусть мы объявили такой делегат:
public delegate double DoubleOperationDelegate(double a, double b);
Он описывает собой какие-то функции, которые возвращают double и на вход принимают также два параметра с типом double. Таким образом, он вполне может описывать какую-то абстрактную арифметическую операцию для двух аргументов с типом double.
Теперь посмотрим, как мы можем объявить переменную с типом делегата DoubleOperationDelegate и присваивать ей различные лямбда-выражения, описывающие разные арифметические операции:
// Объявили и инициализировали переменную op с типом нашего делегата DoubleOperationDelegate. Для инициализации используется лябмда-выражение, преобразующееся в тип делегата.
DoubleOperationDelegate op = (a, b) => a + b;
// Вызываем делегат и присваиваем результат операции переменной myResult1. Фактически через вызов делегата мы вызываем анонимную функцию, созданную нами через лямбда-выражение выше. Эта анонимная функция сложит значения переданных нами аргументов 3 и 2.
double myResult1 = op(3, 2); // Результат операции: 3 + 2, т.е. в myResult1 будет записано 5
Console.WriteLine(myResult1); // на консоль будет выведено 5
// Теперь мы присвоили переменной op другое лямбда-выражение и другую анонимную функцию.
op = (a, b) => a * b;
// Вызываем делегат и присваиваем результат новой анонимной функции в переменную myResult2. Результатом операции на этот раз будет 1.5 * 2 = 3
double myResult2 = op(1.5, 2);
Console.WriteLine(myResult2); // теперь на консоль будет выведено 3
Вариативность в делегатах - ковариация и контрвариантность
При работе с делегатами следует также знать про такую важную их особенность, как гибкость при сопоставлении типа делегата с сигнатурой метода.
- ковариация (или ковариантность) - позволяет методу иметь тип возвращаемого значения, степень наследования которого больше, чем указано в делегате
- контрвариантность - позволяет использовать метод с типами параметров, степень наследования которых меньше, чем у типа делегата
Рассмотрим ковариацию и контрвариантность делегатов на примерах, чтобы лучше понять, что они представляют собой с практической точки зрения в программе на C#.
Пример ковариации (ковариантности):
Предположим, у нас есть родительский класс ParentClass и его класс-наследник ChildClass:
class ParentClass {
}
class ChildClass : ParentClass {
}
Теперь мы объявим делегат CovarianceDelegate, у которого тип возвращаемого значения - это ParentClass:
public delegate ParentClass CovarianceDelegate();
Пусть у нас также есть два метода, один из которых имеет тип возвращаемого значения ParentClass, а второй - ChildClass. И, положим, для простоты мы возвращаем из каждого метода новый экземпляр конкретного класса:
public static ParentClass GetParentClassObject() {
return new ParentClass();
}
public static ChildClass GetChildClassObject() {
return new ChildClass();
}
Теперь посмотрим, как проявляется ковариантность при создании переменных с типом нашего делегата CovarianceDelegate. Благодаря ей, несмотря на то, что в типе делегата возвращаемое значение задано как ParentClass, мы всё равно можем присвоить переменной delegateChildObj ссылку на метод GetChildClassObject, хотя его возвращаемый тип - это ChildClass, а не ParentClass:
CovarianceDelegate delegateParentObj = GetParentClassObject;
// За счёт ковариации данное присвоение разрешено:
CovarianceDelegate delegateChildObj = GetChildClassObject;
В этом и есть основной смысл ковариантности. В нашем примере у метода GetChildClassObject для типа возвращаемого им значения степень наследования больше, чем в самом типе делегата, однако мы можем присваивать переменной делегата delegateChildObj ссылку на этот метод.
Пример контрвариантности:
Концепция поддержки контрвариантности у делегатов может часто встретиться на практике при рассмотрении реализации системы событий для различных элементов управления в приложениях Windows Forms. Для передачи параметров в методы-обработчики событий часто используется базовый класс System.EventArgs или производные от него. В данном случае контрвариантность у подобных делегатов выражается в том, что несмотря на то, что в параметрах типа для таких делегатов указываются типы-наследники от System.EventArgs, сам метод, ответственный за обработку события, может иметь меньшую степень наследования, т.е. в качестве типа параметра иметь System.EventArgs, а не производный от System.EventArgs класс, полностью совпадающий с типом параметра делегата.
Для лучшего понимания, давайте рассмотрим абстрактный пример кода, отражающий принцип контрвариантности. Пусть у нас есть базовый класс BaseClass и два его класса-наследника Child1OfBaseClass, Child2OfBaseClass.
Создадим класс-пример ContravarianceExampleClass, в котором объявим 2 делегата MyHandlerForChild1Delegate и MyHandlerForChild2Delegate. Оба делегата принимают по два параметра, и для второго параметра inputObject у них заданы типы Child1OfBaseClass и Child2OfBaseClass, соответственно. Также объявим в классе два события (event) с именами MyEventForChild1 и MyEventForChild2 с типами указанных делегатов. И в классе создадим метод MyMethodForDelegate, который вторым параметром принимает родительский класс BaseClass:
public class BaseClass {
}
public class Child1OfBaseClass : BaseClass {
}
public class Child2OfBaseClass : BaseClass {
}
public class СontravarianceExampleClass {
public delegate void MyHandlerForChild1Delegate(object sender, Child1OfBaseClass inputObject);
public delegate void MyHandlerForChild2Delegate(object sender, Child2OfBaseClass inputObject);
public event MyHandlerForChild1Delegate MyEventForChild1;
public event MyHandlerForChild2Delegate MyEventForChild2;
public void MyMethodForDelegate(object sender, BaseClass inputObject) {
Console.WriteLine("Вызван MyMethodForDelegate, тип параметра inputObject: " + inputObject.GetType());
}
public void ShowExample() {
// Мы можем использовать метод MyMethodForDelegate, у которого тип второго параметра - BaseClass,
// хотя событие MyEventForChild1 ожидает тип для второго параметра Child1OfBaseClass
MyEventForChild1 += MyMethodForDelegate;
// Аналогичным образом, для события MyEventForChild2 можем использовать всё тот же метод
// MyMethodForDelegate
MyEventForChild2 += MyMethodForDelegate;
MyEventForChild1?.Invoke(this, new Child1OfBaseClass());
MyEventForChild2?.Invoke(this, new Child2OfBaseClass());
}
В методе ShowExample() данного класса мы установим для обоих событий MyEventForChild1 и MyEventForChild2 целевой метод MyMethodForDelegate класса, а в конце метода ShowExample() мы просто вызовем оба события через метод Invoke, как показано в примере выше.
Обратите внимание на особенность контрвариантности: сам метод MyMethodForDelegate имеет тип BaseClass для второго входного параметра, однако мы можем без каких-либо ограничений использовать его для обоих событий класса, хотя типы данных для второго параметра у их делегатов основаны на классах-наследниках от BaseClass, а не на самом BaseClass.
Если теперь запустить простую программу на базе данного класса, создающую экземпляр класса-примера и вызывающую его метод ShowExample(),
СontravarianceExampleClass contravarianceExample = new СontravarianceExampleClass();
contravarianceExample.ShowExample();
то мы увидим следующий результат:
Вызван MyMethodForDelegate, тип параметра inputObject: DelegatesExample.Child1OfBaseClass
Вызван MyMethodForDelegate, тип параметра inputObject: DelegatesExample.Child2OfBaseClass
Как видим по выводу в консоль, реальный тип данных, который передаётся во время выполнения программы при вызове каждого из событий во второй параметр inputObject метода MyMethodForDelegate, равен Child1OfBaseClass в первом случае и Child2OfBaseClass во втором случае.
Повторно проводя параллель с реализацией системы событий в WindowsForms: BaseClass в этом синтетическом примере мог бы являться аналогом стандартного класса System.EventArgs, а классы-наследники Child1OfBaseClass, Child2OfBaseClass - могли бы представлять аналоги каких-то классов, производных от System.EventArgs, например, KeyEventArgs или MouseEventArgs.
Многоадресность делегатов
Ещё одной интересной и полезной особенностью, которая есть у делегатов, является многоадресность. Это означает, что делегат при вызове может вызывать сразу несколько методов, а не один. Для того, чтобы добавить в список методов делегата другой дополнительный метод, нужно просто добавить два делегата при помощи операторов + или +=.
Рассмотрим это на примере. Пусть у нас объявлен делегат LogMessageDelegate, который может использоваться для вызова различных логирующих методов:
public delegate void LogMessageDelegate(string message);
Пусть у нас также есть два простых метода, один из которых выводит обычные сообщения, а другой - сообщения об ошибке:
public void LogError(string message) {
Console.WriteLine("[Ошибка]: " + message);
}
public void LogMessage(string message) {
Console.WriteLine("[Сообщение]: " + message);
}
Теперь создадим три переменных с типом нашего делегата и присвоим им ссылки на эти методы, а третьей переменной присвоим лямбда-выражение:
LogMessageDelegate logMethod1 = LogError;
LogMessageDelegate logMethod2 = LogMessage;
LogMessageDelegate logMethod3 = (message) => Console.WriteLine(string.Format("[Лог из лямбда-выражения]: {0}", message));
Посмотрим как работает многоадресность у делегатов: для этого мы создадим ещё одну переменную с типом нашего делегата и именем logAll и присвоим ей результат сложения logMethod1 и logMethod2, а затем при помощи оператора += добавим к ней и logMethod3:
LogMessageDelegate logAll = logMethod1 + logMethod2;
logAll += logMethod3;
В результате в переменной logAll будет сформирован список вызова из трёх методов, поэтому когда мы вызовем этот делегат следующим образом,
logAll("Это единое сообщение будет выведено при вызове сразу трёх методов");
то в консоли мы увидим сообщения от всех трёх методов:
[Ошибка]: Это единое сообщение будет выведено при вызове сразу трёх методов
[Сообщение]: Это единое сообщение будет выведено при вызове сразу трёх методов
[Лог из лямбда-выражения]: Это единое сообщение будет выведено при вызове сразу трёх методов
Точно таким же образом мы можем убрать из списка вызова для делегата какой-либо из ранее добавленных методов посредством оператора -=, например так:
logAll -= LogError;
Console.WriteLine();
logAll("Теперь другое сообщение без лога для ошибки");
И теперь при запуске на экране консоли мы увидим ожидаемый результат:
[Сообщение]: Теперь другое сообщение без лога для ошибки
[Лог из лямбда-выражения]: Теперь другое сообщение без лога для ошибки
А сейчас давайте ещё раз посмотрим на некоторые особенности делегатов в C#, которые важны для понимания и работы с ними:
- Типобезопасность: делегаты являются типобезопасными. Это означает, что они могут хранить только ссылки на методы с совместимой сигнатурой
- Многопоточность: делегаты могут использоваться для реализации асинхронного программирования и работы с многопоточностью
- События: делегаты широко используются для реализации обработки событий в C#
- Поддержка лямбда-выражений: при присвоении значения переменной с типом делегата можно использовать лямбда-выражение, которое преобразуется в тип делегата
- Ковариантность и контрвариантность: ковариантность позволяет методу иметь тип возвращаемого значения, степень наследования которого больше, чем указано в делегате, контрвариантность позволяет использовать метод с типами параметров, степень наследования которых меньше, чем у типа делегата
- Многоадресность: при вызове делегат может вызывать несколько методов
Заключение
Использование делегатов в C# позволяет улучшить гибкость и расширяемость кода, обеспечивая возможность передачи методов как параметров и реализацию обработки событий.
Правильное использование делегатов поможет упростить структуру программы и повысить ее эффективность.
Ссылка на архив с проектом для Microsoft Visual Studio, содержащий примеры кода из статьи: https://allineed.ru/our-products/download/4-allineed-ru-examples/34-csharp-delegates-example