Функциональное программирование Функциональное программирование: карта курса, история и зачем это нужно
0%

Функциональное программирование: карта курса, история и зачем это нужно

Функциональное программирование: карта курса, история и зачем это нужно

Есть класс багов, который знает каждый, кто писал код дольше года. Тест зелёный локально и красный в CI. Функция работает, пока её вызывают в правильном порядке. Значение переменной «испортилось», и вы полдня ищете, кто именно его переписал. Скролл списка сбрасывается, потому что кто-то отсортировал массив «на месте». Метрика в дашборде удвоилась, потому что обработчик посчитал одно и то же сообщение дважды.

Все эти истории — про одно и то же: смысл выражения в программе зависит не только от того, что в нём написано, но и от того, что произошло раньше в другом месте. Функциональное программирование — это набор дисциплин, который убирает эту зависимость. Не полностью и не бесплатно, и об этой цене мы поговорим честно, но убирает.

Этот курс — не «ещё раз про map и filter». В треке парадигм уже есть обзорная статья Функциональная парадигма, которая ставит ФП в один ряд с ООП, логическим и процедурным программированием. Здесь мы идём вглубь: 17 статей, от чистых функций до архитектуры продакшн-систем, с кодом на Elixir, TypeScript, Python и Haskell.

Боль первая: изменяемое состояние делает код нелокальным

Начнём с кода, а не с определений. Классическая ловушка на Python:

def add_tag(item, tags=[]):          # тот самый мутабельный дефолт
    tags.append(item)
    return tags

print(add_tag("new"))     # ['new']
print(add_tag("sale"))    # ['new', 'sale']  ← сюрприз

Список по умолчанию создаётся один раз при определении функции и живёт между вызовами. Функция, которая выглядит как «взять аргумент, вернуть результат», на самом деле имеет память.

Тот же класс ошибок в TypeScript, только опаснее — потому что выглядит совершенно невинно:

type Order = { id: string; items: Item[]; total: number };

// «Просто отсортируем для показа»
function renderCheapestFirst(order: Order): Item[] {
  return order.items.sort((a, b) => a.price - b.price); // sort мутирует массив!
}

Array.prototype.sort сортирует на месте и возвращает тот же массив. Компонент рендера только что переупорядочил данные, на которые смотрят ещё три компонента и логика расчёта скидки «на первые два товара». Баг проявится через неделю, в другом модуле, у 3% пользователей.

Здесь важно понять причину, а не симптом. Причина в том, что order.items — это разделяемая изменяемая ссылка. Как только у одной ячейки памяти появляется больше одного писателя, порядок операций становится частью контракта, а порядок операций нигде не записан.

Мутация общего состояния против потока значений

Функциональный ответ прост до наглости: не переписывать, а порождать новое значение.

// Чистая версия: исходный массив не тронут
const cheapestFirst = (order: Order): readonly Item[] =>
  [...order.items].sort((a, b) => a.price - b.price);

Одна строка [...order.items] стоит одной аллокации массива указателей — и убирает целый класс багов. Это микрокосм всего ФП: мы платим памятью и косвенностью, а получаем возможность рассуждать о куске кода, не держа в голове всю программу.

Что такое ФП на самом деле: ссылочная прозрачность

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

Проверка на практике: можно ли записать результат в переменную и переиспользовать?

# Чистая: подстановка безопасна
total = price * qty
# можно заменить каждое вхождение `price * qty` на `total` — ничего не сломается

# Нечистая: подстановка меняет смысл
x = input()   # два вызова input() дадут разные значения

Из ссылочной прозрачности механически вытекает всё остальное, что вы слышали про ФП:

  • Мемоизация бесплатна и всегда корректна. Если вход тот же — результат тот же, можно кэшировать.
  • Порядок вычислений не важен. Значит, можно распараллелить, переупорядочить, отложить (ленивость), выполнить на GPU.
  • Тест — это уравнение. Никаких моков, setup/teardown и «состояния мира»: вход → ожидаемый выход.
  • Рефакторинг — это алгебра. «Вынести общее подвыражение», «инлайнить функцию», «поменять местами независимые шаги» — законные преобразования, а не риск.
  • Компилятор может агрессивно оптимизировать. GHC делает fusion — сливает map f . map g в один проход именно потому, что знает: побочных эффектов нет.

И ключевое, ради чего это всё: локальность рассуждения. Чтобы понять чистую функцию, достаточно прочитать её тело и сигнатуры того, что она вызывает. Не нужно знать, кто вызвал её раньше и что лежит в глобальном кэше.

Одна задача на четырёх языках — средний чек активных пользователей:

-- Haskell: каноническая запись. Композиция функций как основной способ склейки.
averageOrder :: [User] -> Maybe Double
averageOrder users =
  case map orderTotal (filter isActive users) of
    []     -> Nothing
    totals -> Just (sum totals / fromIntegral (length totals))
# Elixir: пайплайн |> — данные текут слева направо через преобразования
def average_order(users) do
  totals =
    users
    |> Enum.filter(& &1.active?)
    |> Enum.map(&order_total/1)

  case totals do
    [] -> :error
    ts -> {:ok, Enum.sum(ts) / length(ts)}
  end
end
// TypeScript: те же идеи через методы массива; результат — размеченное объединение
type Result<T> = { ok: true; value: T } | { ok: false };

const averageOrder = (users: readonly User[]): Result<number> => {
  const totals = users.filter((u) => u.active).map(orderTotal);
  return totals.length === 0
    ? { ok: false }
    : { ok: true, value: totals.reduce((a, b) => a + b, 0) / totals.length };
};
# Python: генераторное выражение вместо цикла с аккумулятором
def average_order(users: list[User]) -> float | None:
    totals = [order_total(u) for u in users if u.active]
    return sum(totals) / len(totals) if totals else None

Обратите внимание на то, чего нет ни в одном варианте: временной переменной acc, счётчика i, ветки, которая случайно оставит acc в полусобранном состоянии. Мы описываем что нужно получить, а не как двигать указатель по массиву. Это и есть декларативность — и это единственное, что все четыре языка делают одинаково, несмотря на разный синтаксис.

Короткая история: почему это не новая мода

ФП старше почти всего, что вы используете. Оно родилось не как стиль программирования, а как ответ на вопрос из оснований математики: что вообще значит «вычислимая функция»?

Три сюжета в этой ленте важны для практика.

Первый. Лямбда-исчисление Чёрча — не «математическая экзотика вокруг ФП», а его буквальное ядро. Три конструкции (переменная, абстракция, применение) и одно правило (бета-редукция) достаточны для любого вычисления. Каррирование, замыкания, let — всё это сахар над ними. Отдельный трек по лямбда-исчислению на портале раскрывает эту тему подробно; в нашем курсе мы касаемся её в статьях про композицию и каррирование и рекурсию.

Второй. Лекция Бэкуса 1978 года — «Can Programming Be Liberated from the von Neumann Style?» — это программный манифест. Автор Фортрана публично сказал, что присваивание — «горлышко фон Неймана», через которое программа проталкивает состояние по одному слову за раз, и что нам нужны языки, где программы комбинируются алгебраически. Читается как описание современных data-пайплайнов.

Третий. Волна 2007–2019 — это не победа идеологии, а прагматика. Закон Мура упёрся в многоядерность, и разделяемое изменяемое состояние из «неудобства» превратилось в главный источник гонок. Одновременно веб принёс распределённость: ретраи, идемпотентность, event sourcing. У всех этих задач один и тот же ответ — неизменяемые данные и функции без скрытого контекста.

Что ФП даёт: пять выигрышей, за которые платят

1. Тесты без моков и property-based тестирование

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

# hypothesis: свойство вместо конкретных примеров
from hypothesis import given, strategies as st

@given(st.lists(st.integers()))
def test_sort_idempotent(xs):
    assert sorted(sorted(xs)) == sorted(xs)      # идемпотентность
    assert len(sorted(xs)) == len(xs)            # сохранение мультимножества

Такой подход просто невозможен для функции, которая лезет в БД и глобальный кэш: чтобы прогнать тысячу входов, нужна тысяча состояний мира. Подробнее — в обработке ошибок и в треке по тестированию: https://courses.digitable.life/post/testing/00-overview/.

2. Конкурентность без блокировок

Гонка данных требует трёх условий: два потока, общая ячейка, хотя бы одна запись. Убираете запись — гонка становится невозможной по построению, а не «по договорённости». Именно поэтому Erlang/Elixir с их полностью неизменяемыми данными держат миллионы процессов, а Clojure принёс в JVM персистентные структуры и STM. Разбор — в статье про конкурентность.

3. Кэширование и инкрементальные пересчёты

Если результат зависит только от входа, его можно закэшировать по ключу входа. На этом стоит вся индустрия сборщиков: Bazel, Nix, Turborepo, ccache. Их центральная идея дословно функциональная — «сборка есть чистая функция от входов», поэтому её результат можно переиспользовать между машинами.

4. Безопасный рефакторинг и понятные диффы

Когда функция не имеет скрытых входов, вынести её в модуль, переиспользовать в другом контексте или заменить реализацию — механическая операция. В коде с общим состоянием любое перемещение вызова потенциально меняет поведение.

5. Ошибки как значения, а не как прыжки

Исключение — это скрытый второй канал возврата, которого нет в сигнатуре. ФП возвращает ошибку значением: Option, Either, Result, {:ok, v} | {:error, e}. Компилятор (или линтер) заставляет обработать оба случая, а типы честно говорят, что функция может провалиться. Этому посвящены статьи про алгебраические типы и обработку ошибок.

Цена ФП: честный разбор

Любой курс, который обещает вам одни выигрыши, продаёт идеологию. Плата реальна, и её нужно знать до того, как вы принесёте эти идеи в чужой продакшн.

Производительность и память

Неизменяемость означает аллокации. Обновление одного поля в записи из десяти полей создаёт новую запись. Персистентные структуры (HAMT, RRB-деревья) делают обновление за O(log₃₂ n) вместо O(1) — формально «почти константа», фактически 5–8 разыменований указателей вместо одного сложения адреса.

Три конкретных следствия:

  • Давление на сборщик мусора. Много короткоживущих объектов — это нормально для поколенческого GC (JVM, BEAM, .NET), но это не бесплатно: аллокация занимает такты, а minor GC съедает пропускную способность.
  • Локальность кэша. Плоский int[] идёт последовательно и отлично префетчится. Персистентный вектор — дерево указателей, разбросанное по куче. На числодробилках разница легко достигает 5–20 раз. Почему именно так — см. трек по операционным системам, память и кэш.
  • Ленивость даёт утечки. В Haskell foldl (+) 0 [1..10^7] строит гигантскую цепочку невычисленных thunk-ов и падает по памяти; правильный ответ — строгая свёртка foldl'. Это классическая ловушка, которую разбираем в статье про ленивость.

Практический ориентир: для бизнес-логики, парсеров, оркестрации, API-слоя — накладные расходы неизменяемости обычно теряются на фоне сетевого и дискового I/O. Для внутреннего цикла матричного умножения, рендера кадра или обработки аудиобуфера — нет, там нужны мутируемые буферы. Правильный ответ не «выбрать сторону», а локализовать мутацию: чистый интерфейс снаружи, императивный буфер внутри. Об этом — статья про персистентные структуры.

Кривая обучения и когнитивная стоимость

Это не миф. Порог входа реален по трём причинам:

  1. Незнакомая лексика. «Функтор», «аппликатив», «моноид» — обычные вещи с математическими именами. Хуже того, имена ничего не подсказывают новичку, в отличие от «Фабрики» или «Наблюдателя».
  2. Инверсия привычного мышления. Вместо «делай шаг за шагом» — «опиши преобразование». Первые недели рекурсия и свёртки читаются медленнее циклов.
  3. Абстракционная лестница. В сообществах вокруг Haskell/Scala есть реальная проблема «монадных башен»: ReaderT env (StateT s (ExceptT e IO)) a формально элегантен, а на практике вызывает у команды паралич. Соблазн выразить всё через максимально общую абстракцию — профессиональная болезнь ФП.

Отдельная боль — диагностика. Стектрейс в глубоко композированном или ленивом коде часто указывает не туда: вы видите место, где thunk наконец вычислили, а не место, где его создали. Отладчик, привыкший к «поставь точку останова на строке», в конвейере из восьми преобразований помогает слабо.

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

  • Горячие циклы и системный код. Аллокация в критическом пути неприемлема; нужны мутируемые массивы, in-place алгоритмы, контроль layout.
  • Игровые движки и симуляции. Тысячи объектов, обновляемых каждый кадр; ECS специально проектируют вокруг плотных мутируемых массивов.
  • Задачи, которые по сути про состояние. Драйвер устройства, конечный автомат с историей, интерактивный редактор. Их можно моделировать функционально, но вы будете писать интерпретатор изменений вместо самих изменений.
  • Команда без опыта и без времени на обучение. Код, который никто не может поддержать, хуже неидеального кода.

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

Чистота — это шкала, а не флаг

Полезно перестать делить языки на «функциональные» и «нет». Реальная ось — насколько язык помогает вам ограничивать эффекты.

Из этой шкалы следует главный практический вывод курса: вам не нужно менять язык, чтобы получить 80% пользы. Неизменяемые DTO, чистые функции расчёта, ошибки-значения и отделение решения от исполнения работают в Java, Python, Go и C# ровно так же. Что именно доступно в каждом языке и какие подводные камни — в статье про ФП в мейнстрим-языках.

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

Карта курса

Порядок статей и что вы получите в каждой:

Статья Ключевой вопрос Что унесёте
01. Чистые функции Что считается эффектом и почему это важно Умение отделять решение от исполнения
02. Неизменяемость Как менять данные, ничего не меняя Обновление вложенных структур без мутаций
03. Функции высшего порядка Почему функция — это тоже данные Замыкания, map/filter/reduce изнутри
04. Рекурсия Как заменить цикл и не сломать стек Хвостовые вызовы, аккумуляторы, свёртки
05. Композиция и каррирование Как собирать программу из мелких деталей Пайплайны, частичное применение, point-free
06. АТД и pattern matching Как сделать невозможные состояния непредставимыми Суммы и произведения типов, исчерпывающий разбор
07. Функторы и аппликативы Как применять функции внутрь контейнера map для любого «ящика», параллельная валидация
08. Монады Как связать шаги, которые могут провалиться flatMap, do/for, монады без мистики
09. Карта функторов Как абстракции связаны друг с другом Иерархия от полугруппы до комонады
10. Обработка ошибок Как жить без исключений Option/Either/Result, накопление ошибок
11. Ленивость и потоки Как считать бесконечное и не считать лишнее Потоки, генераторы, ловушки утечек
12. Персистентные структуры Сколько стоит неизменяемость в тактах HAMT, RRB-деревья, structural sharing
13. Эффекты и I/O Как чистый код общается с грязным миром IO, интерпретаторы эффектов, тестируемость
14. Конкурентность Почему без мутаций гонок не бывает STM, акторы, модель BEAM
15. ФП в мейнстриме Что доступно в JS/Python/Java/C#/Kotlin Практичный ФП-стиль без смены стека
16. Архитектура Как построить систему на этих принципах Функциональное ядро, императивная оболочка

Зависимости между статьями нежёсткие, но есть два обязательных пути:

Синий — точка входа, оранжевый — самый крутой участок подъёма, зелёный — цель. Если у вас мало времени и нужен практический результат: 01 → 02 → 03 → 06 → 10 → 16 даёт работающий функциональный стиль без теории.

Как читать этот курс

Языки. Основные примеры — на Elixir, TypeScript и Python; Haskell используется там, где нужна каноническая, короткая запись идеи без синтаксического шума. Знать Haskell для курса не требуется — все фрагменты разбираются построчно. Если хочется поставить окружение:

# Haskell — через ghcup, интерактивный ghci
curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh
ghci

# Elixir — через asdf или пакетный менеджер, интерактивный iex
asdf plugin add elixir && asdf install elixir latest
iex

# TypeScript — быстрый прогон одного файла
npm i -D typescript tsx && npx tsx example.ts

Терминология. Мы намеренно вводим каждую абстракцию через задачу, которую она решает. Сначала вы увидите три повторяющихся куска кода, потом заметите общий узор, и только потом узнаете, что у него есть имя. Категорно-теоретические связи (законы функтора, естественные преобразования) идут после интуиции и кода — и вынесены в отдельные разделы, которые можно пропустить. Если захочется формальной базы, на портале есть Основы теории категорий и Теория категорий в программировании.

Прагматика. В каждой статье есть раздел о цене подхода и о том, когда его не применять. Цель курса — не сделать вас пуристом, а дать инструмент, который вы осознанно достаёте и осознанно убираете.

Мини-итог

  • Корень большинства «мистических» багов — разделяемое изменяемое состояние: смысл кода зависит от того, что произошло раньше и где-то ещё.
  • ФП убирает эту зависимость через ссылочную прозрачность: одинаковый вход — одинаковый выход, никаких скрытых каналов.
  • Из этого механически следуют тестируемость, безопасная конкурентность, кэшируемость и безопасный рефакторинг.
  • Платите вы аллокациями, локальностью кэша, порогом входа команды и худшей отладкой. В горячих циклах и в задачах, которые по сути про состояние, цена перевешивает.
  • Чистота — это шкала. Большую часть пользы можно получить в Python, Java или C#, не меняя стек.
  • Целевая архитектура курса — функциональное ядро с чистой логикой и тонкая императивная оболочка, которая исполняет решения ядра.

Полезные источники

Что дальше

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

Чистые функции, побочные эффекты и почему это меняет всё

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

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

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

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