Делегаты и события — одна из тех тем, которые в C# сначала кажутся запутанными, а потом оказываются повсюду: обработчики кнопок, асинхронные обратные вызовы, LINQ, шаблон «наблюдатель». Если вы понимаете, как они устроены, код становится гибче, а связи между классами — слабее. В статье разберём все основные понятия на примерах и обсудим типичные ловушки.
Что такое делегат
Делегат — это тип, описывающий сигнатуру метода. Проще говоря, переменная делегата может хранить ссылку на метод с подходящим набором параметров и возвращаемым значением. По сути это безопасный объектно-ориентированный аналог указателя на функцию.
Объявляется делегат так:
public delegate int Operation(int a, int b);
public static int Add(int a, int b)
{
return a + b;
}
public static int Multiply(int a, int b)
{
return a * b;
}
Operation op = Add;
Console.WriteLine(op(3, 4)); // 7
op = Multiply;
Console.WriteLine(op(3, 4)); // 12
Одна и та же переменная op вызывает разные методы. Именно это позволяет передавать поведение как параметр: например, метод сортировки принимает делегат сравнения, и вы сами решаете, как сравнивать элементы.
Мультикаст: цепочка вызовов
Делегат в C# может ссылаться не на один метод, а сразу на несколько. Это называется многоадресным делегатом. Добавить метод в цепочку можно оператором +=, убрать — оператором -=.
public delegate void Notify(string message);
static void ToConsole(string m) { Console.WriteLine("Консоль: " + m); }
static void ToLog(string m) { Console.WriteLine("Лог: " + m); }
Notify n = ToConsole;
n += ToLog;
n("Сохранено"); // вызовутся оба метода по порядку
n -= ToLog;
n("Готово"); // только ToConsole
Методы вызываются в порядке добавления. Если у делегата есть возвращаемое значение, вы получите результат только последнего метода цепочки. Поэтому мультикаст чаще применяют с методами, возвращающими void.
Готовые делегаты: Action, Func и Predicate
Объявлять собственный тип делегата приходится нечасто. В библиотеке классов есть готовые универсальные:
Action— метод без возвращаемого значения, до многих параметров;Func<T, TResult>— метод, возвращающий результат; последний параметр типа всегда тип результата;Predicate<T>— метод, принимающий значение и возвращающийbool.
Func<int, int, int> sum = (a, b) => a + b;
Action<string> print = s => Console.WriteLine(s);
Predicate<int> isEven = x => x % 2 == 0;
print("Сумма: " + sum(5, 6));
Console.WriteLine(isEven(10));
Пользуйтесь ими по умолчанию. Собственный делегат оправдан, если нужно дать сигнатуре осмысленное имя или документировать её назначение.
Анонимные методы и лямбда-выражения
Чтобы не писать отдельный именованный метод ради одного вызова, используют анонимные методы и лямбды. Лямбда — самая компактная форма:
var numbers = new List<int> { 5, 2, 8, 1, 9 };
var big = numbers.FindAll(x => x > 4);
numbers.Sort((a, b) => b.CompareTo(a)); // по убыванию
Важная особенность — замыкание. Лямбда может захватывать локальные переменные внешнего метода и использовать их, даже когда метод уже завершился:
int factor = 3;
Func<int, int> mul = x => x * factor;
factor = 10;
Console.WriteLine(mul(2)); // 20, а не 6
Захватывается именно переменная, а не её значение на момент создания. Это частая причина странных ошибок, особенно в циклах: если лямбда создаётся внутри for и использует счётчик, все лямбды увидят его итоговое значение. Решение — скопировать значение в локальную переменную внутри тела цикла.
События: делегат под защитой
Событие — это надстройка над делегатом, которая ограничивает доступ. Снаружи класса на событие можно только подписаться и отписаться. Вызвать его или присвоить ему новое значение может лишь сам класс, объявивший его. Благодаря этому внешний код не способен случайно затереть чужие подписки.
public class Downloader
{
public event EventHandler<ProgressEventArgs> ProgressChanged;
protected virtual void OnProgressChanged(int percent)
{
ProgressChanged?.Invoke(this, new ProgressEventArgs(percent));
}
public void Start()
{
for (int i = 0; i <= 100; i += 25)
{
OnProgressChanged(i);
}
}
}
public class ProgressEventArgs : EventArgs
{
public int Percent { get; }
public ProgressEventArgs(int percent) { Percent = percent; }
}
Подписчик выглядит так:
var d = new Downloader();
d.ProgressChanged += (sender, e) => Console.WriteLine(e.Percent + "%");
d.Start();
Стандартный шаблон события
В .NET принята конвенция, которой стоит следовать:
- Используйте тип
EventHandlerилиEventHandler<TEventArgs>. - Первый параметр обработчика — отправитель (
object sender), второй — данные события. - Данные оформляйте классом-наследником
EventArgs. - Для вызова заведите защищённый виртуальный метод с префиксом
On, чтобы наследники могли переопределить поведение. - Вызывайте событие через оператор
?.Invoke, чтобы не получить исключение при отсутствии подписчиков.
Типичные ошибки
- Утечки памяти. Подписчик, не отписавшийся от долгоживущего источника, остаётся в памяти, потому что источник держит на него ссылку. Отписывайтесь, когда объект больше не нужен.
- Исключение в обработчике. Если один из обработчиков бросит ошибку, остальные в цепочке не вызовутся. При необходимости обходите
GetInvocationList()и оборачивайте вызовы вtry. - Вызов из другого потока. Обработчик выполняется в потоке, который вызвал событие. В интерфейсных приложениях обновлять элементы управления нужно через диспетчер или соответствующий механизм маршалинга.
- Неатомарная проверка на null. Запись
if (E != null) E(...)в многопоточном коде небезопасна — подписчик может отписаться между проверкой и вызовом. Оператор?.Invokeрешает проблему. - Захват переменной цикла в лямбде — уже упоминалось выше.
Чем делегат отличается от события
| Свойство | Делегат (поле) | Событие |
|---|---|---|
| Подписка снаружи | Да | Да |
| Вызов снаружи | Да | Нет |
| Присваивание снаружи | Да, затирает подписки | Нет |
| Назначение | Обратный вызов, передача поведения | Уведомление о происшествии |
Когда что применять
Если вам нужно передать метод как параметр и получить результат — используйте делегат (чаще всего Func или Action). Если объект должен сообщать окружающим о том, что с ним что-то произошло, и ему безразлично, кто слушает, — используйте событие. Так вы получаете слабую связанность: источник не знает о подписчиках, подписчики не лезут во внутренности источника.
Ковариантность и контравариантность делегатов
Делегаты поддерживают вариантность, которая позволяет присваивать методы с чуть более общими или более частными типами. Если делегат возвращает базовый тип, ему можно присвоить метод, возвращающий производный. А если параметр делегата имеет производный тип, подойдёт метод, принимающий базовый. Благодаря этому один универсальный обработчик может обслуживать несколько событий с разными данными, что заметно сокращает дублирование кода.
Асинхронные обработчики
Часто в обработчике нужно дождаться ответа сети или чтения файла. Допустимо пометить обработчик событий как асинхронный и использовать await, но с одной оговоркой: такой метод должен возвращать void, иначе он не подойдёт под сигнатуру события. Исключения из него не перехватить снаружи, поэтому внутри обязательно оборачивайте тело в try и catch. Во всех остальных местах предпочитайте методы, возвращающие Task.
Заключение
Делегаты превращают методы в полноценные значения, лямбды делают их записью в одну строку, а события дают контролируемый способ уведомлять других. Освоив эти три вещи, вы легче читаете чужой код на C# и пишете свой более лаконично. Запомните главные правила: предпочитайте готовые Action и Func, следуйте стандартному шаблону событий, не забывайте отписываться и осторожно обращайтесь с замыканиями. Остальное придёт с практикой.