Делегаты

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

Определение делегатов

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

delegate void Message();

Делегат Message в качестве возвращаемого типа имеет тип void (то есть ничего не возвращает) и не принимает никаких параметров. Это значит, что этот делегат может указывать на любой метод, который не принимает никаких параметров и ничего не возвращает.

Рассмотрим применение этого делегата:

delegate void Message();   // 1. Объявляем делегат
 
Message mes;               // 2. Создаем переменную делегата
mes = Hello;               // 3. Присваиваем этой переменной адрес метода
mes();                     // 4. Вызываем метод
 
void Hello() => Console.WriteLine("Hello METANIT.COM");

Прежде всего сначала необходимо определить сам делегат:

delegate void Message();   // 1. Объявляем делегат

Для использования делегата объявляется переменная этого делегата:

Message mes;   // 2. Создаем переменную делегата

Далее в делегат передается адрес определенного метода (в нашем случае метода Hello). Обратите внимание, что данный метод имеет тот же возвращаемый тип и тот же набор параметров (в данном случае отсутствие параметров), что и делегат.

mes = Hello;   // 3. Присваиваем этой переменной адрес метода

Затем через делегат вызываем метод, на который ссылается данный делегат:

mes();   // 4. Вызываем метод

Вызов делегата производится подобно вызову метода.

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

delegate void Message();
 
class Welcome
{
    public static void Print() => Console.WriteLine("Welcome");
}
 
class Hello
{
    public void Display() => Console.WriteLine("Привет");
}
 
Message message1 = Welcome.Print;
Message message2 = new Hello().Display;
 
message1();   // Welcome
message2();   // Привет

Место определения делегата

Если мы определяем делегат в программах верхнего уровня (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");
    }
}

Параметры и результат делегата

Рассмотрим определение и применение делегата, который принимает параметры и возвращает результат:

delegate int Operation(int x, int y);
 
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;

В данном случае делегат Operation возвращает значение типа int и имеет два параметра типа int. Поэтому этому делегату соответствует любой метод, который возвращает значение типа int и принимает два параметра типа int. В данном случае это методы Add и Multiply. То есть мы можем присвоить переменной делегата любой из этих методов и вызывать.

Поскольку делегат принимает два параметра типа int, то при его вызове необходимо передать значения для этих параметров: operation(4, 5).

Присвоение ссылки на метод

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

delegate int Operation(int x, int y);
 
Operation operation1 = Add;
Operation operation2 = new Operation(Add);
 
int Add(int x, int y) => x + 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. И при вызове делегата все методы из этого списка последовательно вызываются. И мы можем добавлять в этот список не один, а несколько методов. Для добавления методов в делегат применяется операция +=:

delegate void Message();
 
Message message = Hello;
message += HowAreYou;    // теперь message указывает на два метода
message();               // вызываются оба метода - Hello и HowAreYou
 
void Hello() => Console.WriteLine("Hello");
void HowAreYou() => Console.WriteLine("How are you?");

В данном случае в список вызова делегата message добавляются два метода - Hello и HowAreYou. И при вызове message вызываются сразу оба этих метода.

Однако стоит отметить, что в реальности будет происходить создание нового объекта делегата, который получит методы старой копии делегата и новый метод, и новый созданный объект делегата будет присвоен переменной message.

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

delegate void Message();
 
Message message = Hello;
message += HowAreYou;
message += Hello;
message += Hello;
message();
 
void Hello() => Console.WriteLine("Hello");
void HowAreYou() => Console.WriteLine("How are you?");

Консольный вывод:

Hello
How are you?
Hello
Hello

Подобным образом мы можем удалять методы из делегата с помощью операции -=:

delegate void Message();
 
Message? message = Hello;
message += HowAreYou;
message();                   // вызываются все методы из message
 
message -= HowAreYou;        // удаляем метод HowAreYou
if (message != null) 
    message();               // вызывается метод Hello
 
void Hello() => Console.WriteLine("Hello");
void HowAreYou() => Console.WriteLine("How are you?");

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

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

Кроме того, перед вторым вызовом мы проверяем переменную на значение null.

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

Объединение делегатов

Делегаты можно объединять в другие делегаты. Например:

delegate void Message();
 
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?");

В данном случае объект mes3 представляет объединение делегатов mes1 и mes2. Объединение делегатов значит, что в список вызова делегата mes3 попадут все методы из делегатов mes1 и mes2. И при вызове делегата mes3 все эти методы одновременно будут вызваны.

Вызов делегата

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

delegate void Message();
delegate int Operation(int x, int y);
 
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;

Другой способ вызова делегата представляет метод Invoke():

delegate void Message();
delegate int Operation(int x, int y);
 
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;

Если делегат принимает параметры, то в метод Invoke передаются значения для этих параметров.

Следует учитывать, что если делегат пуст, то есть в его списке вызова нет ссылок ни на один из методов (то есть делегат равен null), то при вызове такого делегата мы получим исключение, как, например, в следующем случае:

delegate void Message();
delegate int Operation(int x, int y);
 
Message? mes;
// mes();            // ! Ошибка: делегат равен null
 
Operation? op = Add;
op -= Add;            // делегат op пуст
// int n = op(3, 4); // ! Ошибка: делегат равен null
 
int Add(int x, int y) => x + y;

Поэтому при вызове делегата всегда лучше проверять, не равен ли он null. Либо можно использовать метод Invoke и оператор условного null:

delegate void Message();
delegate int Operation(int x, int y);
 
Message? mes = null;
mes?.Invoke();         // ошибки нет, делегат просто не вызывается
 
Operation? op = Add;
op -= Add;             // делегат op пуст
int? n = op?.Invoke(3, 4);   // ошибки нет, делегат просто не вызывается, а n = null
 
int Add(int x, int y) => x + y;

Если делегат возвращает некоторое значение, то возвращается значение последнего метода из списка вызова (если в списке вызова несколько методов). Например:

delegate int Operation(int x, int y);
 
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 T Operation<T, K>(K val);
 
decimal Square(int n) => n * n;
int Double(int n) => n + n;
 
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

Здесь делегат Operation типизируется двумя параметрами типов. Параметр T представляет тип возвращаемого значения. А параметр K представляет тип передаваемого в делегат параметра. Таким образом, этому делегату соответствует метод, который принимает параметр любого типа и возвращает значение любого типа.

В программе мы можем определить переменные делегата под определенный метод. Например, делегату Operation<decimal, int> соответствует метод, который принимает число int и возвращает число типа decimal. А делегату Operation<int, int> соответствует метод, который принимает и возвращает число типа int.

Делегаты как параметры методов

Также делегаты могут быть параметрами методов. Благодаря этому один метод в качестве параметров может получать действия - другие методы. Например:

delegate int Operation(int x, int y);
 
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;

Здесь метод DoOperation в качестве параметров принимает два числа и некоторое действие в виде делегата Operation. Внутри метода вызываем делегат Operation, передавая ему числа из первых двух параметров.

При вызове метода DoOperation мы можем передать в него в качестве третьего параметра метод, который соответствует делегату Operation.

Возвращение делегатов из метода

Также делегаты можно возвращать из методов. То есть мы можем возвращать из метода какое-то действие в виде другого метода. Например:

delegate int Operation(int x, int y);
 
enum OperationType
{
    Add, Subtract, Multiply
}
 
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;
 
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

В данном случае метод SelectOperation() в качестве параметра принимает перечисление типа OperationType. Это перечисление хранит три константы, каждая из которых соответствует определенной арифметической операции. И в самом методе в зависимости от значения параметра возвращаем определенный метод. Причем поскольку возвращаемый тип метода - делегат Operation, то метод должен возвратить метод, который соответствует этому делегату - в нашем случае это методы Add, Subtract, Multiply. То есть если параметр метода SelectOperation равен OperationType.Add, то возвращается метод Add, который выполняет сложение двух чисел.

При вызове метода SelectOperation мы можем получить из него нужное действие в переменную operation:

Operation operation = SelectOperation(OperationType.Add);

И при вызове переменной operation фактически будет вызываться полученный из SelectOperation метод:

Operation operation = SelectOperation(OperationType.Add);   // Здесь operation = Add
Console.WriteLine(operation(10, 4));    // 14

Возвращение делегатом делегата

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

delegate Hungry Hungry(int value);
 
Eat(5)(6)(7);
 
Hungry Eat(int val)
{
    Console.WriteLine($"Съели число {val}");
    return Eat;
}

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

Для примера определяем метод Eat(), который соответствует делегату Hungry - принимает число типа int и возвращает делегат Hungry. А поскольку метод Eat соответствует делегату Hungry, то из этого метода мы можем возвратить … сам же этот метод.

В итоге при создании цепочки Eat(5)(6)(7) мы получим три вызова метода Eat:

Съели число 5
Съели число 6
Съели число 7