Делегаты представляют такие объекты, которые указывают на методы. То есть делегаты — это указатели на методы и с помощью делегатов мы можем вызвать данные методы.
Определение делегатов
Для объявления делегата используется ключевое слово delegate, после которого идет возвращаемый тип, название и параметры. Например:
delegate void Message();
Делегат Message в качестве возвращаемого типа имеет тип void (то есть ничего не возвращает) и не принимает никаких параметров. Это значит, что этот делегат может указывать на любой метод, который не принимает никаких параметров и ничего не возвращает.
Рассмотрим применение этого делегата:
Message mes; // 2. Создаем переменную делегата
mes = Hello; // 3. Присваиваем этой переменной адрес метода
mes(); // 4. Вызываем метод
void Hello() => Console.WriteLine("Hello METANIT.COM");
delegate void Message(); // 1. Объявляем делегат
Прежде всего сначала необходимо определить сам делегат:
delegate
void
Message(); // 1. Объявляем делегат
Для использования делегата объявляется переменная этого делегата:
Message mes; // 2. Создаем переменную делегата
Далее в делегат передается адрес определенного метода (в нашем случае метода Hello). Обратите внимание, что данный метод имеет тот же возвращаемый тип и тот же набор параметров (в данном случае отсутствие параметров), что и делегат.
mes = Hello; // 3. Присваиваем этой переменной адрес метода
Затем через делегат вызываем метод, на который ссылается данный делегат:
mes(); // 4. Вызываем метод
Вызов делегата производится подобно вызову метода.
При этом делегаты необязательно могут указывать только на методы, которые определены в том же классе, где определена переменная делегата. Это могут быть также методы из других классов и структур.
Message message1 = Welcome.Print;
Message message2 = new Hello().Display;
message1(); // Welcome
message2(); // Привет
delegate void Message();
class Welcome
{
public static void Print() => Console.WriteLine("Welcome");
}
class Hello
{
public void Display() => Console.WriteLine("Привет");
}
Место определения делегата
Если мы определяем делегат в программах верхнего уровня (top-level program), которую по умолчанию представляет файл Program.cs начиная с версии C# 10, как в примере выше, то, как и другие типы, делегат определяется в конце кода. Но в принципе делегат можно определять внутри класса:
class Program
{
delegate void Message(); // 1. Объявляем делегат
static void Main()
{
Message mes; // 2. Создаем переменную делегата
mes = Hello; // 3. Присваиваем этой переменной адрес метода
mes(); // 4. Вызываем метод
void Hello() => Console.WriteLine("Hello METANIT.COM");
}
}
Либо вне класса:
delegate void Message(); // 1. Объявляем делегат
class Program
{
static void Main()
{
Message mes; // 2. Создаем переменную делегата
mes = Hello; // 3. Присваиваем этой переменной адрес метода
mes(); // 4. Вызываем метод
void Hello() => Console.WriteLine("Hello METANIT.COM");
}
}
Параметры и результат делегата
Рассмотрим определение и применение делегата, который принимает параметры и возвращает результат:
Operation operation = Add; // делегат указывает на метод Add
int result = operation(4, 5); // фактически Add(4, 5)
Console.WriteLine(result); // 9
operation = Multiply; // теперь делегат указывает на метод Multiply
result = operation(4, 5); // фактически Multiply(4, 5)
Console.WriteLine(result); // 20
int Add(int x, int y) => x + y;
int Multiply(int x, int y) => x * y;
delegate int Operation(int x, int y);
В данном случае делегат Operation возвращает значение типа int и имеет два параметра типа int. Поэтому этому делегату соответствует любой метод, который возвращает значение типа int и принимает два параметра типа int. В данном случае это методы Add и Multiply. То есть мы можем присвоить переменной делегата любой из этих методов и вызывать.
Поскольку делегат принимает два параметра типа int, то при его вызове необходимо передать значения для этих параметров: operation(4,5)
.
Присвоение ссылки на метод
Выше переменной делегата напрямую присваивался метод. Есть еще один способ — создание объекта делегата с помощью конструктора, в который передается нужный метод:
Operation operation1 = Add;
Operation operation2 = new Operation(Add);
int Add(int x, int y) => x + y;
delegate int Operation(int x, int y);
Оба способа равноценны.
Соответствие методов делегату
Как было написано выше, методы соответствуют делегату, если они имеют один и тот же возвращаемый тип и один и тот же набор параметров. Но надо учитывать, что во внимание также принимаются модификаторы ref, in и out. Например, пусть у нас есть делегат:
delegate
void
SomeDel(int
a, double
b);
Этому делегату соответствует, например, следующий метод:
void
SomeMethod1(int
g, double
n) { }
А следующие методы НЕ соответствуют:
double SomeMethod2(int g, double n) { return g + n; }
void SomeMethod3(double n, int g) { }
void SomeMethod4(ref int g, double n) { }
void SomeMethod5(out int g, double n) { g = 6; }
Здесь метод SomeMethod2 имеет другой возвращаемый тип, отличный от типа делегата. SomeMethod3 имеет другой набор параметров. Параметры SomeMethod4 и SomeMethod5 также отличаются от параметров делегата, поскольку имеют модификаторы ref и out.
Добавление методов в делегат
В примерах выше переменная делегата указывала на один метод. В реальности же делегат может указывать на множество методов, которые имеют ту же сигнатуру и возвращаемые тип. Все методы в делегате попадают в специальный список — список вызова или invocation list. И при вызове делегата все методы из этого списка последовательно вызываются. И мы можем добавлять в этот список не один, а несколько методов. Для добавления методов в делегат применяется операция +=:
Message message = Hello;
message += HowAreYou; // теперь message указывает на два метода
message(); // вызываются оба метода - Hello и HowAreYou
void Hello() => Console.WriteLine("Hello");
void HowAreYou() => Console.WriteLine("How are you?");
delegate void Message();
В данном случае в список вызова делегата message добавляются два метода — Hello и HowAreYou. И при вызове message вызываются сразу оба этих метода.
Однако стоит отметить, что в реальности будет происходить создание нового объекта делегата, который получит методы старой копии делегата и новый метод, и новый созданный объект делегата будет присвоен переменной message.
При добавлении делегатов следует учитывать, что мы можем добавить ссылку на один и тот же метод несколько раз, и в списке вызова делегата тогда будет несколько ссылок на один и то же метод. Соответственно при вызове делегата добавленный метод будет вызываться столько раз, сколько он был добавлен:
Message message = Hello;
message += HowAreYou;
message += Hello;
message += Hello;
message();
Консольный вывод:
Hello How are you? Hello Hello
Подобным образом мы можем удалять методы из делегата с помощью операций -=:
Message? message = Hello;
message += HowAreYou;
message(); // вызываются все методы из message
message -= HowAreYou; // удаляем метод HowAreYou
if (message != null) message(); // вызывается метод Hello
При удалении методов из делегата фактически будет создаватья новый делегат, который в списке вызова методов будет содержать на один метод меньше.
Стоит отметить, что при удалении метода может сложиться ситуация, что в делегате не будет методов, и тогда переменная будет иметь значение null. Поэтому в данном случае переменная определена не просто как переменная типа Message
, а именно Message?, то есть типа, который может представлять как делегат Message, так и значение null.
Кроме того, перед вторым вызовом мы проверяем переменную на значение null.
При удалении следует учитывать, что если делегат содержит несколько ссылок на один и тот же метод, то операция -= начинает поиск с конца списка вызова делегата и удаляет только первое найденное вхождение. Если подобного метода в списке вызова делегата нет, то операция -= не имеет никакого эффекта.
Объединение делегатов
Делегаты можно объединять в другие делегаты. Например:
Message mes1 = Hello;
Message mes2 = HowAreYou;
Message mes3 = mes1 + mes2; // объединяем делегаты
mes3(); // вызываются все методы из mes1 и mes2
void Hello() => Console.WriteLine("Hello");
void HowAreYou() => Console.WriteLine("How are you?");
delegate void Message();
В данном случае объект mes3 представляет объединение делегатов mes1 и mes2. Объединение делегатов значит, что в список вызова делегата mes3 попадут все методы из делегатов mes1 и mes2. И при вызове делегата mes3 все эти методы одновременно будут вызваны.
Вызов делегата
В примерах выше делегат вызывался как обычный метод. Если делегат принимал параметры, то при его вызове для параметров передавались необходимые значения:
Message mes = Hello;
mes();
Operation op = Add;
int n = op(3, 4);
Console.WriteLine(n);
void Hello() => Console.WriteLine("Hello");
int Add(int x, int y) => x + y;
delegate int Operation(int x, int y);
delegate void Message();
Другой способ вызова делегата представляет метод Invoke():
Message mes = Hello;
mes.Invoke(); // Hello
Operation op = Add;
int n = op.Invoke(3, 4);
Console.WriteLine(n); // 7
void Hello() => Console.WriteLine("Hello");
int Add(int x, int y) => x + y;
delegate int Operation(int x, int y);
delegate void Message();
Если делегат принимает параметры, то в метод Invoke передаются значения для этих параметров.
Следует учитывать, что если делегат пуст, то есть в его списке вызова нет ссылок ни на один из методов (то есть делегат равен Null), то при вызове такого делегата мы получим исключение, как, например, в следующем случае:
Message? mes;
//mes(); // ! Ошибка: делегат равен null
Operation? op = Add;
op -= Add; // делегат op пуст
int n = op(3, 4); // !Ошибка: делегат равен null
Поэтому при вызове делегата всегда лучше проверять, не равен ли он null. Либо можно использовать метод Invoke и оператор условного null:
Message? mes = null;
mes?.Invoke(); // ошибки нет, делегат просто не вызывается
Operation? op = Add;
op -= Add; // делегат op пуст
int? n = op?.Invoke(3, 4); // ошибки нет, делегат просто не вызывается, а n = null
Если делегат возвращает некоторое значение, то возвращается значение последнего метода из списка вызова (если в списке вызова несколько методов). Например:
Operation op = Subtract;
op += Multiply;
op += Add;
Console.WriteLine(op(7, 2)); // Add(7,2) = 9
int Add(int x, int y) => x + y;
int Subtract(int x, int y) => x - y;
int Multiply(int x, int y) => x * y;
delegate int Operation(int x, int y);
Обобщенные делегаты
Делегаты, как и другие типы, могут быть обобщенными, например:
Operation<decimal, int> squareOperation = Square;
decimal result1 = squareOperation(5);
Console.WriteLine(result1); // 25
Operation<int, int> doubleOperation = Double;
int result2 = doubleOperation(5);
Console.WriteLine(result2); // 10
decimal Square(int n) => n * n;
int Double(int n) => n + n;
delegate T Operation<T, K>(K val);
Здесь делегат Operation типизируется двумя параметрами типов. Параметр T представляет тип возвращаемого значения. А параметр K представляет тип передаваемого в делегат параметра. Таким образом, этому делегату соответствует метод, который принимает параметр любого типа и возвращает значение любого типа.
В прогамме мы можем определить переменные делегата под определенный метод. Например, делегату Operation<decimal, int>
соответствует метод, который принимает число int и возвращает число типа decimal. А делегату Operation<int, int>
соответствует метод, который принимает и возвращает число типа int.
Делегаты как параметры методов
Также делегаты могут быть параметрами методов. Благодаря этому один метод в качестве параметров может получать действия — другие методы. Например:
DoOperation(5, 4, Add); // 9
DoOperation(5, 4, Subtract); // 1
DoOperation(5, 4, Multiply); // 20
void DoOperation(int a, int b, Operation op)
{
Console.WriteLine(op(a,b));
}
int Add(int x, int y) => x + y;
int Subtract(int x, int y) => x - y;
int Multiply(int x, int y) => x * y;
delegate int Operation(int x, int y);
Здесь метод DoOperation в качестве параметров принимает два числа и некоторое действие в виде делегата Operation. В внутри метода вызываем делегат Operation, передавая ему числа из первых двух параметров.
При вызове метода DoOperation мы можем передать в него в качестве третьего параметра метод, который соответствует делегату Operation.
Возвращение делегатов из метода
Также делегаты можно возвращать из методов. То есть мы можем возвращать из метода какое-то действие в виде другого метода. Например:
Operation operation = SelectOperation(OperationType.Add);
Console.WriteLine(operation(10, 4)); // 14
operation = SelectOperation(OperationType.Subtract);
Console.WriteLine(operation(10, 4)); // 6
operation = SelectOperation(OperationType.Multiply);
Console.WriteLine(operation(10, 4)); // 40
Operation SelectOperation(OperationType opType)
{
switch (opType)
{
case OperationType.Add: return Add;
case OperationType.Subtract: return Subtract;
default: return Multiply;
}
}
int Add(int x, int y) => x + y;
int Subtract(int x, int y) => x - y;
int Multiply(int x, int y) => x * y;
enum OperationType
{
Add, Subtract, Multiply
}
delegate int Operation(int x, int y);
В данном случае метод SelectOperation()
в качестве параметра принимает перечисление типа OperationType. Это перечисление хранит три константы, каждая из которых соответствует определенной арифметической операции. И в самом методе в зависимости от значения параметра возвращаем определенный метод. Причем поскольку возвращаемый тип метода — делегат Operation, то метод должен возвратить метод, который соответствует этому делегату — в нашем случае это методы Add, Subtract, Multiply. То есть если параметр метода SelectOperation равен OperationType.Add
, то возвращается метод Add, который выолняет сложение двух чисел:
case
OperationType.Add: return
Add;
При вызове метода SelectOperation мы можем получить из него нужное действие в переменную operation:
Operation operation = SelectOperation(OperationType.Add);
И при вызове переменной operation фактически будет вызываться полученный из SelectOperation метод:
Operation operation = SelectOperation(OperationType.Add); // Здесь operation = AddConsole.WriteLine(operation(10, 4)); // 14
Ковариантность, контрвариантность
Понятия ковариантности и контравариантности связаны с возможностью использовать в приложении вместо некоторого типа другой тип, который находится ниже или выше в иерархии наследования.
Имеется три возможных варианта поведения:
- Ковариантность: позволяет использовать более конкретный тип, чем заданный изначально
- Контравариантность: позволяет использовать более универсальный тип, чем заданный изначально
- Инвариантность: позволяет использовать только заданный тип
C# позволяет создавать ковариантные и контравариантные обобщенные интерфейсы. Эта функциональность повышает гибкость при использовании обобщенных интерфейсов в программе. По умолчанию все обобщенные интерфейсы являются инвариантными.
Для рассмотрения ковариантных и контравариантных интерфейсов возьмем следующие классы:
class Message
{
public string Text { get; set; }
public Message(string text) => Text = text;
}
class EmailMessage : Message
{
public EmailMessage(string text): base(text) { }
}
Здесь определен класс сообщения Message, который получает через конструктор текст и сохраняет его в свойство Text. А класс EmailMessage представляет условное email-сообщение и просто вызывает конструктор базового класса, передавая ему текст сообщения.
Ковариантные интерфейсы
Обобщенные интерфейсы могут быть ковариантными, если к универсальному параметру применяется ключевое слово out. Такой параметр должен представлять тип объекта, который возвращается из метода. Например:
interface IMessenger<out T>
{
T WriteMessage(string text);
}
class EmailMessenger : IMessenger<EmailMessage>
{
public EmailMessage WriteMessage(string text)
{
return new EmailMessage($"Email: {text}");
}
}
Здесь обобщенный интерфейс IMessenger
представляет интерфейс мессенджера и определяет метод WriteMessage()
для создания сообщения. При этом на момент определения интерфейса мы не знаем, объект какого типа будет возвращаться в этом методе. Ключевое слово out в определении интерфейса указывает, что данный интерфейс будет ковариантным.
Класс EmailMessenger, который представляет условную программу для отправки email-сообщений, реализует этот интерфейс и возвращает из метода WriteMessage()
объект EmailMessage.
Применим данные типы в программе:
IMessenger<Message> outlook = new EmailMessenger();
Message message = outlook.WriteMessage("Hello World");
Console.WriteLine(message.Text); // Email: Hello World
IMessenger<EmailMessage> emailClient = new EmailMessenger();
IMessenger<Message> messenger = emailClient;
Message emailMessage = messenger.WriteMessage("Hi!");
Console.WriteLine(emailMessage.Text); // Email: Hi!
То есть мы можем присвоить более общему типу IMessenger<Message>
объект более конкретного типа EmailMessenger
или IMessenger<EmailMessage>
.
В то же время если бы мы не использовали ключевое слово out
:
interface
IMessenger<T>
то мы столкнулись бы с ошибкой в строке
IMessenger<Message> outlook = new EmailMessenger(); // ! Ошибка
IMessenger<EmailMessage> emailClient = new EmailMessenger();
IMessenger<Message> messenger = emailClient; // ! Ошибка
Поскольку в этом случае невозможно было бы привести объект IMessenger<EmailMessage>
к типу IMessenger<Message>
При создании ковариантного интерфейса надо учитывать, что универсальный параметр может использоваться только в качестве типа значения, возвращаемого методами интерфейса. Но не может использоваться в качестве типа аргументов метода или ограничения методов интерфейса.
Контравариантные интерфейсы
Для создания контравариантного интерфейса надо использовать ключевое слово in. Например, возьмем те же классы Message и EmailMessage и определим следующие типы:
interface IMessenger<in T>
{
void SendMessage(T message);
}
class SimpleMessenger : IMessenger<Message>
{
public void SendMessage(Message message)
{
Console.WriteLine($"Отправляется сообщение: {message.Text}");
}
}
Здесь опять же интерфейс IMessenger представляет интерфейс мессенджера и определяет метод SendMessage()
для отправки условного сообщения. Ключевое слово in в определении интерфейса указывает, что этот интерфейс — контравариантный.
Класс SimpleMessenger представляет условную программу отправки сообщений и реализует этот интерфейс. Причем в качестве типа используемого этот класс использует тип Message. То есть SimpleMessenger фактически представляет тип IMessenger<Message>
.
Применим эти типы в программе:
IMessenger<EmailMessage> outlook = new SimpleMessenger();
outlook.SendMessage(new EmailMessage("Hi!"));
IMessenger<Message> telegram = new SimpleMessenger();
IMessenger<EmailMessage> emailClient = telegram;
emailClient.SendMessage(new EmailMessage("Hello"));
Так как интерфейс IMessenger использует универсальный параметр с ключевым словом in, то он является контравариантным, поэтому в коде мы можем переменной типа IMessenger<EmailMessage>
передать объект IMessenger<Message>
или SimpleMessenger
Если бы ключевое слово in не использовалось бы, то мы не смогли бы это сделать. То есть объект интерфейса с более универсальным типом приводится к объекту интерфейса с более конкретным типом.
При создании контрвариантного интерфейса надо учитывать, что универсальный параметр контрвариантного типа может применяться только к аргументам метода, но не может применяться к возвращаемому результату метода.
Совмещение ковариантности и контравариантности
Также мы можем совместить ковариантность и контравариантность в одном интерфейсе. Например:
interface IMessenger<in T, out K>
{
void SendMessage(T message);
K WriteMessage(string text);
}
class SimpleMessenger : IMessenger<Message, EmailMessage>
{
public void SendMessage(Message message)
{
Console.WriteLine($"Отправляется сообщение: {message.Text}");
}
public EmailMessage WriteMessage(string text)
{
return new EmailMessage($"Email: {text}");
}
}
Фактически здесь объединены два предыдущих примера. Благодаря ковариантности/контравариантности объект класса SimpleMessenger может представлять типы IMessenger<EmailMessage, Message>
, IMessenger<Message, EmailMessage>
, IMessenger<Message, Message>
и IMessenger<EmailMessage, EmailMessage>
. Применение классов:
IMessenger<EmailMessage, Message> messenger = new SimpleMessenger();
Message message = messenger.WriteMessage("Hello World");
Console.WriteLine(message.Text);
messenger.SendMessage(new EmailMessage("Test"));
IMessenger<EmailMessage, EmailMessage> outlook = new SimpleMessenger();
EmailMessage emailMessage = outlook.WriteMessage("Message from Outlook");
outlook.SendMessage(emailMessage);
IMessenger<Message, Message> telegram = new SimpleMessenger();
Message simpleMessage = telegram.WriteMessage("Message from Telegram");
telegram.SendMessage(simpleMessage);