Функциональное программирование: карта курса, история и зачем это нужно
Есть класс багов, который знает каждый, кто писал код дольше года. Тест зелёный локально и красный в 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. Для внутреннего цикла матричного умножения, рендера кадра или обработки аудиобуфера — нет, там нужны мутируемые буферы. Правильный ответ не «выбрать сторону», а локализовать мутацию: чистый интерфейс снаружи, императивный буфер внутри. Об этом — статья про персистентные структуры.
Кривая обучения и когнитивная стоимость
Это не миф. Порог входа реален по трём причинам:
- Незнакомая лексика. «Функтор», «аппликатив», «моноид» — обычные вещи с математическими именами. Хуже того, имена ничего не подсказывают новичку, в отличие от «Фабрики» или «Наблюдателя».
- Инверсия привычного мышления. Вместо «делай шаг за шагом» — «опиши преобразование». Первые недели рекурсия и свёртки читаются медленнее циклов.
- Абстракционная лестница. В сообществах вокруг Haskell/Scala есть реальная проблема «монадных башен»:
ReaderT env (StateT s (ExceptT e IO)) aформально элегантен, а на практике вызывает у команды паралич. Соблазн выразить всё через максимально общую абстракцию — профессиональная болезнь ФП.
Отдельная боль — диагностика. Стектрейс в глубоко композированном или ленивом коде часто указывает не туда: вы видите место, где thunk наконец вычислили, а не место, где его создали. Отладчик, привыкший к «поставь точку останова на строке», в конвейере из восьми преобразований помогает слабо.
Где ФП прямо мешает
- Горячие циклы и системный код. Аллокация в критическом пути неприемлема; нужны мутируемые массивы, in-place алгоритмы, контроль layout.
- Игровые движки и симуляции. Тысячи объектов, обновляемых каждый кадр; ECS специально проектируют вокруг плотных мутируемых массивов.
- Задачи, которые по сути про состояние. Драйвер устройства, конечный автомат с историей, интерактивный редактор. Их можно моделировать функционально, но вы будете писать интерпретатор изменений вместо самих изменений.
- Команда без опыта и без времени на обучение. Код, который никто не может поддержать, хуже неидеального кода.
Правый верхний угол — самый интересный: системы, где состояния много и цена ошибки высока. Именно там работает компромисс, к которому ведёт весь курс: функциональное ядро и императивная оболочка. Чистая логика принимает решения, тонкий грязный слой их исполняет.
Чистота — это шкала, а не флаг
Полезно перестать делить языки на «функциональные» и «нет». Реальная ось — насколько язык помогает вам ограничивать эффекты.
эффекты везде, гарантий нет"] B["Java, C#, Go, Python
ФП-инструменты есть, дисциплина на вас"] C["JS/TS с ФП-библиотеками
readonly, Immer, fp-ts"] D["Elixir, Erlang, Clojure
данные неизменяемы, эффекты не в типах"] E["OCaml, F#, Scala, Rust
иммутабельность по умолчанию, мутация явна"] F["Haskell, PureScript, Idris
эффекты видны в типах"] A --> B --> C --> D --> E --> F style A fill:#d97d3a,fill-opacity:0.2,stroke:#d97d3a style D fill:#4f8ef7,fill-opacity:0.2,stroke:#4f8ef7 style F fill:#3f8f6d,fill-opacity:0.2,stroke:#3f8f6d
Из этой шкалы следует главный практический вывод курса: вам не нужно менять язык, чтобы получить 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#, не меняя стек.
- Целевая архитектура курса — функциональное ядро с чистой логикой и тонкая императивная оболочка, которая исполняет решения ядра.
Полезные источники
- John Backus. Can Programming Be Liberated from the von Neumann Style? — Тьюринговская лекция 1978 года, манифест ФП.
- John Hughes. Why Functional Programming Matters — классический ответ на вопрос «что даёт ФП», про композицию и ленивость.
- Philip Wadler. Monads for functional programming — статья, после которой монады попали в практику.
- Rich Hickey. The Value of Values — доклад о том, почему значения лучше объектов с идентичностью.
- Gary Bernhardt. Boundaries — доклад, из которого выросла идея «functional core, imperative shell».
- Learn You a Haskell for Great Good и Haskell Wiki — дружелюбный вход в Haskell.
- Elixir School — практический курс по Elixir на русском.
- Scott Wlaschin. F# for Fun and Profit — лучший источник про «сделать невозможные состояния непредставимыми».
Что дальше
Начинаем с фундамента, на котором стоит всё остальное: разберём, что такое побочный эффект на самом деле, почему «функция» в программировании и в математике — разные вещи, и как отделить принятие решения от его исполнения.