Кожна стаття, яку я бачив, що говорить про делегати, завжди починається з питання "що таке делегати?" і продовжується чимось на кшталт: "це спосіб передавати методи методам". Хоча це вірно, насправді важко зрозуміти, про що йдеться. Звідки вони з'являються?
Якщо ви маєте таке відчуття, ви не самотні. Ось чому я пишу цю статтю. Почнемо!
Я хочу почати з змінних. Так, ви правильно прочитали — змінних!
Ми всі знаємо, що змінні можуть бути або типами за значенням, або типами за посиланням. Нас цікавлять змінні типів за посиланням. Є два варіанти змінних типів за посиланням:
• Вбудовані змінні
• Користувацькі змінні
Прикладами вбудованих змінних є такі, як string, це псевдонім для класу System.String.
Прикладами користувацьких типів є класи, які ми використовуємо для створення об'єктів, що містять користувацькі дані та методи.
Загальна риса між цими двома типами — це спосіб їх зберігання. Кожна змінна містить посилання/місцезнаходження об'єкта, який зберігається в пам'яті.
Ми звикли бачити щось на кшталт цього:
• Person x = new Person();
X — це змінна, яка утримує місцезнаходження об'єкта person в пам'яті.
Ми дуже комфортно почуваємось із такими об'єктами, але що якщо я скажу, що ми можемо використовувати змінні посилання для зберігання методів? 🙂
Ви можете почуватися незручно і задати ці два питання:
• Як? Ми будемо використовувати користувацькі типи чи вбудовані типи?
• Чому?
Відповідь на друге питання — це одне слово, до якого ми повернемося після того, як дуже чітко відповімо на перше питання.
Це Callback (зворотний виклик)!
Тепер давайте відповімо на питання "Як?"
Так само, як у нас є ключове слово string для вбудованих типів, які зберігають текст, у нас є ключове слово delegate для вбудованих типів, які зберігають методи.
Щоб використати це ключове слово, треба зробити два кроки:
-
Оголосити підпис методу, який ми хочемо, щоб змінна делегата утримувала.
-
Призначити реальний метод для цієї змінної делегата. Цей реальний метод може бути одним з 3-х варіантів:
• Іменований метод
• Анонімний метод
• Лямбда-вираз
Візьмемо приклад. Припустимо, у нас є такий метод:
public static void PrintToConsole(string message)
{
Console.WriteLine("Console: " + message);
}
Що ми хочемо — це змінна, яка зможе утримувати методи, тому давайте скористаємося нашими 2-ма кроками:
- Який підпис методу ми хочемо утримувати? Ну, це PrintToConsole. Зачекайте, я сказав підпис, а не ім'я! 😞
Отже, це метод, який приймає один параметр і нічого не повертає.
Тож давайте оголосимо нашу змінну:
(delegatekeyword) variablename (string x)
Отже, ми маємо:
delegate void PrintToConsoleDelegate(string x);
- Тепер давайте призначимо ім'я методу:
PrintToConsoleDelegate printDelegate = PrintToConsole;
Після визначення делегата, що ми можемо з ним зробити? Багато чого, але поки що зробимо одне: викличемо делегат.
printDelegate("Hello, this is a delegate invoke");
Давайте з'єднаємо все, щоб побачити повну картину:
class Program
{
delegate void PrintToConsoleDelegate(string x);
public static void Main()
{
PrintToConsoleDelegate printDelegate = PrintToConsole;
printDelegate("Hello, this is a delegate invoke");
}
static void PrintToConsole(string message)
{
Console.WriteLine(message);
}
}
Запустіть проєкт, і ви побачите чорне вікно консолі з таким текстом: "Hello, this is a delegate invoke"
Ми вже згадували, що на другому етапі треба призначити метод, і ми згадали 3 способи призначення методу. Ми побачили перший спосіб, який був іменованим методом. Тепер давайте подивимося на другий спосіб: Анонімний метод.
Перед тим, як створити його, ідея дуже проста. Якщо ви не хочете створювати конкретний метод для вашого делегата і хочете використовувати метод лише для делегатів, створіть анонімний.
Давайте перерахуємо попередній приклад ще раз, не визначаючи жодного методу явно.
Давайте дотримуватись наших 2 кроків:
delegate void PrintToConsoleDelegate(string x);
PrintToConsoleDelegate printDelegate = delegate(string message)
{
Console.WriteLine("Console: " + message);
};
Спосіб виклику тут такий самий, як і в попередньому прикладі.
Третій спосіб — це більш лаконічний спосіб оголошення методів, а саме лямбда-вирази. Отже, без зайвих слів, ось наші 2 кроки:
delegate void PrintToConsoleDelegate(string x);
PrintToConsoleDelegate printDelegate = (string x) => Console.WriteLine("Console: " + x);
Вітаємо! Тепер ми знаємо, що таке делегати, як їх створювати та як призначати методи для них трьома способами.
Можна зупинитись на цьому, якщо ви хочете перевести дух. Наступне поняття — це Попередньо визначені делегати.
До цього часу ми створювали власні делегати, але уявіть, як би це було важко, якби в нашому коді було 100 методів, які потрібно було б призначити до змінних делегатів. Нам довелося б створювати 100 змінних делегатів, що є абсурдним і робить код схожим на локшину.
Тому Microsoft допомогла нам з попередньо визначеними делегатами. Що це означає? Пам'ятаєте наші 2 кроки? Здається, що перший крок нам вже не потрібен (так!). Нам потрібен тільки другий крок.
Який захват! Де ці попередньо визначені делегати? Скільки їх є? Як вони охоплюють всі підписи методів?
Ну, є 3 попередньо визначених делегата:
• Action: Використовуйте його, коли у вас є метод в коді, який приймає один параметр (неважливо його тип) і не повертає значення.
• Func: Використовуйте його, коли у вас є метод в коді, який приймає один параметр (до 16) і повертає результат.
• Predicate: Використовуйте його, коли у вас є метод в коді, який приймає один параметр і повертає логічне значення.
Можливо, ви запитаєте, чому у нас є Predicate — чи не те ж саме, що і Func?
Так, це так, але чому б і ні? 😉
Давайте знову подивимося на наш приклад. Наш підпис той самий: це метод, який приймає один параметр і нічого не повертає. Тож який з попередньо визначених типів делегатів підходить? Це Action.
Отже, наш код оновлюється так:
Action printDelegate = (string x) => Console.WriteLine("Console: " + x);
Хіба це не простіше? Використовувати попередньо визначені делегати з лямбда-виразами дійсно весело, і саме так побудований LINQ. Ми до цього ще дійдемо. 🙂
Давайте визначимо ще один метод, щоб концепція попередньо визначених делегатів краще запам'яталася.
Припустимо, у нас є цей метод:
public int Add(int x, int y)
{
return x + y;
}
Якщо ми хочемо призначити його до змінної делегата, це буде Func.
Отже, це виглядає ось так:
Func<int, int, int> addDelegate = (int x, int y) => x + y;
Тепер ми пройшли довгий шлях, і ви, мабуть, думаєте, що я забув про друге питання: навіщо вони нам потрібні? Сподіваюся, ви пам'ятаєте мою коротку відповідь: Callback (зворотний виклик).
По-перше, що таке callback? Це частина виконуваного коду (метод), яка передається як аргумент іншому коду (іншому методу). Інший код очікує, що виконає цей шматок коду в зручний час.
Все це через callback? 😞 Чому вони важливі?
Насправді, вони надзвичайно важливі! Callbacks дозволяють нам підключати новий код до існуючого коду.
Тому ми можемо інжектувати поведінку (метод), яку нам потрібно, у будь-яку існуючу частину коду, що робить код більш гнучким (динамічний виклик, різні стратегії під час виконання) і застосовує принцип відкритості/закритості. Існуючий код не відкритий для редагування; ми можемо додавати будь-який новий код, не торкаючись старого.
Давайте подивимося на приклад. Припустимо, у нас є цей метод:
public void ProcessData(string data)
{
var processedData = data.ToUpper();
}
У нас є цей метод, і наш клієнт, який хоче використовувати цей метод, хоче мати можливість вивести оброблені дані на консоль чи в файл.
Один зі способів, який ми могли б використати, це додати булевий прапорець у підпис методу, який вказує, як саме ми виводимо дані.
Отже, ми отримаємо щось на кшталт цього:
public void ProcessData(string data, bool isFile)
{
var processedData = data.ToUpper();
if (isFile)
Console.WriteLine("File: " + processedData);
else
Console.WriteLine("Console: " + processedData);
}
Згодом клієнт прийшов і сказав, що вони хочуть додати ще одну стратегію виведення. Тепер нам потрібно оновити метод і додати ще один додатковий блок, що робить наш код більш вразливим і може призвести до помилок, якщо ми випадково пропустимо одну з умов. Крім того, якщо у вас є SonarQube чи будь-який інший інструмент для аналізу складності коду, він вимагатиме спростити ці блоки (це називається ‘Cyclomatic complexity’).
Давайте подумаємо інакше. Ми хочемо передавати різні стратегії виведення під час виконання і залишити контроль у руках клієнта.
Отже, клієнт хоче, щоб ми обробляли різні стратегії виведення в залежності від поведінки, яку вони хочуть зараз чи можуть захотіти пізніше. Коли ви чуєте "стратегія під час виконання", подумайте про делегати, навіть якщо це не завжди так. Спробуйте!
Тепер питання: якщо ми думаємо про делегати, як виглядатиме ця поведінка? Нам потрібно передати оброблені дані до методу, який нічого не повертає. Я бачу, що це те саме, що і делегат Action.
Давайте подивимося, як ми це реалізуємо:
public void ProcessData(string data, Action printStrategy)
{
var processedData = data.ToUpper();
printStrategy(processedData);
}
Як ми це викликаємо? Клієнт напише код такого вигляду:
ProcessData("Delegates article", x => Console.WriteLine("Console: " + x));
Наскільки це зручно і гнучко? 🙂
Отже, тепер у нас є розуміння того, як використовувати делегати як зворотні виклики (callbacks), але чому ж не можна просто писати спагеті-код і забути про все це?
Ну, ви можете так зробити, але проблема в тому, що ви будете зустрічати делегати кожного дня у вашій роботі, а хороший розробник — це той, хто знає, що він кодить.
Маю впевненість, що ви бачили щось на кшталт цього:
var list = list.Where(x => x == 5).ToList();
var orders = collection.Select(x => new Order { }).ToList();
users.ForEach(x => x.UpdatedDate = DateTime.Now);
Це операції LINQ, які побудовані на основі делегатів і лямбда-виразів. Чому делегати? Тому що саме так ми можемо передавати необхідну поведінку всередину їхніх методів.
Делегати зустрічаються в різних ситуаціях і численних прикладах, але я вірю, що якщо ви зрозумієте основну ідею цього механізму, ви зрозумієте все, що побудовано на його основі.
Дякую за увагу!
Перекладено з: From Confusion to Clarity: Delegates Explained Right