Advanced Core

Делегати, Події та Лямбда-вирази

Розуміння делегатів, подій та лямбда-виразів у C# — потужних механізмів для створення гнучкого та розширюваного коду

Делегати, Події та Лямбда-вирази

Вступ та Контекст

Уявіть, що ви створюєте систему обробки файлів. Коли програма знаходить файл, вона має повідомити різні частини вашого додатку: один компонент оновлює UI, інший логує результат, третій відправляє статистику. Як зробити це елегантно, не створюючи жорстких зв'язків між класами?

Проблема: У традиційному програмуванні, щоб викликати метод, ви маєте знати точний тип об'єкта. Це створює тісний зв'язок (tight coupling) між компонентами.

Делегати (Delegates) вирішують цю проблему. Вони є типобезпечними покажчиками на методи, які дозволяють передавати методи як параметри, зберігати їх у колекціях та викликати динамічно.

Еволюція

У мові C існували вказівники на функції — потужний, але небезпечний механізм. C# взяв цю ідею та зробив її типобезпечною:

Loading diagram...
graph LR
    A[C: Function Pointers] -->|Типобезпека| B[C#: Delegates]
    B -->|Синтаксичний цукор| C[Anonymous Methods]
    C -->|Лаконічність| D[Lambda Expressions]

    style A fill:#64748b,stroke:#334155,color:#ffffff
    style B fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style C fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style D fill:#f59e0b,stroke:#b45309,color:#ffffff

Передумови

Перед вивченням цієї теми вам потрібно розуміти:

  • Методи та їх сигнатури
  • Класи та об'єкти
  • Основи ООП (інкапсуляція, поліморфізм)

Делегати (Delegates)

Визначення

Делегат (Delegate) — це тип, що представляє посилання на методи з певною сигнатурою. По суті, це клас, похідний від System.MulticastDelegate, який описує контракт методу.

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

Під капотом

На низькому рівні делегат — це об'єкт, що містить посилання на метод та об'єкт, до якого цей метод належить.

  • _target: посилання на об'єкт (для instance методів) або null (для static методів).
  • _methodPtr: вказівник на функцію в пам'яті.

Коли ви створюєте MulticastDelegate, він додатково містить список інших делегатів, які потрібно викликати послідовно.

Теоретичні аспекти реалізації делегатів:

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

  • Посилання на метод, який потрібно викликати
  • Об'єкт екземпляра (для методів екземпляру) або null (для статичних методів)
  • Інформацію про сигнатуру методу для забезпечення типобезпеки
  • Для multicast делегатів - масив інших делегатів, які потрібно викликати

Коли ви викликаєте делегат, CLR (Common Language Runtime) виконує наступні кроки:

  1. Перевіряє, чи делегат не є null
  2. Визначає, чи це multicast делегат
  3. Викликає кожен метод у списку викликів (invocation list) у встановленому порядку
  4. Якщо делегат повертає значення, повертається результат останнього викликаного методу

Ця реалізація забезпечує типобезпечність та ефективність викликів методів через делегати.

Синтаксис

Оголошення делегата схоже на оголошення методу, але з ключовим словом delegate:

DelegateDeclaration.cs
// Оголошення типу делегата
public delegate void ProcessHandler(string message);
public delegate int CalculateHandler(int a, int b);
public delegate bool PredicateHandler<T>(T item);

Делегат визначає:

  • Тип повернення
  • Назву делегата
  • Список параметрів

Створення та Використання

delegate void NotifyDelegate(string message);

class Program
{
    static void EmailNotification(string msg)
    {
        Console.WriteLine($"📧 Email: {msg}");
    }

    static void SmsNotification(string msg)
    {
        Console.WriteLine($"📱 SMS: {msg}");
    }

    static void Main()
    {
        // Створення екземпляра делегата
        NotifyDelegate notify = new NotifyDelegate(EmailNotification);

        // Виклик делегата
        notify("Ваше замовлення відправлено"); // 📧 Email: Ваше замовлення відправлено
    }
}
Важливо: Навіть якщо два делегати мають ідентичну сигнатуру, вони є різними типами, якщо оголошені окремо. Це забезпечує типобезпеку.

Multicast Delegates (Багатоадресні Делегати)

Делегати в C# підтримують Multicast — можливість викликати декілька методів послідовно:

MulticastExample.cs
delegate void LogDelegate(string message);

class Logger
{
    static void ConsoleLog(string msg)
        => Console.WriteLine($"[CONSOLE] {msg}");

    static void FileLog(string msg)
        => Console.WriteLine($"[FILE] {msg}");

    static void Main()
    {
        LogDelegate log = ConsoleLog;
        log += FileLog; // Додавання методу

        log("Помилка підключення");
        // Виведе:
        // [CONSOLE] Помилка підключення
        // [FILE] Помилка підключення

        log -= ConsoleLog; // Видалення методу
        log("Повторна спроба");
        // Виведе тільки:
        // [FILE] Повторна спроба
    }
}
Небезпека NullReferenceException: Якщо ви видалите всі методи з делегата, він стане null. Виклик null делегата спричинить виняток. Використовуйте оператор ?.:
log?.Invoke("Безпечний виклик");

Як Працюють Multicast Delegates

Loading diagram...
graph TD
    A[Delegate Instance] --> B{Invocation List}
    B --> C[Method 1]
    B --> D[Method 2]
    B --> E[Method 3]

    C --> F[Execute Method 1]
    F --> G[Execute Method 2]
    G --> H[Execute Method 3]
    H --> I[Return last result]

    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style B fill:#f59e0b,stroke:#b45309,color:#ffffff
    style C fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style D fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style E fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style I fill:#64748b,stroke:#334155,color:#ffffff
Повернення значення: Якщо делегат повертає значення, multicast делегат поверне тільки результат останнього методу в списку. Результати попередніх методів будуть втрачені.

Коваріантність та Контраваріантність

Це механізм, який дозволяє використовувати методи, чия сигнатура не зовсім збігається з делегатом, але є сумісною з точки зору наслідування типів.

1. Коваріантність (Covariance) — out

Дозволяє методу повертати більш конкретний тип (спадкоємець), ніж оголошено в делегаті.

"Якщо делегат обіцяє повернути Person, то метод, що повертає Employee, теж підходить, бо Employee — це теж Person."

2. Контраваріантність (Contravariance) — in

Дозволяє методу приймати більш загальний тип параметра (батько), ніж оголошено в делегаті.

"Якщо делегат передає Employee, то метод, який вміє обробляти будь-якого Person, теж впорається, бо Employee є Person."

Приклад та Liskov Substitution Principle

Це пряме застосування принципу підстановки Лісков (LSP).

VarianceDeepDive.cs
class Person { }
class Employee : Person { }
class Manager : Employee { }

delegate Person Factory();           // Covariant return
delegate void Handler(Employee e);   // Contravariant param

class Program
{
    // Повертає Manager (спадкоємець Person) -> OK
    static Manager CreateManager() => new Manager();

    // Приймає Person (батько Employee) -> OK
    static void LogPerson(Person p) { }

    static void Main()
    {
        // COVARIANCE: Factory очікує Person, ми даємо Manager.
        // Це безпечно, бо хто очікує Person, зможе працювати з Manager.
        Factory factory = CreateManager;

        // CONTRAVARIANCE: Handler збирається передати Employee.
        // Ми даємо метод, що приймає Person.
        // Це безпечно, бо метод LogPerson точно зможе обробити Employee.
        Handler handler = LogPerson;
    }
}

Generic Variance

Узагальнені делегати Func та Action вже мають правильні модифікатори варіативності:

  • Func<out TResult>: коваріантний за результатом.
  • Action<in T>: контраваріантний за аргументом.
Обмеження Value Types: Варіативність працює тільки для Reference Types. Ви не можете привести Func<string> до Func<object>, це працює. Але Func<int> до Func<object>ні, тому що int — це value type, і потребує boxing, що змінює представлення в пам'яті.

Теоретичні аспекти варіативності в делегатах:

Варіативність у делегатах реалізована на рівні компілятора та CLR. Вона дозволяє забезпечити типобезпечність при використанні делегатів з різними, але сумісними типами. Коваріантність дозволяє використовувати більш похідні типи в позиціях, де очікуються базові типи, а контраваріантність дозволяє використовувати базові типи в позиціях, де очікуються більш похідні типи.

Ці механізми реалізовані через модифікатори in та out у визначенні узагальнених параметрів типу. Модифікатор out дозволяє коваріантність (використання параметра типу тільки в позиціях "на вихід" - повернення значень), а модифікатор in дозволяє контраваріантність (використання параметра типу тільки в позиціях "на вхід" - параметри методів).

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

Вбудовані Делегати

Замість створення власних типів делегатів для кожного випадку, C# надає універсальні вбудовані делегати: Action, Func та Predicate.

Action

Action — делегат, який не повертає значення (void). Може мати від 0 до 16 параметрів.

ActionExample.cs
// Без параметрів
Action greet = () => Console.WriteLine("Привіт!");

// Один параметр
Action<string> printMessage = msg => Console.WriteLine(msg);

// Декілька параметрів
Action<string, int> printWithCode = (msg, code) =>
    Console.WriteLine($"[{code}] {msg}");

greet();                              // Привіт!
printMessage("Помилка системи");     // Помилка системи
printWithCode("Не знайдено", 404);   // [404] Не знайдено

Func<T, TResult>

Func — делегат, який повертає значення. Останній параметр типу — це тип повернення.

FuncExample.cs
// Без параметрів, повертає int
Func<int> getRandomNumber = () => Random.Shared.Next(1, 101);

// Один параметр, повертає результат
Func<int, int> square = x => x * x;

// Два параметри, повертає суму
Func<int, int, int> add = (a, b) => a + b;

// Перетворення типів
Func<string, int> parseToInt = s => int.Parse(s);

Console.WriteLine(getRandomNumber()); // Випадкове число
Console.WriteLine(square(5));         // 25
Console.WriteLine(add(10, 20));       // 30
Console.WriteLine(parseToInt("42"));  // 42

Predicate

Predicate<T> — спеціалізований делегат для перевірки умови. Приймає один параметр типу T та повертає bool.

PredicateExample.cs
Predicate<int> isEven = x => x % 2 == 0;
Predicate<string> isNullOrEmpty = string.IsNullOrEmpty;

Console.WriteLine(isEven(4));              // True
Console.WriteLine(isEven(7));              // False
Console.WriteLine(isNullOrEmpty(""));      // True
Console.WriteLine(isNullOrEmpty("text")); // False

// Використання з List.FindAll
List<int> numbers = [1, 2, 3, 4, 5, 6];
List<int> evenNumbers = numbers.FindAll(isEven);
Console.WriteLine(string.Join(", ", evenNumbers)); // 2, 4, 6

Порівняльна Таблиця

ДелегатСигнатураПовертає значення?Типове використання
Actionvoid Method()❌ НіВиконання дій без результату
Action<T>void Method(T arg)❌ НіОбробка даних без повернення
Func<TResult>TResult Method()✅ ТакГенерація значень
Func<T, TResult>TResult Method(T arg)✅ ТакТрансформація даних
Predicate<T>bool Method(T arg)✅ Так (bool)Перевірка умов, фільтрація
Func<T1, T2, TResult>TResult Method(T1 arg1, T2 arg2)✅ ТакКомбінування двох значень
Comparison<T>int Method(T x, T y)✅ ТакПорівняння елементів для сортування
EventHandlervoid Method(object sender, EventArgs e)❌ НіОбробка подій в UI та інших системах
EventHandler<TEventArgs>void Method(object sender, TEventArgs e)❌ НіОбробка подій з додатковою інформацією
Best Practice: Використовуйте Action та Func замість створення власних типів делегатів, якщо це можливо. Це робить код більш читабельним та стандартизованим.

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

Анонімні методи (Anonymous Methods) — це способ створення делегата без оголошення окремого іменованого методу. Введені в C# 2.0.

Синтаксис

AnonymousMethodSyntax.cs
Func<int, int, int> sum = delegate(int a, int b)
{
    return a + b;
};

Console.WriteLine(sum(5, 3)); // 8
class Calculator
{
    static int Add(int a, int b)
    {
        return a + b;
    }

    static void Main()
    {
        Func<int, int, int> operation = Add;
        Console.WriteLine(operation(10, 5)); // 15
    }
}

Навіщо це було потрібно?

Анонімні методи дозволяють:

  • Оголошувати логіку "на місці" без створення окремих методів
  • Зменшити кількість коду для простих операцій
  • Отримати доступ до локальних змінних (closure)
Історична перспектива: Анонімні методи були революційними в C# 2.0, але з появою лямбда-виразів у C# 3.0, вони стали рідше використовуватися через більш компактний синтаксис лямбд.

Лямбда-вирази (Lambda Expressions)

Лямбда-вирази (Lambda Expressions) — це сучасний та лаконічний спосіб створення анонімних функцій. Вони є еволюцією анонімних методів.

Синтаксис

Базова форма лямбда-виразу:

(parameters) => expression-or-statement-block
// Один вираз справа від =>
Func<int, int> square = x => x * x;

// Еквівалентно:
Func<int, int> squareLong = x => { return x * x; };

Варіації Синтаксису

LambdaSyntaxVariations.cs
// Без параметрів
Action sayHello = () => Console.WriteLine("Hello!");

// Один параметр (дужки необов'язкові)
Func<int, int> double = x => x * 2;

// Два і більше параметрів (дужки обов'язкові)
Func<int, int, int> multiply = (a, b) => a * b;

// Явна типізація параметрів
Func<int, int> explicitType = (int x) => x + 1;

// Ігнорування параметрів (C# 9.0+)
Func<int, int, int> ignoreSecond = (x, _) => x * 2;

Type Inference (Виведення Типів)

Компілятор може автоматично визначити тип делегата:

TypeInference.cs
// Компілятор виведе тип Func<string, int>
var parse = (string s) => int.Parse(s);

// Використання
int number = parse("42");
Console.WriteLine(number); // 42
Тип параметра s має бути явно вказаний, інакше компілятор не зможе вивести тип parse.

Closures (Замикання)

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

ClosureExample.cs
int multiplier = 5;

Func<int, int> multiplyByFive = x => x * multiplier;

Console.WriteLine(multiplyByFive(10)); // 50

multiplier = 10; // Змінюємо зовнішню змінну
Console.WriteLine(multiplyByFive(10)); // 100 — захоплена змінна!

Як це працює під капотом?

Компілятор створює прихований клас (display class) для зберігання захоплених змінних.

  1. Локальні змінні: Переносяться в поля generated класу.
  2. this: Якщо лямбда використовує члени класу, вона захоплює this, що може подовжити життя всього об'єкта.
CompilerGenerated.cs
// Ваш код:
class Calculator
{
    int _base = 10;

    public Func<int, int> GetMultiplier(int factor)
    {
        return x => x * factor + _base;
    }
}

// Що генерує компілятор (спрощено):
class Calculator
{
    int _base = 10;

    // Display Class для методу GetMultiplier
    sealed class <>c__DisplayClass0_0
    {
        public int factor;       // Захоплений локальний параметр
        public Calculator <>4__this; // Захоплене посилання на батьківський об'єкт

        public int <GetMultiplier>b__0(int x)
        {
            // Доступ до _base через захоплений this
            return x * factor + <>4__this._base;
        }
    }

    public Func<int, int> GetMultiplier(int factor)
    {
        var closure = new <>c__DisplayClass0_0();
        closure.factor = factor;
        closure.<>4__this = this; // УВАГА: Calculator не буде зібраний GC, поки живе делегат!

        return new Func<int, int>(closure.<GetMultiplier>b__0);
    }
}
Performance Impact: Кожне замикання створює новий об'єкт у кучі (heap allocation). У високопродуктивних циклах (hot paths) це створює тиск на GC.

Пастка змінної циклу (The Loop Variable Trap)

До C# 5.0 змінна циклу foreach оголошувалася зовні циклу. Це призводило до того, що всі лямбди захоплювали одну й ту ж змінну.

var actions = new List<Action>();
for (int i = 0; i < 3; i++) // 'i' одна на весь цикл
{
    actions.Add(() => Console.WriteLine(i));
}

foreach (var a in actions) a();
// Виведе: 3, 3, 3 (значення i після завершення циклу)

Чому так? Компілятор створює один екземпляр display class перед циклом, і в циклі просто оновлює його поле. Всі делегати посилаються на цей єдиний об'єкт.

Рішення: Створити локальну копію всередині блоку циклу.

for (int i = 0; i < 3; i++)
{
    int copy = i; // Нова змінна на кожній ітерації -> новий display class
    actions.Add(() => Console.WriteLine(copy));
}
Починаючи з C# 5.0, foreach автоматично створює нову змінну на кожній ітерації, тому там ця проблема зникла. Але для for вона все ще актуальна!

Обмеження (Limitations)

Лямбда-вирази не можуть захоплювати параметри ref, out або in. Причина: Ці параметри живуть на стеку (або є посиланнями на стек). Display class створюється в кучі (heap). Посилання з кучі на стек заборонені в .NET заради безпеки пам'яті (щоб уникнути доступу до звільненого фрейму стеку).

void TryCapture(ref int x)
{
    // Помилка компіляції CS1628: Cannot use ref, out, or in parameter inside an anonymous method
    Action act = () => Console.WriteLine(x);
}

Attributes on Lambdas (C# 10+)

Починаючи з C# 10, можна додавати атрибути до лямбда-виразів:

LambdaAttributes.cs
var parse = [return: NotNull] (string s) => int.Parse(s);

Func<string, int> parseWithAttribute =
    [Obsolete("Use TryParse instead")]
    (string s) => int.Parse(s);

Expression Trees (Дерева Виразів)

Лямбда-вирази можуть бути перетворені на дерева виразів (Expression<TDelegate>), що дозволяє аналізувати їх як дані:

ExpressionTree.cs
Expression<Func<int, int>> expression = x => x * x;

Console.WriteLine(expression); // x => (x * x)
Де це використовується?: LINQ to Entities, Entity Framework, Dapper та інші ORM використовують дерева виразів для перетворення C# виразів у SQL запити.

Теоретичні аспекти дерева виразів:

Дерева виразів представляють код у вигляді структури даних, яку можна аналізувати, модифікувати та перетворювати. На відміну від звичайних делегатів, які містять виконуваний IL-код, дерева виразів зберігають структуру самого виразу у вигляді об'єктів.

Коли ви створюєте Expression<TDelegate>, компілятор генерує не виконуваний код, а структуру об'єктів, що представляє вираз. Ця структура може бути:

  • Проаналізована для отримання інформації про вираз
  • Модифікована для зміни логіки
  • Перетворена в інші форми (наприклад, у SQL запити)
  • Скомпільована назад у делегат для виконання

Кожен вузол дерева виразу представляє елемент мови C# (наприклад, змінну, метод, оператор). Це дозволяє розробляти потужні системи, які можуть аналізувати та перетворювати код під час виконання, що особливо корисно для ORM, які перетворюють LINQ-запити в SQL.

Події (Events)

Події (Events) — це механізм, що дозволяє об'єктам повідомляти інші об'єкти про зміни стану або важливі події. Вони базуються на Publisher-Subscriber pattern (паттерн "Видавець-Підписник").

Publisher-Subscriber Pattern

Loading diagram...
sequenceDiagram
    participant P as Publisher (Button)
    participant S1 as Subscriber 1 (Logger)
    participant S2 as Subscriber 2 (UI Updater)

    S1->>P: Subscribe to Click event
    S2->>P: Subscribe to Click event

    Note over P: User clicks button
    P->>P: Raise Click event

    P->>S1: Invoke OnClick()
    S1-->>P: Log action

    P->>S2: Invoke OnClick()
    S2-->>P: Update UI

    Note over P,S2: Event processing complete

Оголошення та Використання Подій

EventBasicExample.cs
// 1. Оголосити делегат для події
public delegate void NotifyEventHandler(string message);

// Клас-видавець
public class FileProcessor
{
    // 2. Оголосити подію за допомогою ключового слова event
    public event NotifyEventHandler? FileProcessed;

    public void ProcessFile(string filename)
    {
        Console.WriteLine($"Обробка файлу: {filename}");

        // Симуляція обробки...

        // 3. Викликати подію (raising event)
        FileProcessed?.Invoke($"Файл {filename} оброблено");
    }
}

// Клас-підписник
public class Logger
{
    public void OnFileProcessed(string message)
    {
        Console.WriteLine($"[LOG] {message}");
    }
}

// Використання
class Program
{
    static void Main()
    {
        var processor = new FileProcessor();
        var logger = new Logger();

        // 4. Підписатися на подію
        processor.FileProcessed += logger.OnFileProcessed;

        processor.ProcessFile("data.txt");
        // Виведе:
        // Обробка файлу: data.txt
        // [LOG] Файл data.txt оброблено
    }
}

Стандартний Event Pattern

.NET має стандартний паттерн для подій: використання EventHandler делегата та EventArgs:

StandardEventPattern.cs
// Власний клас аргументів події
public class FileProcessedEventArgs : EventArgs
{
    public string FileName { get; }
    public long FileSize { get; }

    public FileProcessedEventArgs(string fileName, long fileSize)
    {
        FileName = fileName;
        FileSize = fileSize;
    }
}

// Клас-видавець
public class FileProcessor
{
    // Використання EventHandler<T>
    public event EventHandler<FileProcessedEventArgs>? FileProcessed;

    public void ProcessFile(string filename)
    {
        Console.WriteLine($"Обробка файлу: {filename}");

        long fileSize = 1024; // Симуляція

        // Виклик події з аргументами
        OnFileProcessed(new FileProcessedEventArgs(filename, fileSize));
    }

    // Protected virtual метод для виклику події (best practice)
    protected virtual void OnFileProcessed(FileProcessedEventArgs e)
    {
        FileProcessed?.Invoke(this, e);
    }
}

// Підписник
class Program
{
    static void Main()
    {
        var processor = new FileProcessor();

        // Підписка з використанням лямбда-виразу
        processor.FileProcessed += (sender, e) =>
        {
            Console.WriteLine($"Оброблено: {e.FileName} ({e.FileSize} bytes)");
        };

        processor.ProcessFile("document.pdf");
        // Виведе:
        // Обробка файлу: document.pdf
        // Оброблено: document.pdf (1024 bytes)
    }
}
Best Practice: Завжди створюйте protected virtual метод OnEventName для виклику події. Це дозволяє похідним класам перевизначати поведінку події.
Best Practice: Завжди створюйте protected virtual метод OnEventName для виклику події. Це дозволяє похідним класам перевизначати поведінку події.

Field-like vs Explicit Events

У C# є два способи оголошення подій:

  1. Field-like events (скорочений синтаксис):
public event EventHandler Click;
```

Компілятор автоматично створює приватне поле-делегат і методи `add`/`remove`.

2.  **Explicit events** (розгорнутий синтаксис):
Ви самі керуєте зберіганням делегата (наприклад, використовуючи `EventHandlerList` для економії пам'яті, якщо подій дуже багато, а підписників мало).

### Потокобезпечність (Thread Safety)

Field-like події є потокобезпечними за замовчуванням. Компілятор генерує методи `add` та `remove` з використанням `Interlocked.CompareExchange`.

Ось як виглядає код, який генерує компілятор для `add`:

```csharp
public void add_Click(EventHandler value)
{
EventHandler loop, current = this.Click;
do
{
    loop = current;
    // Об'єднуємо поточний список з новим делегатом
    EventHandler combined = (EventHandler)Delegate.Combine(loop, value);
    // Атомарно замінюємо старе значення новим, якщо ніхто не встиг змінити його
    current = Interlocked.CompareExchange(ref this.Click, combined, loop);
}
while (current != loop); // Якщо під час операції поле змінилося — повторюємо
}

Це гарантує, що якщо два потоки одночасно підписуються, жодна підписка не загубиться.

Важливо: Потокобезпечність стосується тільки підписки/відписки. Сам виклик події не є автоматично безпечним. Якщо потік А перевірив Click != null, а потік Б в цей момент відписав останнього підписника (зробивши Click null), то потік А впаде з NullReferenceException. Рішення: Click?.Invoke(...) або копіювання в локальну змінну var handler = Click; handler(...).

Витоки пам'яті (Memory Leaks) та Weak Events

Типова проблема подій — Lapsed Listener Problem. Якщо довгоживучий об'єкт (Publisher) має посилання на короткоживучий об'єкт (Subscriber) через подію, то Subscriber не буде зібраний GC, поки живе Publisher.

Рішення:

  1. Явна відписка (-=) у Dispose().
  2. Використання Weak Events (слабких посилань). Це дозволяє GC збирати підписника, навіть якщо він підписаний на подію. У сучасному .NET це часто реалізується через WeakReference або спеціальні бібліотеки (наприклад, WeakEventManager у WPF).

Теоретичні аспекти продуктивності делегатів:

Використання делегатів та лямбда-виразів має певні витрати на продуктивність, які важливо враховувати:

  1. Виклик делегата: Виклик через делегат трохи повільніше за прямий виклик методу через невелику накладну механіку виклику та перевірки на null.
  2. Closures (замикання): Кожне замикання створює додатковий об'єкт у кучі, що збільшує навантаження на GC. Це особливо важливо в циклах та високопродуктивних сценаріях.
  3. Multicast делегати: Виклик multicast делегата вимагає послідовного виклику всіх методів у списку викликів, що збільшує час виконання пропорційно кількості підписників.
  4. Створення лямбда-виразів: Якщо лямбда-вираз створюється в циклі або часто викликається, це може призводити до створення багатьох тимчасових об'єктів, особливо якщо використовуються замикання.
  5. Expression trees: Перетворення лямбда-виразів в expression trees відбувається під час компіляції і має нульову вартість виконання, але аналіз та перетворення expression trees в інші форми (наприклад, SQL) відбувається під час виконання і має відповідні витрати.

Теоретичні аспекти потокобезпеки та продуктивності:

Під капотом, безпечна підписка/відписка від подій використовує алгоритм CAS (Compare-And-Swap), який є неблокуючим. Це означає, що коли кілька потоків одночасно намагаються змінити список підписників, вони не блокуються, а замість цього повторюють операцію до успішного завершення.

Проте, це створює певні накладні витрати на продуктивність:

  • Потокобезпечна підписка вимагає додаткових операцій порівняння та об'єднання делегатів
  • У високонавантажених сценаріях, коли багато потоків одночасно підписуються/відписуються, може виникати багаторазове повторення операцій
  • Виклик події, особливо з багатьма підписниками, вимагає атомарного читання списку підписників, що також має певну ціну

Для високопродуктивних сценаріїв існують альтернативні підходи, такі як використання lock-free черг подій або спеціалізованих бібліотек для управління подіями.

Custom Event Accessors

Іноді потрібен більший контроль над підпискою/відпискою:

CustomEventAccessors.cs
public class Button
{
    private EventHandler? _click;

    public event EventHandler Click
    {
        add
        {
            Console.WriteLine("Підписка на Click");
            _click += value;
        }
        remove
        {
            Console.WriteLine("Відписка від Click");
            _click -= value;
        }
    }

    public void SimulateClick()
    {
        _click?.Invoke(this, EventArgs.Empty);
    }
}

Практичні Приклади

Приклад 1: Система Логування з Пріоритетами

Приклад 2: Calculator з різними операціями

DelegateCalculator.cs
public class Calculator
{
    public int Execute(int a, int b, Func<int, int, int> operation)
    {
        return operation(a, b);
    }
}

class Program
{
    static void Main()
    {
        var calc = new Calculator();

        // Різні операції як делегати
        Func<int, int, int> add = (x, y) => x + y;
        Func<int, int, int> subtract = (x, y) => x - y;
        Func<int, int, int> multiply = (x, y) => x * y;

        Console.WriteLine(calc.Execute(10, 5, add));      // 15
        Console.WriteLine(calc.Execute(10, 5, subtract)); // 5
        Console.WriteLine(calc.Execute(10, 5, multiply)); // 50

        // Власна операція
        Console.WriteLine(calc.Execute(10, 5, (x, y) => x % y)); // 0
    }
}

Приклад 3: Observer Pattern з Events

ObserverPattern.cs
// Subject (Observable)
public class Stock
{
    private decimal _price;

    public string Symbol { get; }
    public decimal Price
    {
        get => _price;
        set
        {
            if (_price != value)
            {
                _price = value;
                OnPriceChanged(new PriceChangedEventArgs(_price));
            }
        }
    }

    public event EventHandler<PriceChangedEventArgs>? PriceChanged;

    public Stock(string symbol, decimal initialPrice)
    {
        Symbol = symbol;
        _price = initialPrice;
    }

    protected virtual void OnPriceChanged(PriceChangedEventArgs e)
    {
        PriceChanged?.Invoke(this, e);
    }
}

public class PriceChangedEventArgs : EventArgs
{
    public decimal NewPrice { get; }

    public PriceChangedEventArgs(decimal newPrice)
    {
        NewPrice = newPrice;
    }
}

// Observer
public class StockMonitor
{
    public void Subscribe(Stock stock)
    {
        stock.PriceChanged += OnPriceChanged;
    }

    private void OnPriceChanged(object? sender, PriceChangedEventArgs e)
    {
        if (sender is Stock stock)
        {
            Console.WriteLine($"{stock.Symbol}: {e.NewPrice:C}");
        }
    }
}

// Використання
class Program
{
    static void Main()
    {
        var appleStock = new Stock("AAPL", 150.00m);
        var monitor = new StockMonitor();

        monitor.Subscribe(appleStock);

        appleStock.Price = 155.50m; // AAPL: $155.50
        appleStock.Price = 160.00m; // AAPL: $160.00
    }
}

Troubleshooting (Типові Проблеми)

1. NullReferenceException при виклику делегата

Проблема:
Action myAction = null;
myAction(); // ❌ NullReferenceException
Рішення:
// Використання null-conditional operator
myAction?.Invoke();

// Або перевірка перед викликом
if (myAction != null)
{
    myAction();
}

2. Memory Leak через події

Проблема: Підписка на події створює сильне посилання, що запобігає збору пам'яті:
public class LongLivedPublisher
{
    public event EventHandler? SomeEvent;
}

public class ShortLivedSubscriber
{
    public ShortLivedSubscriber(LongLivedPublisher publisher)
    {
        publisher.SomeEvent += OnSomeEvent;
        // ❌ Підписник ніколи не звільниться!
    }

    private void OnSomeEvent(object? sender, EventArgs e) { }
}
Рішення: Завжди відписуйтесь або використовуйте weak events:
public class ShortLivedSubscriber : IDisposable
{
    private readonly LongLivedPublisher _publisher;

    public ShortLivedSubscriber(LongLivedPublisher publisher)
    {
        _publisher = publisher;
        _publisher.SomeEvent += OnSomeEvent;
    }

    public void Dispose()
    {
        _publisher.SomeEvent -= OnSomeEvent;
    }

    private void OnSomeEvent(object? sender, EventArgs e) { }
}

3. Захоплення змінної циклу (Loop Variable Capture)

Проблема:
var actions = new List<Action>();

for (int i = 0; i < 5; i++)
{
    actions.Add(() => Console.WriteLine(i));
}

actions[0](); // 5, а не 0!
actions[4](); // 5
Рішення:
for (int i = 0; i < 5; i++)
{
    int localI = i; // Локальна копія
    actions.Add(() => Console.WriteLine(localI));
}

actions[0](); // 0
actions[4](); // 4

4. Multicast Delegate з Return Value

Проблема: При multicast делегаті тільки останнє значення повертається:
Func<int> getNumber = () => 1;
getNumber += () => 2;
getNumber += () => 3;

int result = getNumber(); // result = 3 (тільки останній!)
Якщо потрібні всі результати:
Func<int> getNumber = () => 1;
getNumber += () => 2;
getNumber += () => 3;

var results = new List<int>();
foreach (Func<int> func in getNumber.GetInvocationList())
{
    results.Add(func());
}

Console.WriteLine(string.Join(", ", results)); // 1, 2, 3

Практика (Practice Tasks)

Рівень 1: Початківець

Рівень 2: Середній

Рівень 3: Просунутий

Резюме

Делегати

Типобезпечні покажчики на методи. Використовуйте для callback-функцій, event handlers та стратегій.

Action & Func

Вбудовані універсальні делегати. Action для void методів, Func для методів з результатом.

Лямбда-вирази

Лаконічний синтаксис для анонімних функцій. Підтримують closures та type inference.

Події

Publisher-Subscriber pattern для loose coupling. Використовуйте EventHandler<T> та EventArgs.

Ключові Takeaways

  • Делегати дозволяють передавати методи як параметри та створювати гнучкі API
  • Multicast delegates викликають декілька методів послідовно
  • Action/Func/Predicate замінюють більшість кастомних делегатів
  • Лямбда-вирази — це сучасний спосіб створення анонімних функцій
  • Closures захоплюють змінні з зовнішньої області видимості (будьте обережні!)
  • Події забезпечують loose coupling через Publisher-Subscriber pattern
  • Завжди відписуйтесь від подій, щоб уникнути memory leaks

Узагальнююча таблиця концепцій:

КонцепціяПризначенняПриклад використанняПеревагиНедоліки
ДелегатиТипобезпечні покажчики на методиFunc<int, int> square = x => x * x;Типобезпека, гнучкістьНевеликі накладні витрати на виклик
Multicast делегатиВиклик декількох методівlogger += ConsoleLog; logger += FileLog;Можливість одночасного виклику кількох методівПовертається лише результат останнього методу
Лямбда-виразиЛаконічний синтаксис для анонімних функційnumbers.Where(x => x > 5)Компактність, виведення типівМожуть створювати замикання, що впливає на GC
ПодіїПаттерн Publisher-Subscriberbutton.Click += OnButtonClick;Loose coupling, безпечне сповіщенняПотенційні витоки пам'яті при неправильному використанні
Expression TreesАналіз та перетворення коду в структуруExpression<Func<int, int>> expr = x => x * 2;Можливість аналізу та перетворення виразівДодаткові витрати на компіляцію назад у делегат
Наступні кроки: Ознайомтеся з Інтерфейсами та LINQ, де делегати та лямбда-вирази широко використовуються.

Додаткові Ресурси