Функциональное программирование ФП в обычных языках: JavaScript, TypeScript, Python, Java, C#, Kotlin
0%

ФП в обычных языках: JavaScript, TypeScript, Python, Java, C#, Kotlin

ФП в обычных языках: JavaScript, TypeScript, Python, Java, C#, Kotlin

Весь предыдущий трек мы разбирали идеи ФП там, где они живут в своей естественной среде: чистые функции, персистентные структуры, типизированные эффекты. Примеры на Haskell и Elixir убедительны ровно до того момента, пока вы не закрываете вкладку и не возвращаетесь в репозиторий на Java 17 со Spring Boot, где сорок человек пишут код уже восемь лет.

Вопрос этой статьи прагматичный: что из всего этого реально работает в языке, который для ФП не проектировали, и сколько это стоит.

Плохой ответ — «перепишите на Scala». Хороший ответ начинается с наблюдения: за последние двадцать лет мейнстримные языки утащили из ФП почти всё, что можно утащить без смены системы типов. Лямбды, замыкания, ленивые последовательности, алгебраические типы, сопоставление с образцом, неизменяемые записи — всё это уже в языке, которым вы пользуетесь. Не приехало только то, что требует высших родов и контроля эффектов.

Обзорный взгляд на парадигму в целом есть в статье Функциональное программирование — здесь мы идём вглубь по каждому языку.

Как ФП просачивалось в мейнстрим

Полезно помнить хронологию: почти всё, что сегодня кажется «просто нормальным современным кодом», двадцать лет назад было академической экзотикой.

Тенденция односторонняя: языки заимствуют у ФП, обратного потока почти нет. Причина не в моде. Каждая из этих фич закрывает конкретную боль, которую мультипоточность и рост кодовых баз сделали невыносимой: разделяемое изменяемое состояние, null, невыраженная в типах вариативность данных.

Матрица: что есть в языке, а что нет

Прежде чем разбирать языки по одному, полезно увидеть картину целиком.

Сравнение поддержки функциональных возможностей в шести мейнстримных языках

Из матрицы видны три вещи.

Первая строка везде заполнена. Функции первого класса и замыкания есть буквально во всех. Это значит, что функции высшего порядка, композиция и каррирование доступны вам прямо сейчас, без библиотек и без споров с командой. Это самый дешёвый и самый недооценённый уровень внедрения.

Середина матрицы — водораздел. Алгебраические типы и проверка исчерпывающести отделяют языки, где ошибка «забыл обработать случай» ловится компилятором, от языков, где она ловится в проде. Здесь Java 21 и Kotlin неожиданно впереди TypeScript.

Нижние строки пустые почти везде. Ни один из шести языков не имеет высших родов. Это принципиальное ограничение: вы можете написать map для своего Result, но не можете написать функцию, работающую для любого функтора. Практический вывод — не пытайтесь портировать Haskell-библиотеку целиком, портируйте конкретные типы.

Универсальные препятствия

Прежде чем идти по языкам, четыре ограничения, общих почти для всех.

Нет оптимизации хвостовых вызовов. Прямая рекурсия из статьи про рекурсию взорвёт стек на любом реалистичном объёме данных. Практическое правило: рекурсия допустима по структурам с логарифмической глубиной (деревья, JSON) и запрещена по спискам произвольной длины. Для списков — свёртка или цикл.

Нет персистентных коллекций по умолчанию. list.copy() в Python и [...arr] в JS — это O(n) копирование, а не структурное разделение. Наивная «неизменяемость через копирование» в цикле даёт O(n²).

Эффекты не видны в типах. Функция int -> int в Java может ходить в базу. Никакой компилятор вас не остановит. Значит, граница чистого ядра держится дисциплиной и код-ревью, а не системой типов.

Исключения существуют и их бросают чужие библиотеки. Даже если весь ваш код возвращает Result, драйвер базы кинет исключение. Граница между двумя моделями ошибок должна быть явной и узкой.

JavaScript: всё разрешено, ничего не гарантировано

Что есть

JavaScript функционален по происхождению: замыкания, функции как значения, объектные литералы. map/filter/reduce в языке с 2009 года, стрелочные функции и const — с ES2015.

// Композиция и конвейер — без библиотек
const pipe = (...fns) => (x) => fns.reduce((acc, f) => f(acc), x);

const normalize = pipe(
  (s) => s.trim(),
  (s) => s.toLowerCase(),
  (s) => s.replace(/\s+/g, " "),
);

Первая ловушка: const не про неизменяемость

const запрещает переприсваивание переменной, но не изменение объекта. Это самое частое заблуждение новичков.

const user = { name: "Аня", tags: ["admin"] };
user.tags.push("owner");   // работает: const не мешает
// user = {};              // а вот это ошибка

Object.freeze даёт поверхностную заморозку — вложенные объекты остаются изменяемыми, и в нестрогом режиме запись просто молча игнорируется. Глубокая заморозка стоит рекурсивного обхода и заметно тормозит горячие объекты.

Вторая ловушка: цена наивного копирования

// O(n^2): каждый шаг копирует весь аккумулятор
const bad = items.reduce((acc, x) => [...acc, transform(x)], []);

// O(n): map делает ровно один проход
const good = items.map(transform);

Раскладывание аккумулятора внутри reduce — самая распространённая ошибка производительности в «функциональном» JS. На тысяче элементов разница незаметна, на ста тысячах это секунды.

Отдельная стоимость — цепочки. arr.filter(...).map(...).filter(...) делает три полных прохода и создаёт два промежуточных массива. Для больших коллекций помогают ленивые итераторы (Iterator Helpers, доступные в современных движках) или свёртка в один проход.

// Один проход, без промежуточных массивов
const result = orders
  .values()                       // Iterator
  .filter((o) => o.status === "paid")
  .map((o) => o.total)
  .take(100)
  .toArray();

Immer: неизменяемость без синтаксического шума

Библиотека Immer решает главную практическую боль — обновление глубоко вложенного состояния. Вы пишете изменяющий код по черновику, а получаете новый неизменяемый объект со структурным разделением.

import { produce } from "immer";

const next = produce(state, (draft) => {
  draft.orders[3].items[0].qty += 1;   // мутируем черновик
});
// state не изменился; next разделяет с ним всё, кроме изменённого пути

Цена: Proxy-обёртки добавляют накладные расходы на чтение внутри рецепта, а стек-трейсы становятся менее прозрачными. Для состояния UI это выгодная сделка, для горячего цикла обработки — нет.

Где ФП в JS ломается

  • Нет ОХВ: рекурсия по длинному списку падает (спецификация ES2015 требует правильных хвостовых вызовов, но реально их реализовал только JavaScriptCore).
  • this и прототипы конфликтуют с точечно-свободным стилем: arr.map(obj.method) теряет получателя.
  • Array.prototype.sort сортирует на месте — типичный источник неожиданной мутации.
  • Нет типов, значит нет ни Result, ни проверки исчерпывающести. В голом JS дисциплина ФП держится только на договорённостях.

TypeScript: типы, ради которых стоит терпеть

TypeScript меняет расстановку сил: становятся возможны размеченные объединения — практическая реализация алгебраических типов.

// Сумма-тип: состояние загрузки, где невозможны недопустимые комбинации
type RemoteData<E, A> =
  | { readonly tag: "idle" }
  | { readonly tag: "loading" }
  | { readonly tag: "failure"; readonly error: E }
  | { readonly tag: "success"; readonly value: A };

function render<E, A>(rd: RemoteData<E, A>): string {
  switch (rd.tag) {
    case "idle":    return "";
    case "loading": return "Загрузка…";
    case "failure": return `Ошибка: ${String(rd.error)}`;
    case "success": return show(rd.value);
    default: {
      // Проверка исчерпывающести: если добавить вариант и забыть его тут,
      // компилятор откажется присваивать его типу never
      const _exhaustive: never = rd;
      return _exhaustive;
    }
  }
}

Трюк с never — рабочий, но ручной. Компилятор не потребует его сам: без default-ветки забытый вариант просто вернёт undefined. Это отличие от Java и Kotlin, где исчерпывающесть проверяется автоматически.

Result вместо исключений

type Result<E, A> =
  | { readonly ok: false; readonly error: E }
  | { readonly ok: true; readonly value: A };

const err = <E>(error: E): Result<E, never> => ({ ok: false, error });
const ok  = <A>(value: A): Result<never, A> => ({ ok: true, value });

const map = <E, A, B>(r: Result<E, A>, f: (a: A) => B): Result<E, B> =>
  r.ok ? ok(f(r.value)) : r;

const flatMap = <E, A, B>(r: Result<E, A>, f: (a: A) => Result<E, B>): Result<E, B> =>
  r.ok ? f(r.value) : r;

Ключевая деталь: в ветке !r.ok возвращается сам r, а не пересобранный объект. Сужение типов TypeScript это принимает, лишней аллокации не происходит.

readonly и as const

// readonly в сигнатуре — контракт «я не буду это менять»
function total(items: readonly Item[]): number {
  return items.reduce((s, i) => s + i.price, 0);
}

const CONFIG = { retries: 3, hosts: ["a", "b"] } as const;
// тип: { readonly retries: 3; readonly hosts: readonly ["a", "b"] }

Важно понимать границы: readonly стирается при компиляции. Это защита от ваших опечаток, а не от изменения в рантайме и не от чужого JS-кода.

Экосистема: fp-ts и Effect

fp-ts эмулировал высшие роды через приём дефункционализации: тип-«имя» плюс расширение интерфейса-словаря. Работает, но диагностика типов страдает — ошибка в цепочке даёт сообщение на пятьдесят строк.

Сегодня центр тяжести экосистемы сместился к Effect, который вместо эмуляции HKT строит один богатый тип Effect<A, E, R> — успех, ошибка, требуемые зависимости. Это ровно типизированные эффекты в TypeScript-обёртке.

import { Effect } from "effect";

const getUser = (id: string): Effect.Effect<User, NotFound | DbError, Db> =>
  Effect.gen(function* () {
    const db = yield* Db;
    const row = yield* db.query(id);          // ошибки попадают в тип E
    return yield* decodeUser(row);
  });

Честная оценка: Effect даёт настоящий контроль эффектов, отмену, ретраи и внедрение зависимостей, но это отдельный язык поверх TypeScript. Порог входа для новичка в команде — недели, не дни. Берите его, когда сложность асинхронности и ошибок уже является вашей главной проблемой, а не заранее. Подробнее про сам язык — в треке TypeScript.

Python: выразительный, но с потолком

Что работает хорошо

Замороженные датаклассы дают неизменяемые записи с бесплатным __eq__, __hash__ и удобным копированием-с-изменением.

from dataclasses import dataclass, replace

@dataclass(frozen=True, slots=True)
class Order:
    id: str
    items: tuple[str, ...]      # кортеж, а не list — иначе неизменяемость мнимая
    total: int

o1 = Order("A-1", ("книга",), 500)
o2 = replace(o1, total=450)     # аналог copy/with

slots=True заметно снижает потребление памяти и ускоряет доступ к полям — для датакласса, который создаётся миллионами штук, это не мелочь. Обратите внимание на tuple: frozen=True запрещает переприсваивание атрибутов, но не мешает вызвать .append() у вложенного списка.

Сопоставление с образцом

С Python 3.10 доступен match (PEP 636), и он умеет разбирать датаклассы структурно:

type Result = Ok | Err          # Python 3.12+; раньше — Union[Ok, Err]

@dataclass(frozen=True, slots=True)
class Ok:
    value: object

@dataclass(frozen=True, slots=True)
class Err:
    problems: tuple[str, ...]

def describe(r: Result) -> str:
    match r:
        case Ok(value=v):
            return f"успех: {v}"
        case Err(problems=(single,)):        # ровно одна проблема
            return f"ошибка: {single}"
        case Err(problems=ps):
            return f"ошибок: {len(ps)}"
        case _ as unreachable:
            assert_never(unreachable)        # mypy проверит исчерпывающесть

assert_never из typing — единственный способ получить проверку исчерпывающести, и работает она только при запуске mypy или pyright. Рантайм ничего не проверит.

Цена ФП в Python особенно заметна

Вызов функции в CPython дорог. Идиома map(lambda x: x * 2, xs) обычно медленнее списочного включения, потому что включение не делает вызов на каждый элемент.

# медленнее: вызов лямбды на каждый элемент
squares = list(map(lambda x: x * x, xs))
# быстрее и идиоматичнее для Python
squares = [x * x for x in xs]
# map быстр, когда функция уже реализована в C
lengths = list(map(len, strings))

Рекурсия ещё дороже: лимит по умолчанию — 1000 кадров, а глубокая рекурсия работает в разы медленнее цикла. Хвостовая рекурсия в Python — антипаттерн, а не приём.

Зато генераторы дают настоящую ленивость практически бесплатно:

from itertools import islice

def read_lines(path):
    with open(path, encoding="utf-8") as f:
        yield from f                          # ленивое чтение, файл не в памяти

pipeline = (
    line.strip().lower()
    for line in read_lines("access.log")
    if "ERROR" in line
)
first_100 = list(islice(pipeline, 100))       # прочитается только нужное

Из библиотек полезны pyrsistent (персистентные коллекции с HAMT и векторными деревьями) и returns (Result, Maybe, do-нотация). Обе меняют стиль всего проекта — вводить их стоит осознанно и целиком, а не в одном модуле.

Java: сначала Stream, потом настоящие АТД

Java 8 принесла лямбды и Stream, но подлинный сдвиг случился в Java 21, когда сошлись record, sealed и сопоставление с образцом в switch. Только теперь в Java есть полноценные алгебраические типы с проверкой исчерпывающести.

public sealed interface Payment {
    record Card(String last4, int amount)      implements Payment {}
    record Transfer(String iban, int amount)   implements Payment {}
    record Cash(int amount)                    implements Payment {}
}

// Компилятор требует покрыть все варианты: default не нужен,
// а добавление нового варианта ломает сборку — именно то, что нужно
static String describe(Payment p) {
    return switch (p) {
        case Payment.Card(String last4, int amount) ->
            "Карта *" + last4 + " на " + amount;
        case Payment.Transfer(String iban, int amount) when amount > 100_000 ->
            "Крупный перевод на " + iban;
        case Payment.Transfer(String iban, int amount) ->
            "Перевод на " + iban;
        case Payment.Cash(int amount) ->
            "Наличные " + amount;
    };
}

Это ровно то, за чем раньше ходили в Scala. Записи-паттерны с деструктуризацией плюс охранники when дают выразительность, близкую к ML-языкам.

Result вручную

public sealed interface Result<T> {
    record Ok<T>(T value) implements Result<T> {}
    record Err<T>(List<String> problems) implements Result<T> {}

    default <R> Result<R> map(Function<? super T, ? extends R> f) {
        return switch (this) {
            case Ok<T> ok -> new Ok<>(f.apply(ok.value()));
            case Err<T> e -> new Err<>(e.problems());
        };
    }

    default <R> Result<R> flatMap(Function<? super T, Result<R>> f) {
        return switch (this) {
            case Ok<T> ok -> f.apply(ok.value());
            case Err<T> e -> new Err<>(e.problems());
        };
    }
}

Пересоздание Err при map — вынужденная плата за инвариантность дженериков в Java: Err<String> не является подтипом Result<Integer>. В Kotlin с out T этой проблемы нет.

Ловушки Stream

// Плохо: боксинг Integer на каждом элементе
int sum = list.stream().map(Item::price).reduce(0, Integer::sum);

// Хорошо: примитивный поток, без аллокаций
int sum = list.stream().mapToInt(Item::price).sum();

Три частые ошибки:

  • Боксинг. Всегда используйте IntStream/LongStream/DoubleStream для чисел.
  • parallelStream() наугад. Он использует общий ForkJoinPool.commonPool, и один медленный блокирующий запрос парализует всё приложение. Параллельный поток оправдан при большом объёме, дешёвом разбиении источника и чистой операции.
  • Optional как поле или параметр. По замыслу авторов это тип возвращаемого значения. Optional сериализуется плохо, добавляет аллокацию и уровень косвенности.

Для более полного ФП есть Vavr — персистентные коллекции, Either, Try, Validation с накоплением ошибок. Библиотека зрелая, но её коллекции не совместимы с java.util, и на границе с фреймворками придётся постоянно конвертировать.

C#: LINQ был монадой всё это время

Интересный исторический факт: SelectMany в LINQ — это в точности bind из монад, а синтаксис запросов с несколькими from — это do-нотация. Эрик Мейер, один из авторов LINQ, пришёл в Microsoft из мира Haskell.

// Синтаксис запросов...
var pairs = from a in listA
            from b in listB
            where a + b == 10
            select (a, b);

// ...разворачивается компилятором в цепочку SelectMany — то есть в bind
var same = listA.SelectMany(a => listB.Where(b => a + b == 10).Select(b => (a, b)));

Понимание этого даёт практическую суперсилу: реализуйте SelectMany для своего типа — и получите синтаксис запросов бесплатно, включая Result и Task.

Records и неизменяемость

public sealed record Order(string Id, ImmutableList<string> Items, int Total);

var o1 = new Order("A-1", ImmutableList.Create("книга"), 500);
var o2 = o1 with { Total = 450 };   // копия с изменённым полем
// структурное равенство из коробки: o1 == o1 with { } → true

System.Collections.Immutable — часть базовой библиотеки, и это редкое преимущество C#: персистентные коллекции доступны без сторонних зависимостей. ImmutableList реализован как AVL-дерево, поэтому индексация — O(log n), а не O(1). Для сценария «собрали один раз, дальше только читаем» правильнее ImmutableArray: чтение по индексу за O(1), но любое изменение — полное копирование.

Сопоставление с образцом

public abstract record Shape
{
    private Shape() { }                        // запрещаем внешние наследники
    public sealed record Circle(double R) : Shape;
    public sealed record Rect(double W, double H) : Shape;
}

static double Area(Shape s) => s switch
{
    Shape.Circle(var r)     => Math.PI * r * r,
    Shape.Rect(var w, var h) => w * h,
    _ => throw new UnreachableException(),
};

Главная оговорка: C# предупреждает о неисчерпывающем switch-выражении (CS8509), но не запрещает его. Если у вас не включён <TreatWarningsAsErrors>, забытый вариант проедет в прод и упадёт с MatchFailureException. Это стоит включить в первый же день проекта на ФП-стиле. Подробнее про язык — в треке C#.

Для полного набора абстракций есть language-extOption, Either, Validation, трансформеры, эффекты. Мощная, но крайне идиосинкратичная библиотека: код на ней читает только тот, кто её знает.

Цена LINQ

Каждый оператор LINQ — это делегат плюс объект-итератор. В горячем цикле, вызываемом миллионы раз в секунду, это заметно. Практика такова: LINQ по умолчанию везде, ручной цикл — только там, где профайлер показал проблему. Современные версии .NET специализируют часть операторов над массивами и списками, поэтому разрыв меньше, чем принято думать, но он не нулевой.

Kotlin: самый функциональный из мейнстримных

Kotlin проектировался с оглядкой на ФП и даёт больше всех из коробки.

sealed interface Result<out T> {
    data class Ok<T>(val value: T) : Result<T>
    data class Err(val problems: List<String>) : Result<Nothing>
}

// when как выражение над sealed — исчерпывающесть проверяет компилятор
fun <T> Result<T>.orElse(fallback: T): T = when (this) {
    is Result.Ok  -> value
    is Result.Err -> fallback
}

Обратите внимание на out T и Result<Nothing>: ковариантность позволяет одному объекту Err подходить под любой Result<T>, без пересоздания, которого требует Java.

Функции-расширения дают конвейеры без обёрток и без наследования:

fun String.slugify(): String =
    trim().lowercase().replace(Regex("[^a-zа-я0-9]+"), "-").trim('-')

val slug = title.slugify()          // читается как метод, компилируется как статика

Инлайнинг — недооценённое преимущество. Функции высшего порядка, помеченные inline, разворачиваются на месте: лямбда не превращается в объект, аллокации нет. Именно поэтому list.map { } в Kotlin дешевле, чем эквивалент через анонимный класс в Java. Sequence даёт ленивость с семантикой Java Stream, но без накладных расходов на разбиение для параллелизма:

val firstMatches = hugeList
    .asSequence()                      // переключаемся в ленивый режим
    .map(::parse)
    .filter { it.isValid }
    .take(10)
    .toList()                          // parse вызовется примерно 10 раз, не 1_000_000

Ровно один нюанс: для маленьких коллекций asSequence() медленнее обычных операций из-за накладных расходов на итераторы. Порог окупаемости — сотни элементов или дорогие промежуточные операции.

tailrec — единственная в нашей шестёрке настоящая оптимизация хвостовых вызовов, хоть и ограниченная прямой саморекурсией:

tailrec fun sum(xs: List<Int>, acc: Int = 0): Int =
    if (xs.isEmpty()) acc else sum(xs.drop(1), acc + xs.first())
// компилируется в цикл; без tailrec — StackOverflowError

Экосистема — Arrow. Показателен её путь: в ранних версиях Arrow эмулировала высшие роды, но в 1.0 от эмуляции отказались ради читаемых типов и сообщений об ошибках. Современный Arrow опирается на raise-DSL, который даёт короткое замыкание ошибок в обычном императивно выглядящем коде:

import arrow.core.raise.either

val order: Either<Problem, Order> = either {
    val user = findUser(id).bind()        // при Left выходим сразу
    val cart = loadCart(user).bind()
    ensure(cart.isNotEmpty()) { EmptyCart }
    Order(user, cart)
}

Один пример на всех: валидация с накоплением ошибок

Хороший тест на зрелость ФП-подхода — задача из статьи про [обработку ошибок](https://courses.digitable.life/post/functional-programming/10-error-handling/: показать пользователю все проблемы формы сразу, а не первую.

Каноническая запись на Haskell для сравнения:

data Reg = Reg { email :: Email, age :: Age, name :: Name }

validate :: Raw -> Validation [Problem] Reg
validate r = Reg <$> vEmail (rEmail r)
                 <*> vAge   (rAge r)
                 <*> vName  (rName r)
-- Validation — аппликатив, который склеивает ошибки полугруппой,
-- поэтому проверяются все три поля, а не до первой неудачи

Ключ здесь — аппликатив, а не монада: монада обязана прервать цепочку, аппликатив — нет.

В TypeScript то же самое пишется явно:

type Validated<A> = { ok: true; value: A } | { ok: false; problems: string[] };

function zip3<A, B, C, R>(
  a: Validated<A>, b: Validated<B>, c: Validated<C>,
  f: (a: A, b: B, c: C) => R,
): Validated<R> {
  if (a.ok && b.ok && c.ok) return { ok: true, value: f(a.value, b.value, c.value) };
  return {
    ok: false,
    problems: [
      ...(a.ok ? [] : a.problems),
      ...(b.ok ? [] : b.problems),
      ...(c.ok ? [] : c.problems),
    ],
  };
}

const reg = zip3(vEmail(raw.email), vAge(raw.age), vName(raw.name),
                 (email, age, name) => ({ email, age, name }));

Без высших родов вам придётся написать zip2, zip3, zip4 руками — обобщить их одной функцией нельзя. Это и есть цена отсутствия HKT в повседневной работе: не «нельзя», а «руками и с дублированием».

В Kotlin через Arrow то же самое выглядит короче, а в Java аналог даёт Validation из Vavr. Схема одинакова во всех языках:

Обратите внимание: три проверки независимы, поэтому и возможно накопление. Если бы проверка возраста требовала результата проверки email, пришлось бы вернуться к монадической цепочке с коротким замыканием.

Честная цена

Раздел, который обычно пропускают в статьях про ФП.

Производительность

Аллокации. Неизменяемость означает создание новых объектов. На JVM и .NET молодое поколение сборщика мусора дёшево, и большинство короткоживущих объектов почти бесплатны — escape-анализ иногда убирает их совсем. В CPython аллокация дороже, а GC работает по счётчику ссылок, поэтому цена заметнее.

Косвенность. Персистентный HAMT-словарь — это несколько разыменований указателей вместо одного обращения к массиву. Промахи кэша процессора часто обходятся дороже, чем сама асимптотика. Разрыв с изменяемым хэш-словарём в реальных замерах обычно в 2–5 раз на чтении.

Мегаморфные точки вызова. Активное использование функций высшего порядка приводит к тому, что в одну точку вызова приходят десятки разных лямбд. JIT перестаёт встраивать вызов, теряется вся цепочка последующих оптимизаций. Это тихая, неочевидная стоимость, которую не видно в микробенчмарке с одной лямбдой.

Практический вывод. ФП-стиль дороже императивного примерно в единицы раз, а не на порядки, и почти всегда неважен вне горячих путей. Правильная стратегия: функциональное ядро по умолчанию, профилирование, локальная мутация в найденных горячих точках. Локальная мутация внутри функции не нарушает чистоту — снаружи функция остаётся ссылочно прозрачной.

Кривая обучения

Издержки распределены неравномерно, и это главное, что стоит учитывать при планировании внедрения.

Левый верхний угол — то, что окупается на первой же неделе и не требует ни библиотек, ни объяснений на ревью. Правый нижний — то, что превращает код в диалект, понятный трём людям в компании. Между ними нет непрерывного перехода: это не шкала «чем больше ФП, тем лучше».

Где ФП прямо мешает

Список ситуаций, в которых честный ответ — «не надо»:

  • Горячие численные циклы. Обработка изображений, физика, матрицы. Здесь нужны массивы примитивов и мутация на месте.
  • Граница с фреймворком. Spring, Django ORM, Entity Framework построены на изменяемых сущностях с идентичностью и отслеживанием изменений. Попытка натянуть на них неизменяемость даёт слой конвертации, который стоит дороже, чем даёт.
  • Задачи, которые по сути про состояние. Игровой цикл, эмулятор, конечный автомат протокола на горячем пути. Явное изменяемое состояние здесь честнее, чем State-монада.
  • Отладка. Стек-трейс глубокой композиции точечно-свободных функций или цепочки flatMap бесполезен: вы видите lambda$main$3, а не место в бизнес-логике. Именованные промежуточные функции стоят дешевле, чем экономия строк.
  • Смешанная кодовая база. Половина проекта на Either, половина на исключениях — хуже, чем любая из двух моделей целиком. Граница должна быть по модулю или по слою, а не по вкусу автора файла.

Порядок внедрения

Работающая последовательность выглядит так — каждый следующий шаг имеет смысл только после предыдущего.

Пункт «остановиться здесь» — не шутка и не компромисс. Подавляющее большинство команд получают почти всю пользу ФП на шагах 1–6, без единой сторонней библиотеки и без слова «монада» на ревью.

Ещё три правила из практики:

  1. Начинайте с новых модулей, а не с переписывания. Функциональное ядро легко вырастить в новом ограниченном контексте и трудно вырезать из старого.
  2. Договоритесь письменно. Одна страница в репозитории: где используем Result, а где исключения; какие коллекции неизменяемы; что запрещено на ревью. Без этого стиль расползается.
  3. Не проповедуйте. Аргумент «этот модуль не имеет состояния, поэтому его тесты не требуют моков и выполняются за 30 миллисекунд» убеждает лучше любой теории категорий.

Мини-итог

  • Функции первого класса есть везде — самый дешёвый уровень ФП доступен без обсуждений.
  • АТД плюс проверка исчерпывающести — главный приз. Java 21 и Kotlin дают их полноценно, TypeScript и C# — с ручными приёмами, Python — только через статический анализатор, JavaScript — никак.
  • Высших родов нет нигде. Отсюда zip2/zip3/zip4 руками и невозможность честно портировать Haskell-библиотеку. Работайте с конкретными типами.
  • ОХВ есть только в Kotlin (tailrec). Рекурсия по длинным спискам — источник падений во всех остальных.
  • Цена — единицы раз на аллокациях и косвенности, и она почти всегда неважна вне горячих путей. Чистое ядро плюс локальная мутация по результатам профилирования.
  • Границу с фреймворками и исключениями нужно проектировать явно. Полумеры хуже любой из чистых стратегий.

Источники

Что дальше

Мы разобрали, что именно доступно в каждом языке и сколько это стоит. Осталось собрать из этих кирпичей архитектуру целиком: где проходит граница чистого ядра, как выглядит императивная оболочка, как это тестировать и куда идти дальше в изучении ФП.

Архитектура на ФП: функциональное ядро и императивная оболочка, ресурсы

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов