1. Вступ
Напевно, у школі вам казали: «Це функція від функції». Функції вищого порядку (Higher-Order Functions, HOF) — це функції, які приймають інші функції як аргументи, повертають функції як результат або роблять і те, і інше.
Простими словами: якщо ваш метод може отримати як параметр іншу функцію (наприклад, делегат або лямбду) чи повернути її як результат — вітаємо, у вас функція вищого порядку!
Функція вищого порядку — мов майстер-ключ, що може не лише сам відчинити двері, а й передати вам інший ключ, аби ви відчинили потрібні двері пізніше.
Застосування в реальному житті
Усе це звучить цікаво й нетривіально. Але навіщо такі прийоми потрібні в повсякденній роботі розробника у C#? Ось найкоротша відповідь:
Функції вищого порядку роблять код гнучким, багаторазово придатним і лаконічним.
Це основа для таких речей, як LINQ, фільтрація та сортування колекцій, побудова пайплайнів обробки даних, налаштування callback-ів, подій і навіть інʼєкції залежностей.
Кілька реальних сценаріїв:
- Обмежити поведінку методу, передавши туди потрібну логіку (наприклад, фільтрацію, сортування, перетворення).
- Написати універсальний обробник даних, у який ви вбудовуєте потрібну операцію через делегат.
- Побудувати ланцюжки обробки («пайплайни»), де кожна функція модифікує дані за власним рецептом.
- Створювати кросплатформенні абстракції: що робити у Windows, що — у Linux? Просто передайте потрібну функцію.
2. Приклади простих функцій вищого порядку
Функція, що приймає іншу функцію
Найкласичніший приклад — метод, що приймає делегат або лямбду.
// Функція вищого порядку: приймає функцію process як параметр
void ForEach<T>(IEnumerable<T> collection, Action<T> process)
{
foreach (var item in collection)
{
process(item); // Викликаємо функцію-аргумент
}
}
// Використання:
var numbers = new List<int> { 1, 2, 3 };
ForEach(numbers, n => Console.WriteLine($"Елемент: {n}"));
Що тут відбувається? Метод ForEach не знає, що саме робити з кожним елементом. Усе, що він робить, — викликає переданий процесор (process). Цей процесор може бути будь-яким — друкувати на екран, зберігати до бази, малювати на екрані тощо.
Так — саме так працює метод ForEach у колекціях C#, і майже всі методи LINQ — це функції вищого порядку!
Функція, що повертає функцію
А тепер приклад складніший — метод, який не лише приймає, а й повертає функцію.
// Функція вищого порядку: повертає іншу функцію
Func<int, int> CreateMultiplier(int factor)
{
// Повертаємо лямбду, що використовує змінну factor
return x => x * factor;
}
// Використання:
var multiplyBy10 = CreateMultiplier(10);
Console.WriteLine(multiplyBy10(7)); // 70
Тут CreateMultiplier повертає функцію, що множить свій аргумент на заздалегідь заданий множник. Це вже не просто HOF, а приклад «фабрики функцій».
Функція, що приймає і повертає функцію
Func<int, int> Compose(Func<int, int> f, Func<int, int> g)
{
// Поверне функцію, що застосовує спочатку g, а потім f: f(g(x))
return x => f(g(x));
}
// Використання:
Func<int, int> increment = x => x + 1;
Func<int, int> doubleIt = x => x * 2;
var incrementThenDouble = Compose(doubleIt, increment);
Console.WriteLine(incrementThenDouble(5)); // (5 + 1) * 2 = 12
Саме такі композиції лежать в основі конвеєрної обробки даних — наприклад, коли ви обробляєте масив методами Select, Where, OrderBy тощо.
3. Як C# підтримує функції вищого порядку
У функціональних мовах (Haskell, F#) усі функції — вищого порядку за замовчуванням. Але й C# (починаючи з версії 2.0) підтримує цей підхід завдяки делегатам і лямбдам.
- Делегати (Func, Action, Predicate) — типи функцій.
- Лямбда-вирази — синтаксис для створення функцій просто на місці.
- Методи можуть приймати й повертати делегати — отже, функції вищого порядку підтримуються «з коробки».
Візуальна схема
flowchart LR
A[Дані] --> B[Функція 1]
B --> C[Функція 2]
C --> D[Результат]
subgraph "Пайплайн обробки (функції вищого порядку)"
B
C
end
4. Розвиваємо наш застосунок
Продовжімо розвивати наш покроковий демозастосунок — нехай це буде «Міні-обробник рядків».
Додамо простий метод вищого порядку
Уявімо, що в нас є список користувацьких імен, і ми хочемо довільно модифікувати їх за допомогою функцій.
// Метод, що приймає список рядків і функцію для перетворення
List<string> TransformNames(List<string> names, Func<string, string> transformer)
{
var result = new List<string>();
foreach (var name in names)
{
result.Add(transformer(name));
}
return result;
}
Як використовувати цей метод?
var names = new List<string> { "Анна", "Борис", "Сергій" };
// Перетворюємо у верхній регістр
var upperNames = TransformNames(names, n => n.ToUpper());
// Додаймо «пан/пані» до кожного імені
var politeNames = TransformNames(names, n => "Шановний(а) " + n);
foreach (var n in upperNames)
Console.WriteLine(n); // АННА, БОРИС, СЕРГІЙ
foreach (var n in politeNames)
Console.WriteLine(n); // Шановний(а) Анна, ...
Метод TransformNames універсальний: він делегує логіку перетворення переданому параметрові (transformer), наприклад виклику ToUpper або будь-якому іншому рецепту.
Адаптація до типів
Наш приклад легко адаптувати для будь-яких типів даних.
// Універсальний типовий метод — функція вищого порядку, що працює з будь-яким T
List<TResult> Map<T, TResult>(List<T> items, Func<T, TResult> transformer)
{
var result = new List<TResult>();
foreach (var item in items)
{
result.Add(transformer(item));
}
return result;
}
Застосування:
var numbers = new List<int> { 1, 2, 3 };
var doubled = Map(numbers, x => x * 2); // [2, 4, 6]
var strings = Map(numbers, x => $"Число: {x}"); // ["Число: 1", ...]
5. Фільтрація й агрегація через функції вищого порядку
Логіку фільтрації й пошуку вже давно реалізують через функції вищого порядку.
// Фільтрація: функція вищого порядку
List<T> Filter<T>(List<T> items, Predicate<T> criteria)
{
var result = new List<T>();
foreach (var item in items)
{
if (criteria(item)) // Викликаємо функцію-критерій
{
result.Add(item);
}
}
return result;
}
Як використовувати:
var names = new List<string> { "Анна", "Борис", "Андрій" };
var aNames = Filter(names, n => n.StartsWith("А"));
// Результат: "Анна", "Андрій"
6. Поняття композиції функцій (function composition)
Функції вищого порядку дозволяють не лише використовувати окремі функції, а й збирати їх у ланцюжки — так звані композиції. У C# це можна реалізувати як функцію, що приймає дві функції й повертає нову — їхню комбінацію.
// Композитор функцій: повертає функцію, яка застосовує спочатку f, потім g
Func<T, TResult> Compose<T, TIntermediate, TResult>(
Func<TIntermediate, TResult> f,
Func<T, TIntermediate> g)
{
return x => f(g(x));
}
// Приклад:
Func<int, int> plusOne = n => n + 1;
Func<int, int> timesTwo = n => n * 2;
var plusOneThenDouble = Compose(timesTwo, plusOne);
Console.WriteLine(plusOneThenDouble(3)); // (3 + 1) * 2 = 8
7. Корисні нюанси
Передісторія проблеми: чому раніше все було складніше?
До появи делегатів і лямбд розробники часто писали безліч однотипних циклів, копіювали шматки коду, щоб «відфільтрувати», «змінити» або «згрупувати» дані. Із появою функцій вищого порядку стало можливим винести змінну частину поведінки в окремі функції-параметри — і таким чином радикально скоротити дублювання та підвищити виразність коду.
Трохи синтаксичного цукру: функції як вирази
Функції вищого порядку часто реалізують через expression-bodied методи — лаконічні однорядкові методи:
List<string> FilterNames(Predicate<string> pred) =>
Names.Where(name => pred(name)).ToList();
List<TResult> MapNames<TResult>(Func<string, TResult> transformer) =>
Names.Select(transformer).ToList();
Порівняння з механізмами LINQ
Подивімося, як LINQ використовує функції вищого порядку:
| Метод LINQ | Який делегат приймає | Призначення |
|---|---|---|
|
|
Фільтрує елементи |
|
|
Перетворює елементи |
|
|
Сортує за ключем |
|
|
Агрегує (згортає) колекцію |
|
|
Перевіряє наявність елемента за умовою |
Усі ці методи побудовані навколо ідеї функцій вищого порядку: ви формулюєте власні правила, а стандартна бібліотека забезпечує «інфраструктуру».
8. Можливі помилки та підводні камені
Плутанина із типами делегатів.
Спочатку може бути складно розібратися, де потрібен Action, де Func, а де Predicate.
Підказка: якщо функція повертає bool — найімовірніше, це Predicate. Якщо повертає значення — використовуйте Func, якщо нічого не повертає — Action.
Захоплення змінних (замикання).
Якщо повернена функція використовує змінні із зовнішнього оточення, стежте, щоб значення були актуальні на момент виклику. Змінні не копіюються, а «захоплюються».
Налагодження складних ланцюжків.
Коли функції компонуються в довгі пайплайни, стає складно зрозуміти, який шар обробив дані не так. Додавайте тимчасовий вивід у консоль і коментарі:
n => {
Console.WriteLine("До операції:" + n);
var res = n * 2;
Console.WriteLine("Після:" + res);
return res;
}
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ