Функциональное программирование в C#: от синтаксиса к архитектуре
Современный C# давно вышел за рамки чисто объектно-ориентированного языка. Начиная с версии 3.0 (LINQ) и заканчивая последними релизами .NET, язык активно впитывает парадигмы функционального программирования (ФП). Если вы освоили лямбды и LINQ, но хотите копнуть глубже — эта статья для вас. Мы разберём продвинутые концепции ФП, которые сделают ваш код на C# более предсказуемым, тестируемым и лаконичным.
1. Чистые функции и работа с побочными эффектами
Чистая функция — это функция, которая:
- Всегда возвращает один и тот же результат для одних и тех же аргументов (ссылочная прозрачность).
- Не имеет побочных эффектов (не изменяет глобальное состояние, не пишет в файл, не посылает HTTP-запросы).
В C# идеальный пример чистой функции — математические операции. Проблема в том, что реальное приложение не может состоять только из чистых функций: рано или поздно нужно сохранить данные или отправить письмо. Решение — отделение логики от эффектов.
Продвинутый приём: используйте функторы и монады для контейнеризации эффектов. Рассмотрим простой пример с Maybe<T> (аналог nullable, но функциональный):
public class Maybe<T>
{
private readonly T _value;
public bool HasValue { get; }
private Maybe(T value, bool hasValue) => (_value, HasValue) = (value, hasValue);
public static Maybe<T> Some(T value) => new Maybe<T>(value, true);
public static Maybe<T> None() => new Maybe<T>(default, false);
// Функтор: Select (Map)
public Maybe<TResult> Select<TResult>(Func<T, TResult> selector) =>
HasValue ? Maybe<TResult>.Some(selector(_value)) : Maybe<TResult>.None();
// Монада: SelectMany (FlatMap/Bind)
public Maybe<TResult> SelectMany<TResult>(Func<T, Maybe<TResult>> binder) =>
HasValue ? binder(_value) : Maybe<TResult>.None();
}
// Использование
Maybe<int> result = Maybe<int>.Some(10)
.Select(x => x * 2)
.SelectMany(x => x > 15 ? Maybe<int>.Some(x) : Maybe<int>.None());
Этот код не генерирует исключений и не проверяет null вручную — вся логика обработки отсутствия значения инкапсулирована в монаде.
2. Каррирование и частичное применение
В классическом ФП каждая функция принимает один аргумент. Каррирование — это преобразование функции с несколькими параметрами в цепочку вложенных функций, каждая из которых принимает один аргумент. Частичное применение — фиксация части аргументов.
В C# нет встроенного каррирования, но его легко реализовать через замыкания:
// Обычная функция
Func<int, int, int, int> add = (a, b, c) => a + b + c;
// Каррированная версия
Func<int, Func<int, Func<int, int>>> curriedAdd = a => b => c => a + b + c;
// Частичное применение: фиксируем первый аргумент
Func<int, Func<int, int>> add5 = curriedAdd(5);
// Использование
int sum = add5(10)(20); // 35
Продвинутый сценарий: внедрение зависимостей через частичное применение. Вместо того чтобы передавать конфигурацию или сервис через конструктор класса, можно зафиксировать их на этапе композиции:
public class ReportService
{
public Func<string, string, string> FormatReport =>
(template, data) => $"[{DateTime.Now}] {template}: {data}";
}
// Использование с частичным применением
var service = new ReportService();
var formatWithTemplate = service.FormatReport("Error"); // фиксируем шаблон
string errorMessage = formatWithTemplate("Connection failed");
// Результат: "[05.04.2025 14:30:00] Error: Connection failed"
3. Сопоставление с образцом (Pattern Matching) на стероидах
C# 7+ и .NET Core 3+ предоставили мощный механизм сопоставления с образцом. На продвинутом уровне его можно комбинировать с discriminated unions (алгебраическими типами данных), которые эмулируются через иерархию классов и рекордов.
Рассмотрим пример обработки результата HTTP-запроса:
public abstract record HttpResult<T>;
public record Success<T>(T Data) : HttpResult<T>;
public record NotFound(string Message) : HttpResult<object>;
public record Error(string Code, string Description) : HttpResult<object>;
public string HandleResult(HttpResult<object> result) => result switch
{
Success<object> { Data: var d } => $"Успех: {d}",
NotFound { Message: var msg } => $"Не найдено: {msg}",
Error { Code: "500", Description: var desc } => $"Серверная ошибка: {desc}",
Error _ => "Неизвестная ошибка",
_ => "Необработанный результат"
};
Компилятор C# проверяет полноту сопоставления (exhaustiveness), что предотвращает ошибки времени выполнения. Это намного безопаснее, чем цепочки if-else или switch по строкам.
4. Композиция функций через LINQ и кастомные операторы
Функциональный стиль поощряет композицию — создание новых функций путём объединения существующих. В C# для этого удобно использовать LINQ и методы расширения.
Продвинутый приём — композиция с помощью конвейера (pipeline). Создадим generic-функцию для композиции:
public static class FuncExtensions
{
// Компози