1. Введение
Переходя от теории к практике, логично задаться вопросом: «А зачем мне, разработчику на .NET и C#, все эти приёмы функционального программирования?»
Действительно, C# — не чисто функциональный язык вроде F# или Haskell. Но начиная с версии 3.0 и вплоть до C# 14, он обзавёлся множеством инструментов ФП, которые могут серьёзно поднять качество и выразительность кода.
Вот где они особенно хорошо работают:
- Работа с коллекциями — LINQ, Map/Reduce, фильтрация, агрегация, сортировка и прочая «магия» с данными.
- Чистые функции — меньше багов из-за состояния и побочных эффектов, проще отлаживать.
- Функции высшего порядка — универсальные, переиспользуемые компоненты, с которыми приятно работать.
- Неизменяемость в многопоточности — один из главных залогов безопасного кода в параллельных и асинхронных сценариях.
- Композиция функций — делает сложную бизнес-логику лаконичной, читаемой и удобной для тестирования.
Таблица: Сравнение ООП-подхода и ФП-подхода в C#
| Задача | Императивно (ООП/старая школа) | Функционально (ФП) |
|---|---|---|
| Фильтрация списка | |
|
| Преобразование списка | |
|
| Поиск по критерию | |
|
| Агрегирование | |
|
| Кеширование | |
|
2. LINQ: самое функциональное в C#
Если вам показалось, что “функциональное программирование” — это про списки, фильтры и всякие .Where, .Select, .Aggregate, — поздравляю: это действительно так! LINQ — квинтэссенция ФП в C#.
Давайте вспомним, как LINQ устроен
LINQ оперирует коллекциями с помощью цепочек методов, принимающих функции-параметры (например, лямбды). Например:
var numbers = new List<int> { 1, 2, 3, 4, 5 };
// Получим только чётные числа и удвоим их
var result = numbers
.Where(x => x % 2 == 0)
.Select(x => x * 2);
foreach (var number in result)
Console.WriteLine(number);
Что тут происходит?
- .Where — функция высшего порядка: принимает функцию (x => x % 2 == 0) и возвращает другую коллекцию.
- .Select — тоже принимает функцию (x => x * 2).
- Мы не изменяем исходную коллекцию, а получаем новый результат.
Этот стиль удобно читать и расширять (можно прицепить ещё .OrderBy, .Take, .Distinct и т.д.).
Обратите внимание! Лямбда-выражения — удобный способ создать «делегат на лету». LINQ был бы невозможен без поддержки ФП в C#.
Схема: Функциональная обработка коллекции
Коллекция --> Where(x => bool) --> Select(x => y) --> Новый результат
3. Композиция функций и конвейер обработки данных
ФП часто используют композицию: сложная операция строится как цепочка маленьких функций, каждая делает свою работу.
Пример: цепочка обработки строки
Стиль с изменением состояния (ООП):
string s = " hello world ";
s = s.Trim();
s = s.ToUpper();
s = s + "!";
Console.WriteLine(s); // HELLO WORLD!
Более «функционально» — как конвейер функций:
Func<string, string> trim = x => x.Trim();
Func<string, string> upper = x => x.ToUpper();
Func<string, string> addBang = x => x + "!";
// Композиция функций — последовательно применяем их
Func<string, string> pipeline = x => addBang(upper(trim(x)));
Console.WriteLine(pipeline(" hello world ")); // HELLO WORLD!
Простейший комбинатор композиции:
Func<T, R> Compose<T, U, R>(Func<T, U> f, Func<U, R> g) =>
x => g(f(x));
// А теперь pipeline через Compose:
var pipeline2 = Compose(trim, upper);
pipeline2 = Compose(pipeline2, addBang);
Console.WriteLine(pipeline2(" hello again ")); // HELLO AGAIN!
4. Работа с неизменяемостью: защита от багов
Иммутабельность — основной кирпич ФП. Мы не изменяем структуру данных, а возвращаем новую. Это особенно важно в многопоточных приложениях.
Пример: «неправильно» (мутабельно)
List<int> numbers = new List<int> { 1, 2, 3 };
numbers[0] = 42;
Пример: «правильно» (функционально)
var numbers = new List<int> { 1, 2, 3 };
var newNumbers = numbers.Select((x, i) => i == 0 ? 42 : x).ToList();
В современном C# есть коллекции ImmutableList<T> и другие типы из пространства имён System.Collections.Immutable:
using System.Collections.Immutable;
var immutableNumbers = ImmutableList.Create(1, 2, 3);
var changed = immutableNumbers.SetItem(0, 42); // Возвращает новый список!
5. Функции высшего порядка в реальной жизни
Функции высшего порядка — способ писать универсальные компоненты без множества условных операторов.
Пример: Универсальный фильтр для пользователей
class User
{
public string Name { get; set; }
public int Age { get; set; }
}
var users = new List<User>
{
new User { Name = "Вася", Age = 26 },
new User { Name = "Катя", Age = 17 },
new User { Name = "Лёша", Age = 35 }
};
List<User> FilterUsers(List<User> source, Predicate<User> predicate)
{
return source.Where(u => predicate(u)).ToList();
}
// Использование:
var adults = FilterUsers(users, u => u.Age >= 18);
var longNames = FilterUsers(users, u => u.Name.Length > 3);
6. Pattern matching и switch-выражения
Современный C# активно использует сопоставление с образцом: switch-выражения часто заменяют тяжелые цепочки if.
object value = 123;
string description = value switch
{
int i when i > 100 => "Большое число",
string s when s.Length > 3 => "Строка длинная",
null => "Пустое значение",
_ => "Неизвестно"
};
Console.WriteLine(description); // Большое число
7. Мемоизация: кеширование результатов функций
Мемоизация — кеширование результата функции для одинаковых аргументов. В C# её легко реализовать самостоятельно.
Func<int, int> SlowFib = null; // Рекурсивная функция Фибоначчи
var cache = new Dictionary<int, int>();
SlowFib = n =>
{
if (cache.ContainsKey(n))
return cache[n];
if (n <= 1)
cache[n] = n;
else
cache[n] = SlowFib(n - 1) + SlowFib(n - 2);
return cache[n];
};
Console.WriteLine(SlowFib(40)); // Молниеносно!
8. Каррирование и частичное применение
Частичное применение — фиксация части аргументов функции. В C# это удобно делать лямбдами.
Func<int, int, int> add = (a, b) => a + b;
// Фиксируем первый аргумент
Func<int, int> add10 = b => add(10, b);
Console.WriteLine(add10(5)); // 15
Console.WriteLine(add10(100)); // 110
9. Декларативный стиль с функциями
Императивно:
var result = new List<int>();
foreach (var n in numbers)
{
if (n > 0)
result.Add(n * n);
}
Декларативно:
var result = numbers
.Where(n => n > 0)
.Select(n => n * n)
.ToList();
10. Практическая задача
Реализуем модуль фильтрации задач в «менеджере задач для студентов».
Модель:
class StudentTask
{
public string Title { get; set; }
public bool IsCompleted { get; set; }
public int Priority { get; set; }
}
Исходные данные:
var tasks = new List<StudentTask>
{
new StudentTask { Title = "Сделать домашку", IsCompleted = false, Priority = 2 },
new StudentTask { Title = "Попить кофе", IsCompleted = true, Priority = 3 },
new StudentTask { Title = "Посмотреть лекцию", IsCompleted = false, Priority = 1 }
};
Универсальный фильтр:
List<StudentTask> FilterTasks(
List<StudentTask> all,
Predicate<StudentTask> predicate)
{
return all.Where(t => predicate(t)).ToList();
}
// Поиск незавершённых задач с приоритетом > 1
var importantTasks = FilterTasks(tasks, t => !t.IsCompleted && t.Priority > 1);
// Выводим результат
foreach (var task in importantTasks)
Console.WriteLine(task.Title);
Комбинаторы предикатов:
Predicate<StudentTask> IsActive = t => !t.IsCompleted;
Predicate<StudentTask> IsHighPriority = t => t.Priority > 1;
// Объединяем несколько критериев, вариант 1
var specialTasks = FilterTasks(tasks, t => IsActive(t) && IsHighPriority(t));
// Вариант 2: функция-комбинатор двух предикатов
Predicate<StudentTask> And(Predicate<StudentTask> a, Predicate<StudentTask> b) => t => a(t) && b(t);
var specialTasks2 = FilterTasks(tasks, And(IsActive, IsHighPriority));
11. Особенности и типичные ошибки при применении функционального подхода в C#
Во-первых, помните, что C# — строго типизированный язык. Иногда нужно явно указывать типы, особенно когда функции возвращают делегаты или сложные лямбды. Иначе можно получить ошибку компиляции из-за невыведенного типа.
Во-вторых, не передавайте функции с побочными эффектами туда, где ожидаются чистые функции. Изменение внешних переменных ломает предсказуемость. Старайтесь, чтобы ваши функции не «мутировали» состояние вне своей области видимости.
В-третьих, будьте аккуратны с захватом переменных внешней области (closure capture), особенно в асинхронном и многопоточном коде. Переменная, значение которой меняется после передачи в лямбду (например, в LINQ), может приводить к неочевидным багам.
Наконец, избыточный ФП-стиль может усложнить поддержку кода для команды, не привыкшей к нему. Используйте ФП там, где это реально упрощает решение, а не ради «красоты».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ