OOP Course
Сьогодні

Підрозділ 6.1

Делегати

Пояснює делегати як посилання на методи: оголошення, відповідність сигнатур, списки виклику, Invoke, узагальнені делегати, делегати як параметри та приклад з банківським рахунком.

6.1. Делегати

Делегати — один із фундаментальних механізмів C#, який дозволяє зберігати посилання на методи і передавати методи як значення. Якщо змінна типу int зберігає число, а змінна типу string — рядок, то змінна-делегат зберігає посилання на метод. Через цю змінну метод можна викликати, передати в інший метод як аргумент або зберегти для виклику пізніше. Делегати є основою для подій, лямбда-виразів, функцій вищого порядку та патерну зворотного виклику (callback). Розуміння делегатів — це розуміння того, як у C# методи стають об'єктами першого класу.

Визначення делегата

Для оголошення делегата використовується ключове слово delegate, після якого вказується тип значення, що повертається, ім'я делегата та список параметрів. По суті, це оголошення нового типу, який описує сигнатуру методів, на які делегат може вказувати:

delegate void Notify(string message);

Делегат Notify відповідає будь-якому методу, який приймає один параметр string і нічого не повертає (void). Жоден інший набір параметрів або тип, що повертається, не підійде — компілятор перевіряє це статично.

Розглянемо застосування цього делегата:

Насамперед необхідно визначити сам делегат — оголосити новий тип. Потім оголошується змінна цього типу. Далі делегату передається адреса певного методу — у нашому випадку методу AlertDoctor. Зверніть увагу: цей метод має той самий тип, що повертається, і той самий набір параметрів, що і делегат. Нарешті, через змінну делегата викликається метод. Виклик делегата виглядає так само, як звичайний виклик методу.

Делегат як посилання на метод

При цьому делегати не обмежені методами того класу, де визначена змінна делегата. Це можуть бути також методи інших класів і структур:

Місце визначення делегата

Якщо ми визначаємо делегат у програмах верхнього рівня (top-level program), яку за замовчуванням представляє файл Program.cs, починаючи з версії C# 10, то, як і інші типи, делегат визначається в кінці коду (як у прикладах вище). Але делегат можна визначати і всередині класу:

class Program
{
    delegate void Notify(string message); // делегат всередині класу

    static void Main()
    {
        Notify handler;
        handler = AlertDoctor;
        handler("Критичний пульс!");

        void AlertDoctor(string msg) => Console.WriteLine($"[ЛІКАР] {msg}");
    }
}

Або поза класом:

delegate void Notify(string message); // делегат поза класом

class Program
{
    static void Main()
    {
        Notify handler;
        handler = AlertDoctor;
        handler("Критичний пульс!");

        void AlertDoctor(string msg) => Console.WriteLine($"[ЛІКАР] {msg}");
    }
}

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

Делегат повністю успадковує сигнатуру методу: параметри, їх типи і тип значення, що повертається. Розглянемо визначення та застосування делегата, який приймає параметри і повертає результат. Делегат можна перепризначати між методами, що мають однакову сигнатуру:

Делегат Calculate повертає double і приймає два параметри double. Тому йому відповідає будь-який метод з таким самим підписом — Add, Subtract, Multiply. Оскільки делегат приймає два параметри, при виклику необхідно передати їх значення.

Існує також альтернативний синтаксис ініціалізації через конструктор делегата — обидва варіанти рівноцінні:

Calculate calc1 = Add;
Calculate calc2 = new Calculate(Add); // рівноцінно першому

Відповідність методів делегату

Як зазначено вище, методи відповідають делегату, якщо вони мають той самий тип, що повертається, і той самий набір параметрів. При цьому до уваги також беруться модифікатори ref, in та out. Наприклад, нехай у нас є делегат:

delegate void AlertHandler(string message, int code);

Цьому делегату відповідає такий метод:

void LogAlert(string msg, int level) { } // OK — той самий підпис

А такі методи не відповідають:

string GetAlert(string msg, int code) { return msg; } // інший тип повернення
void Handle(int code, string msg)     { }              // інший порядок параметрів
void Handle(ref string msg, int code) { }              // модифікатор ref
void Handle(out string msg, int code) { msg = ""; }    // модифікатор out

Метод з іншим типом повернення не підходить. Метод з іншим порядком параметрів — теж, навіть якщо типи самих параметрів збігаються. Наявність модифікаторів ref або out також робить метод несумісним із делегатом без цих модифікаторів.

Додавання методів у делегат

У прикладах вище змінна делегата вказувала на один метод. Насправді делегат може вказувати на безліч методів, які мають ту ж сигнатуру і тип, що повертається. Усі методи у делегаті потрапляють у спеціальний список — список виклику (invocation list). При виклику делегата всі методи цього списку послідовно викликаються. Для додавання методів до делегата застосовується операція +=:

У цьому випадку до списку виклику делегата alert додаються два методи. При виклику alert викликаються відразу обидва.

Однак варто зазначити, що в реальності відбувається створення нового об'єкта делегата, який отримує методи старої копії і новий метод, і цей новий об'єкт присвоюється змінній alert.

При додаванні делегатів слід враховувати, що один і той самий метод можна додати кілька разів. У цьому випадку у списку виклику делегата буде кілька посилань на той самий метод, і при виклику делегата цей метод буде викликатися стільки разів, скільки він був доданий:

Список виклику multicast-делегата

Подібним чином ми можемо видаляти методи з делегата за допомогою операції -=:

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

Варто відзначити: при видаленні може скластися ситуація, що в делегаті не залишиться жодного методу — тоді змінна матиме значення null. Тому в даному випадку змінна оголошена як AlertHandler? (nullable), тобто тип, який може представляти як делегат, так і значення null. Крім того, перед другим викликом ми перевіряємо змінну на null.

При видаленні слід враховувати: якщо делегат містить кілька посилань на один і той самий метод, то операція -= починає пошук із кінця списку виклику і видаляє лише перше знайдене входження. Якщо такого методу у списку немає — операція -= не має жодного ефекту.

Об'єднання делегатів

Делегати можна поєднувати в інші делегати за допомогою оператора +. Отримаємо новий делегат, чий список виклику містить усі методи обох:

Об'єднання делегатів означає, що до списку виклику делегата all потраплять усі методи із делегатів group1 та group2. При виклику all всі ці методи викликаються одночасно.

Виклик делегата

У прикладах вище делегат викликався як звичайний метод. Якщо делегат приймає параметри — при виклику передаються необхідні значення:

Інший спосіб виклику делегата — метод Invoke():

Якщо делегат приймає параметри — методу Invoke передаються їх значення.

Слід враховувати: якщо делегат порожній, тобто у його списку виклику немає жодного методу (делегат дорівнює null), то при виклику такого делегата виникне виняток:

AlertHandler? alert;
// alert("Помилка!"); // NullReferenceException — делегат дорівнює null

Calculate? calc = Add;
calc -= Add; // делегат calc порожній
// int n = calc(10, 5); // NullReferenceException

delegate void AlertHandler(string message);
delegate int Calculate(int x, int y);
int Add(int x, int y) => x + y;

Тому при виклику делегата краще завжди перевіряти, чи він не дорівнює null. Або використовувати метод Invoke з оператором умовного null ?., який безпечно не виконає виклик якщо делегат null:

Якщо делегат повертає деяке значення і у його списку виклику кілька методів — повертається значення останнього методу зі списку. Наприклад:

Узагальнені делегати

Делегати, як і інші типи, можуть бути узагальненими. Це дозволяє описати делегат, що відповідає методам із різними конкретними типами параметрів і повернення:

Тут делегат Transform типізується двома параметрами типів. Параметр K представляє тип вхідного параметра, а параметр T — тип значення, що повертається. Таким чином, цьому делегату відповідає метод, який приймає параметр будь-якого типу та повертає значення будь-якого типу. Делегату Transform<double, double> відповідає метод, що приймає і повертає double, а делегату Transform<int, string> — метод, що приймає int і повертає string.

Делегати як параметри методів

Делегати можуть бути параметрами методів. Завдяки цьому один метод може отримувати інші методи як дії — параметри. Це і є функції вищого порядку:

Тут метод CalculateVitals як параметри приймає два числа і деяку дію у вигляді делегата Operation. Всередині методу викликаємо делегат, передаючи йому числа з перших двох параметрів. При виклику методу CalculateVitals ми можемо передати як третій параметр будь-який метод, що відповідає делегату Operation.

Повернення делегатів із методів

Також делегати можна повертати з методів. Тобто ми можемо повертати з методу якусь дію у вигляді іншого методу:

В даному випадку метод SelectOperation як параметр приймає значення перерахування OperationType. Залежно від значення параметра повертається певний метод. Оскільки тип методу, що повертається — делегат Operation, то метод повинен повернути метод, який відповідає цьому делегату. Тобто, якщо параметр дорівнює OperationType.Add, повертається метод Add.

При виклику SelectOperation ми отримуємо необхідну дію у змінну op1. І при виклику змінної op1 фактично буде викликатися отриманий з SelectOperation метод.

Застосування делегатів

Наведені вище приклади, можливо, не показують справжньої сили делегатів, оскільки потрібні нам методи ми могли б викликати і без будь-яких делегатів. Однак найбільш сильна сторона делегатів полягає в тому, що вони дозволяють делегувати виконання певного коду ззовні. На момент написання класу ми можемо не знати, що саме буде виконуватися — ми просто викликаємо делегат. А який метод безпосередньо виконуватиметься, вирішуватиметься потім, при використанні класу.

Патерн зворотного виклику через делегат

Розглянемо детальний приклад. Нехай у нас є клас, який описує пацієнта в системі:

public class Patient
{
    int _balance; // умовний баланс страхових коштів
    public Patient(int balance) => _balance = balance;
    public void AddFunds(int amount) => _balance += amount;
    public void Spend(int amount)
    {
        if (_balance >= amount) _balance -= amount;
    }
}

Припустимо, нам треба повідомляти про кожне списання страхових коштів пацієнта. Якщо клас використовується лише в консольній програмі того самого проекту, де він створений, можна написати просто:

public void Spend(int amount)
{
    if (_balance >= amount)
    {
        _balance -= amount;
        Console.WriteLine($"Списано {amount} грн. зі страхового рахунку.");
    }
}

Але якщо наш клас планується використовувати в інших проектах — у графічному додатку Windows Forms або WPF, у мобільному додатку, у веб-API — рядок повідомлення Console.WriteLine(...) не матиме жодного сенсу. Більш того, якщо клас Patient використовуватиметься іншими розробниками у вигляді окремої бібліотеки, ці розробники захочуть повідомляти про списання коштів якимось іншим чином — про який ми навіть не здогадуємося на момент написання класу.

Тому жорстко вшитий Console.WriteLine — не найкраще рішення. Делегати дозволяють делегувати визначення дії із класу у зовнішній код, який використовуватиме цей клас. Змінимо клас, застосувавши делегати:

Для делегування дії тут визначено делегат PatientHandler. Цей делегат відповідає будь-яким методам, які мають тип void та приймають параметр типу string:

public delegate void PatientHandler(string message);

У класі Patient визначається змінна _onSpend, що представляє цей делегат. Далі визначається спеціальний метод RegisterHandler, через який передається реальна дія — конкретний метод ззовні:

public void RegisterHandler(PatientHandler handler)
{
    _onSpend = handler;
}

Виклик делегата відбувається у методі Spend. Залежно від того, чи відбулося списання, передаються різні повідомлення. Класу Patient не важливо, що саме відбудеться — він лише надсилає повідомлення через делегат.

Таким чином, ми створили механізм зворотного виклику для класу Patient. Тут ми виводимо повідомлення на консоль. Але зовнішній код міг би записати повідомлення у файл, надіслати на email, показати у графічному вікні — будь-який спосіб обробки, незалежно від класу Patient.

Додавання та видалення методів у делегаті

Хоча у прикладі наш делегат приймав адресу одного методу, насправді він може вказувати відразу на кілька. Крім того, за потреби ми можемо видалити посилання на певні методи, щоб вони не викликалися при виклику делегата. Змінимо клас Patient: метод RegisterHandler тепер використовуватиме +=, а новий метод UnregisterHandler-=:

У методі RegisterHandler делегати _onSpend і handler об'єднуються в один, який присвоюється змінній _onSpend. У методі UnregisterHandler зі змінної _onSpend видаляється делегат handler.

Анонімні методи

З делегатами тісно пов'язані анонімні методи. Анонімні методи використовуються для створення екземплярів делегатів без оголошення окремого іменованого методу.

Визначення анонімних методів починається з ключового слова delegate, після якого у дужках йде список параметрів та тіло методу у фігурних дужках:

delegate(параметри)
{
    // інструкції
}

Наприклад:

Анонімний метод не може існувати сам по собі — він використовується для ініціалізації екземпляра делегата. У даному випадку змінна handler є анонімним методом, і через цю змінну делегата можна викликати цей анонімний метод.

Інший приклад анонімних методів — передача як аргумент для параметра, який представляє делегат:

Якщо анонімному методу не потрібні параметри — дужки з параметрами опускаються. При цьому навіть якщо делегат приймає кілька параметрів, в анонімному методі можна їх не вказувати:

Тобто якщо анонімний метод містить параметри — вони обов'язково повинні відповідати параметрам делегата. Або анонімний метод взагалі може не містити жодних параметрів, тоді він відповідає будь-якому делегату з тим самим типом значення, що повертається. При цьому параметри анонімного методу не можуть бути опущені, якщо один або декілька параметрів визначено модифікатором out.

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

При цьому анонімний метод має доступ до всіх змінних, визначених у зовнішньому коді (замикання):

Анонімний метод захоплює змінні wardName і alertCount із зовнішнього контексту — це замикання (closure). Зміна alertCount всередині анонімного методу відображається і в зовнішньому коді. Анонімні методи зазвичай використовують тоді, коли потрібно визначити одноразову дію, яка не має багато інструкцій та ніде більше не використовується. Зокрема, їх часто застосовують для обробки подій, які будуть розглянуті далі. Анонімні методи є попередниками лямбда-виразів — більш компактного сучасного способу запису того самого, який розглядатиметься у наступних розділах.

Розроблено Tomka Yurii · © 2026 ·