$ sudo teach IT
Модуль 8 · Функции

Урок 8.5 — Делегаты и события

Делегаты — ссылки на методы. События — подписка и уведомление.

Представь, что у тебя есть список дел на день. Ты можешь дать этот список другу, чтобы он выполнил все задачи. В C# делегат — это тип, который хранит ссылку на метод. Ты можешь передать метод как аргумент, сохранить его в переменной, а позже вызвать. Событие — это механизм, позволяющий объекту сообщать другим объектам о том, что что-то произошло. Это основа UI-программирования и многих паттернов.

📜

Что такое делегат

Делегат (delegate) — это тип, который определяет сигнатуру метода (возвращаемый тип и параметры) и может хранить ссылку на любой метод с этой сигнатурой. Делегаты в C# — это аналог указателей на функции из C/C++, но безопасные и объектно-ориентированные.

Зачем нужны делегаты? Они позволяют передавать методы как параметры, создавать callback-функции, реализовывать паттерн «Наблюдатель» (Observer) и делать код более гибким.

Общая схема:

delegate возвращаемыйТип Имя(параметры);

Пример: delegate void MyDelegate(int x); — делегат, который может хранить метод, принимающий int и возвращающий void.

C# · Объявление и использование делегата
// 1. Объявляем делегат
delegate void MyDelegate(int x);

// 2. Создаём методы с той же сигнатурой
static void PrintDouble(int number)
{
    Console.WriteLine(number * 2);
}

static void PrintSquare(int number)
{
    Console.WriteLine(number * number);
}

// 3. Используем делегат
MyDelegate del = PrintDouble;  // сохраняем ссылку на метод
del(5);                        // вызываем метод через делегат → 10

del = PrintSquare;             // теперь ссылаемся на другой метод
del(5);                        // → 25

Делегат выступает как «посредник» между кодом, который хочет вызвать метод, и самим методом. Это пригодится для callback'ов, обработчиков событий и LINQ.

📝

Объявление delegate void MyDelegate(int x)

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

C# · Разные варианты делегатов
// Делегат без параметров, возвращает void
delegate void SimpleDelegate();

// Делегат с одним параметром, возвращает void
delegate void LogDelegate(string message);

// Делегат с двумя параметрами, возвращает int
delegate int MathDelegate(int a, int b);

// Делегат с параметром и возвращаемым bool
delegate bool FilterDelegate(int value);

// Использование
static void ShowTime() => Console.WriteLine(DateTime.Now);
static void Log(string msg) => Console.WriteLine($"[LOG] {msg}");
static int Add(int a, int b) => a + b;
static bool IsPositive(int v) => v > 0;

SimpleDelegate s = ShowTime;
s();                              // → текущее время

LogDelegate log = Log;
log("Старт программы");           // → [LOG] Старт программы

MathDelegate math = Add;
Console.WriteLine(math(3, 7));    // → 10

FilterDelegate filter = IsPositive;
Console.WriteLine(filter(-5));    // → False
💡 Делегат можно объявить на уровне класса (внутри или снаружи), а также внутри namespace. Обычно их объявляют снаружи класса, на уровне пространства имён.
🔗

Multicast-делегаты: += и -=

Делегат может хранить не один, а несколько методов. Такой делегат называется multicast-делегатом. Методы добавляются через += и удаляются через -=. При вызове делегата все методы вызываются последовательно в порядке их добавления.

C# · Multicast делегат
delegate void Notifier(string message);

static void SendEmail(string msg)
{
    Console.WriteLine($"Email отправлен: {msg}");
}

static void SendSms(string msg)
{
    Console.WriteLine($"SMS отправлено: {msg}");
}

static void LogToFile(string msg)
{
    Console.WriteLine($"Записано в лог: {msg}");
}

Notifier notify = SendEmail;
notify += SendSms;     // добавляем второй метод
notify += LogToFile;   // добавляем третий метод

notify("Система запущена");
// Вывод:
// Email отправлен: Система запущена
// SMS отправлено: Система запущена
// Записано в лог: Система запущена

notify -= SendSms;     // удаляем SMS-уведомление
notify("Обновление завершено");
// Вывод:
// Email отправлен: Обновление завершено
// Записано в лог: Обновление завершено
⚠️ Если делегат возвращает значение (не void), то при multicast-вызове будет возвращено значение только последнего вызванного метода. Предыдущие результаты теряются.
C# · Проверка делегата перед вызовом
Notifier notify = null;
// notify("тест");  // NullReferenceException!

// Безопасный вызов:
if (notify != null)
{
    notify("тест");
}

// Или через null-conditional (C# 6+):
notify?.Invoke("тест");  // ничего не произойдёт, ошибки нет
📦

Встроенные делегаты: Func<>, Action<>, Predicate<>

В .NET есть три встроенных семейства делегатов, которые покрывают 99% случаев. Тебе не нужно объявлять собственные делегаты — используй готовые:

Action<T> Делегат, возвращающий void

Action — это делегат, который ничего не возвращает (void). Может принимать до 16 параметров. Используется для действий, команд, callback'ов.

C# · Action
Action sayHello = () => Console.WriteLine("Привет!");
sayHello();                  // → Привет!

Action<string> print = msg => Console.WriteLine(msg);
print("C# крутой!");         // → C# крутой!

Action<int, int> showSum = (a, b) =>
    Console.WriteLine($"Сумма: {a + b}");
showSum(3, 5);               // → Сумма: 8
Func<TResult> Делегат, возвращающий значение

Func — это делегат, возвращающий значение. Последний параметр в угловых скобках — это возвращаемый тип. Первые — типы входных параметров. Может принимать до 16 входных параметров.

C# · Func
Func<int, int> square = x => x * x;
Console.WriteLine(square(5));       // → 25

Func<int, int, int> add = (a, b) => a + b;
Console.WriteLine(add(5, 3));       // → 8

Func<double, double> celsiusToFahr =
    c => c * 9 / 5 + 32;
Console.WriteLine(celsiusToFahr(0)); // → 32
Predicate<T> Делегат, возвращающий bool

Predicate<T> — это делегат, который принимает один параметр типа T и возвращает bool. Используется для проверки условий (фильтрации).

C# · Predicate
Predicate<int> isEven = x => x % 2 == 0;
Console.WriteLine(isEven(4));  // → True
Console.WriteLine(isEven(7));  // → False

List<int> numbers = new() { 1, 2, 3, 4, 5, 6 };
List<int> evens = numbers.FindAll(isEven);
Console.WriteLine(string.Join(", ", evens));  // → 2, 4, 6
💡 Func<T1, T2, TResult> — последний тип всегда TResult (возвращаемое значение). Action<T1, T2> — все типы — параметры, возврат void. Predicate<T> — это фактически Func<T, bool>, но с более понятным названием.
🔔

События: event keyword

Событие (event) — это специальная конструкция на основе делегата, которая позволяет объекту уведомлять другие объекты о том, что произошло некоторое действие. События — это основа GUI-программирования (нажатия кнопок, движение мыши) и паттерна «Издатель-Подписчик» (Publisher-Subscriber).

Ключевое отличие события от простого делегата: извне можно только += или -= обработчики, но нельзя перезаписать или вызвать событие напрямую.

Объявление и использование события:

public event DelegateType EventName;

C# · Событие — полный пример
// 1. Объявляем делегат для события
public delegate void ButtonClickHandler(string message);

// 2. Создаём класс с событием (издатель)
public class Button
{
    // Объявляем событие
    public event ButtonClickHandler OnClick;

    public void Click(string userMessage)
    {
        Console.WriteLine($"Кнопка нажата: {userMessage}");
        // Вызываем событие — уведомляем всех подписчиков
        OnClick?.Invoke($"Кнопка нажата пользователем: {userMessage}");
    }
}

// 3. Создаём подписчиков
public class Logger
{
    public void Log(string msg)
    {
        Console.WriteLine($"[Логгер] {msg}");
    }
}

public class EmailNotifier
{
    public void SendNotification(string msg)
    {
        Console.WriteLine($"[Email] Отправлено уведомление: {msg}");
    }
}

Теперь применим это в программе:

C# · Подписка на события
static void Main()
{
    Button button = new Button();
    Logger logger = new Logger();
    EmailNotifier email = new EmailNotifier();

    // Подписываемся на событие
    button.OnClick += logger.Log;
    button.OnClick += email.SendNotification;

    // Вызываем событие
    button.Click("Сохранить документ");
    // Вывод:
    // Кнопка нажата: Сохранить документ
    // [Логгер] Кнопка нажата пользователем: Сохранить документ
    // [Email] Отправлено уведомление: Кнопка нажата пользователем: Сохранить документ

    // Отписываемся от события
    button.OnClick -= email.SendNotification;

    button.Click("Открыть файл");
    // Вывод:
    // Кнопка нажата: Открыть файл
    // [Логгер] Кнопка нажата пользователем: Открыть файл
}
📡

Подписка и вызов событий — детали

Чтобы правильно работать с событиями, нужно запомнить несколько важных правил:

Правила работы с событиями

  • Событие может быть объявлено только внутри класса (или интерфейса).
  • Извне к событию можно только подписаться (+=) или отписаться (-=).
  • Вызвать событие (например, button.OnClick("тест")) можно только из того же класса, где оно объявлено.
  • Событие нельзя присвоить напрямую: button.OnClick = someMethod; — ошибка компиляции.
  • Перед вызовом события всегда проверяй его на null: OnClick?.Invoke(msg).
  • Можно использовать встроенный делегат EventHandler и EventHandler<TEventArgs>.
C# · Стандартный паттерн EventHandler
// Стандартный делегат: (object sender, EventArgs e)
public event EventHandler? OnProcessCompleted;

// Вызов события (внутри класса)
protected virtual void OnProcessCompleted()
{
    OnProcessCompleted?.Invoke(this, EventArgs.Empty);
}

// С собственными данными:
public class OrderEventArgs : EventArgs
{
    public string OrderId { get; set; }
    public decimal Amount { get; set; }
}

public event EventHandler<OrderEventArgs> OnOrderPlaced;

protected virtual void OnOrderPlaced(OrderEventArgs e)
{
    OnOrderPlaced?.Invoke(this, e);
}
💼

Практические примеры: UI-обработчики и callback'и

Делегаты и события повсеместно используются в реальных C#-приложениях. Вот самые частые сценарии:

Пример 1: Обработчики UI (например, Windows Forms)

C# · Обработчик кнопки
// Windows Forms
Button saveButton = new Button();
saveButton.Text = "Сохранить";
saveButton.Click += (sender, e) =>
{
    MessageBox.Show("Файл сохранён!");
};

// WPF
Button wpfButton = new Button();
wpfButton.Content = "Нажми меня";
wpfButton.Click += OnButtonClick;

private void OnButtonClick(object sender, RoutedEventArgs e)
{
    // Обработка нажатия
}

Пример 2: Callback после завершения операции

C# · Callback через Action
void ProcessData(int[] data, Action<int> onItemProcessed)
{
    foreach (var item in data)
    {
        int result = item * 2;
        onItemProcessed(result);  // вызываем callback
    }
}

// Использование:
int[] numbers = { 1, 2, 3, 4, 5 };
ProcessData(numbers, result =>
{
    Console.WriteLine($"Обработан элемент: {result}");
});

Пример 3: Фильтрация с помощью делегата (как в LINQ)

C# · Фильтрация с делегатом
List<int> FilterList(List<int> list, Predicate<int> predicate)
{
    List<int> result = new();
    foreach (var item in list)
    {
        if (predicate(item))
            result.Add(item);
    }
    return result;
}

List<int> numbers = new() { 1, 2, 3, 4, 5, 6, 7, 8 };

var evens = FilterList(numbers, x => x % 2 == 0);
var big = FilterList(numbers, x => x > 5);

Console.WriteLine(string.Join(", ", evens));  // → 2, 4, 6, 8
Console.WriteLine(string.Join(", ", big));    // → 6, 7, 8
❓

Мини-тест

Вопрос 1: Что такое делегат в C#?

A) Тип, который хранит значение целого числа
B) Тип, который хранит ссылку на метод с определённой сигнатурой
C) Тип, который хранит коллекцию объектов
D) Тип, который управляет памятью

Правильный ответ: B

Вопрос 2: Какой синтаксис объявления делегата?

A) delegate (int x) { }
B) void delegate MyDel(int x)
C) delegate void MyDel(int x)
D) del void MyDel(int x)

Правильный ответ: C

Вопрос 3: Какой встроенный делегат используется для методов, возвращающих значение?

A) Action
B) Func
C) Predicate
D) EventHandler

Правильный ответ: B

Вопрос 4: Что делает оператор += с делегатом?

A) Перезаписывает делегат новым методом
B) Добавляет метод в список вызовов делегата
C) Удаляет метод из списка вызовов
D) Вызывает метод немедленно

Правильный ответ: B

Вопрос 5: Чем событие отличается от делегата?

A) Событие работает быстрее
B) Событие нельзя подписать через +=
C) Снаружи класса можно только подписаться/отписаться, но нельзя вызвать
D) Событие не может принимать параметры

Правильный ответ: C

🧪

Задание

Система уведомлений о погоде

Создай консольное приложение, которое моделирует систему уведомлений:

  1. Объяви делегат WeatherDelegate, принимающий string city, int temperature.
  2. Создай класс WeatherStation (издатель) с событием OnWeatherChanged.
  3. Добавь метод UpdateWeather(string city, int temp), который выводит информацию и вызывает событие.
  4. Создай классы-подписчики: PhoneApp (выводит уведомление на телефон), EmailService (отправляет email), Logger (пишет в лог).
  5. В Main() подпиши все три подписчика на событие, вызови UpdateWeather для двух городов.
  6. Отпиши Logger и снова вызови UpdateWeather.
  7. Используй лямбда-выражение для подписки дополнительного уведомления.

Цель

Отработать объявление делегатов, событий, подписку и отписку, multicast-делегаты.

Сложность

Средняя

📌

Что важно запомнить

1️⃣
Делегат — тип, хранящий ссылку на метод с определённой сигнатурой. Объявляется через ключевое слово delegate.
2️⃣
Multicast-делегат хранит несколько методов. Добавление через +=, удаление через -=. Все методы вызываются последовательно.
3️⃣
Встроенные делегаты: Action (void), Func<TResult> (возвращает значение), Predicate<T> (возвращает bool).
4️⃣
Событие (event) — обёртка над делегатом. Снаружи можно только подписаться или отписаться. Вызвать событие можно только внутри класса-издателя.
5️⃣
Всегда проверяй на null перед вызовом события: OnClick?.Invoke(msg). Используй EventHandler для стандартных сценариев.

Тест: 8.5: Делегаты и события

3 вопроса

Делегаты

Premium