Чистые функции, побочные эффекты и почему это меняет всё
Если в обзорной статье трека парадигм (https://courses.digitable.life/post/paradigms/03-functional/) функциональное программирование было одной из точек на карте, то здесь начинается курс. И начинается он не с монад, не с каррирования и не с категорий, а с одной-единственной дисциплины: разделять вычисление и эффект. Всё остальное в ФП — надстройки над этим решением. Функторы нужны, потому что мы отказались менять данные на месте. Монады нужны, потому что мы вынесли эффекты наружу и теперь их надо как-то описывать. Персистентные структуры нужны, потому что копирование целиком слишком дорого.
Поэтому первая статья — самая важная. Если вы поймёте цену и выгоду чистоты по-настоящему, остальной курс будет читаться как набор следствий.
Сначала боль: тест, который падает по вторникам
Вот код, который вы видели сотни раз. Реальная функция из реального биллинга, слегка причёсанная:
// Скидка за выслугу лет + сезонная акция.
let totalDiscountsIssued = 0; // метрика "сколько скидок выдали"
function applyDiscount(order: Order): number {
const yearsWithUs = new Date().getFullYear() - order.customer.since;
let discount = 0;
if (yearsWithUs >= 3) discount += 0.10;
if (new Date().getMonth() === 11) discount += 0.05; // декабрьская акция
const rate = config.get("discount.maxRate"); // читаем глобальный конфиг
discount = Math.min(discount, rate);
order.discountApplied = discount; // мутируем аргумент
totalDiscountsIssued += 1; // мутируем глобальное состояние
logger.info(`discount ${discount} for ${order.id}`); // пишем в лог
metrics.increment("discount.applied"); // ходим по сети
return order.total * (1 - discount);
}
Сколько здесь проблем? Считаем:
- Тест зависит от календаря. В декабре он даёт один результат, в январе — другой. Классическая ситуация «на CI зелено, на моей машине красное» и наоборот.
- Тест зависит от глобального конфига. Значит, тесты нельзя гонять параллельно: один тест поменял
discount.maxRate, другой упал. - Функция мутирует аргумент. Вызывающий код после
applyDiscount(order)внезапно обнаруживает изменённыйorder. Если тот жеorderлежит в кеше — вы уже отравили кеш. - Счётчик
totalDiscountsIssuedломает идемпотентность. Повторный вызов с теми же данными меняет состояние системы. Ретрай HTTP-запроса даст двойную метрику. - Нельзя закешировать. Даже если вы точно знаете, что для этого заказа результат уже считали — нельзя пропустить вызов, потому что вызов «что-то делает».
- Нельзя перенести вызов. Поменять две строки местами — потенциально изменить поведение. Рефакторинг превращается в сапёрное дело.
И главное: ничего из этого не видно в сигнатуре. (order: Order) => number выглядит как безобидное вычисление. Тип соврал.
Функциональное программирование начинается с наблюдения: почти все шесть проблем выше исчезнут, если функция будет честной — если всё, что она читает, придёт аргументами, а всё, что она делает, вернётся результатом.
Определение: чистота — это два свойства
Функция чистая (pure), если выполняются оба условия:
- Детерминизм. При одних и тех же аргументах она всегда возвращает один и тот же результат. Никаких часов,
random, чтения глобальных переменных, файлов, БД, сети, переменных окружения. - Отсутствие наблюдаемых побочных эффектов. Вызов не меняет ничего за пределами функции: не мутирует аргументы, не пишет в глобальные переменные, не пишет в файл/сеть/консоль, не бросает исключений как способ вернуть результат.
Оба пункта нужны. Функция logAndAdd(a, b) { console.log(a); return a + b; } детерминирована, но нечиста. Функция now() не имеет эффектов, но не детерминирована. Чистая функция — это в точности математическая функция: отображение из входов в выходы, ничего больше.
Отсюда следует ключевое свойство — референциальная прозрачность (referential transparency): любой вызов чистой функции можно заменить на её результат, и программа не изменится. Формально это и есть определение, а практически — это разрешение на модель подстановки: чтобы понять, что делает выражение, достаточно подставлять значения вместо вызовов, как в школьной алгебре.
-- Каноническая запись на Haskell. Тип честен: никаких эффектов в нём нет.
discountRate :: Int -> Int -> Double -- yearsWithUs -> month -> rate
discountRate years month =
min 0.15 (loyalty + seasonal)
where
loyalty = if years >= 3 then 0.10 else 0.0
seasonal = if month == 12 then 0.05 else 0.0
-- Подстановка: discountRate 5 12 == min 0.15 (0.10 + 0.05) == 0.15
-- Это верно всегда, в любой момент, в любом потоке.
Сравните с исходным TypeScript: там applyDiscount(order) нельзя заменить на число, потому что вызов ещё и увеличивает счётчик, и пишет лог, и мутирует заказ.
Таксономия побочных эффектов
Слово «эффект» звучит расплывчато. Полезно иметь конкретный список того, что именно ломает чистоту — так проще проводить ревью.
эффекты)) Скрытые входы системное время генератор случайных глобальные переменные переменные окружения чтение файла, БД, HTTP чтение изменяемого поля объекта идентичность объекта Скрытые выходы мутация аргумента запись в глобальные запись в поле this запись в файл, БД, сокет вывод в консоль и лог выброс исключения порождение потока или процесса Пограничные мемоизация ленивая инициализация счётчики и трассировка выделение памяти и GC время выполнения
Скрытые входы ломают детерминизм. Скрытые выходы ломают возможность подстановки. Отдельного разговора заслуживают пограничные случаи — здесь и живут все интересные споры.
Пограничные случаи: где чистота — вопрос уровня наблюдения
Ключевое слово в определении — наблюдаемый эффект. Функция может внутри мутировать сколько угодно, если снаружи это не видно.
def sort_and_sum(items: list[int]) -> int:
"""Чистая функция, хотя внутри мутирует список."""
local = list(items) # копия — мутируем свою, а не чужую
local.sort() # мутация в полный рост
total = 0
for x in local: # цикл с изменяемым аккумулятором
total += x
return total
Эта функция чиста: local и total — локальные, наружу не утекают. Такой приём называется локальной мутацией и он абсолютно легален. В Haskell для этого есть специальный механизм — runST, который позволяет использовать настоящие изменяемые массивы внутри и при этом иметь чистую сигнатуру снаружи. Тип ST s гарантирует, что ссылка не сбежит.
Мемоизация — эффект, невидимый в значении, но видимый во времени. Чистая по значению, нечистая по производительности. Практически весь мир согласился считать её чистой:
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n: int) -> int: # снаружи: (int) -> int, детерминирована
return n if n < 2 else fib(n - 1) + fib(n - 2)
# Кеш — скрытое состояние, но результат от него не зависит.
# Сложность: без кеша O(φⁿ) времени; с кешем O(n) времени и O(n) памяти.
Логирование — самый спорный. Строго говоря, это запись во внешний мир, значит эффект. Прагматически — если лог не влияет на логику и не участвует в контракте, многие команды закрывают глаза. Правильный компромисс: чистая функция возвращает то, что надо залогировать (список сообщений, событий), а пишет их вызывающий слой. Так вы получаете тестируемость: можно проверить, что функция «решила» залогировать предупреждение, не перехватывая stdout.
Исключение — тоже эффект: функция обещала вернуть number, а вместо этого разрушила стек вызовов. В ФП вместо исключений возвращают значение — Option/Either/Result, об этом целая статья: https://courses.digitable.life/post/functional-programming/10-error-handling/. Не путайте с «настоящими» аварийными ситуациями (нехватка памяти, баг-инвариант) — их бросать нормально, они не часть бизнес-логики.
Как переписать боль в чистый вид
Вернёмся к примеру. Приём один и тот же: всё, что функция читала из мира, становится параметром; всё, что она делала с миром, становится частью возвращаемого значения.
type DiscountDecision = {
rate: number;
finalTotal: number;
events: DomainEvent[]; // то, что раньше было логом и метрикой
};
// Чистая. Всё нужное — в аргументах. Всё сделанное — в результате.
function decideDiscount(
order: Order,
today: Date, // время — параметр, а не new Date()
maxRate: number, // конфиг — параметр, а не config.get()
): DiscountDecision {
const years = today.getFullYear() - order.customer.since;
const loyalty = years >= 3 ? 0.10 : 0;
const seasonal = today.getMonth() === 11 ? 0.05 : 0;
const rate = Math.min(loyalty + seasonal, maxRate);
return {
rate,
finalTotal: order.total * (1 - rate),
events: rate > 0 ? [{ type: "DiscountApplied", orderId: order.id, rate }] : [],
};
}
// Нечистая оболочка — тонкая, почти без логики, её тестируем интеграционно.
async function handleOrder(order: Order): Promise<number> {
const decision = decideDiscount(order, new Date(), config.get("discount.maxRate"));
for (const e of decision.events) {
logger.info(e);
metrics.increment(e.type);
}
return decision.finalTotal;
}
Что изменилось по факту:
| Было | Стало |
|---|---|
| Тест зависит от месяца | Передаём new Date("2025-12-05") — тест детерминирован |
| Тесты нельзя параллелить | Нет общего состояния — параллелятся свободно |
Мокаем logger, metrics, config |
Моки не нужны: проверяем возвращённый events |
order мутирован |
order не тронут, можно кешировать и переиспользовать |
| Ретрай удваивает метрику | decideDiscount идемпотентна по определению |
Обратите внимание на цену: сигнатура выросла с одного параметра до трёх. Это не побочный ущерб, это явное указание зависимостей. Функция, которой нужны часы и конфиг, теперь честно об этом говорит. Именно поэтому чистый код часто выглядит «многословнее» — он не прячет связи, а показывает их.
Дерево решений: чистая ли эта функция?
чего нет в аргументах?} B -- "время, random, глобальные,
IO, изменяемое поле" --> N[НЕЧИСТАЯ:
скрытый вход] B -- нет --> C{Меняет что-то
вне своего стека?} C -- "аргумент, глобальные,
this, файл, сеть, консоль" --> M[НЕЧИСТАЯ:
скрытый выход] C -- нет --> D{Бросает исключение
как часть контракта?} D -- да --> E[НЕЧИСТАЯ:
скрытый выход через стек] D -- нет --> F{Мутация только
локальных значений?} F -- да --> P[ЧИСТАЯ
локальная мутация легальна] F -- нет --> P N --> R[Вынести эффект
в оболочку] M --> R E --> S[Вернуть Result/Either
вместо throw]
Практическое правило для ревью: прочитайте сигнатуру и попробуйте предсказать, что функция может сделать. Если реальность богаче предсказания — функция врёт.
Что чистота даёт на самом деле
Это не эстетика. Каждое свойство конвертируется в конкретную инженерную возможность.
1. Тесты без моков
Чистая функция тестируется как таблица: вход → выход. Не нужны фикстуры БД, не нужны фейковые часы, не нужен jest.mock. Это самая быстрая обратная связь, которая вообще бывает.
defmodule Billing do
@moduledoc "Чистое ядро расчёта скидки."
@spec decide_discount(Order.t(), Date.t(), float()) :: Decision.t()
def decide_discount(%Order{} = order, %Date{} = today, max_rate) do
loyalty = if today.year - order.customer.since >= 3, do: 0.10, else: 0.0
seasonal = if today.month == 12, do: 0.05, else: 0.0
rate = min(loyalty + seasonal, max_rate)
%Decision{
rate: rate,
final_total: order.total * (1 - rate),
events: if(rate > 0, do: [{:discount_applied, order.id, rate}], else: [])
}
end
end
# Тест — просто сравнение значений, без setup/teardown.
test "декабрь + выслуга даёт 15%" do
order = %Order{total: 1000.0, customer: %{since: 2018}, id: "A-1"}
decision = Billing.decide_discount(order, ~D[2025-12-05], 0.20)
assert decision.rate == 0.15
assert decision.final_total == 850.0
end
2. Property-based тестирование
Настоящий бонус: у чистых функций можно проверять свойства на тысячах сгенерированных входов, а не отдельные примеры. Для нечистой функции это почти невозможно — генератор упрётся в состояние.
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_sort_idempotent(xs):
# Свойство: сортировка сортированного ничего не меняет.
assert sorted(sorted(xs)) == sorted(xs)
@given(st.lists(st.integers()))
def test_sort_is_permutation(xs):
# Свойство: сортировка сохраняет мультимножество элементов.
from collections import Counter
assert Counter(sorted(xs)) == Counter(xs)
Инструменты: Hypothesis (Python), fast-check (TS), StreamData (Elixir), QuickCheck (Haskell, прародитель всего жанра). Подробнее о подходе — в треке тестирования: https://courses.digitable.life/post/testing/00-overview/.
3. Кеширование и мемоизация — бесплатно и безопасно
Если функция чистая, кеш всегда корректен: результат зависит только от аргументов. Отсюда lru_cache, React.memo, useMemo, кеш компилятора, инкрементальные системы сборки (Bazel, Nix), кеш селекторов в Redux. Все они держатся ровно на одном допущении — что вы им дали чистую функцию. Соврали — получили неуловимый баг.
4. Параллелизм без блокировок
Гонка данных требует двух вещей: разделяемого состояния и записи в него. Чистые функции не пишут в разделяемое состояние. Значит, любое количество потоков может вызывать их одновременно без единого мьютекса.
нет блокировок, нет гонок Pure-->>W1: decision1 Pure-->>W2: decision2 W1->>DB: применить эффекты (транзакция) W2->>DB: применить эффекты (транзакция) Note over DB: синхронизация нужна
только здесь, на границе
Это тот же принцип, на котором построена модель акторов в Erlang/Elixir и параллельные коллекции. Подробно — https://courses.digitable.life/post/functional-programming/14-concurrency/.
5. Рефакторинг становится механическим
Референциальная прозрачность — это лицензия на преобразования. Вынести подвыражение в переменную, поднять инвариант из цикла, переставить независимые вызовы, вычислить константу на этапе компиляции, распараллелить map — всё это безопасно только для чистого кода. Компиляторы Haskell и GHC делают эти преобразования агрессивно именно потому, что типы гарантируют чистоту.
6. Локальное рассуждение
Самая недооценённая выгода. Чтобы понять чистую функцию, достаточно прочитать её тело. Не нужно знать, кто её вызывал раньше, какое состояние успело накопиться, в каком потоке она исполняется. Именно это «Out of the Tar Pit» Moseley и Marks называют главным источником сложности в софте: не алгоритмы, а state — необходимость держать в голове историю системы.
Честная цена: где чистота стоит дорого
Курс без этого раздела был бы рекламой. Чистота — это компромисс, и у него есть счёт.
Производительность: аллокации вместо записи по адресу
Нечистый цикл записывает в существующий буфер. Чистый — создаёт новые значения. На микроуровне разница огромна.
# Императивно: одна аллокация, запись на месте. O(n) времени, O(1) доп. памяти.
def normalize_inplace(xs: list[float], k: float) -> None:
for i in range(len(xs)):
xs[i] /= k
# Чисто: новый список. O(n) времени, O(n) доп. памяти + давление на GC.
def normalize(xs: list[float], k: float) -> list[float]:
return [x / k for x in xs]
Для списка на 10 элементов разницы нет. Для матрицы 4096×4096 в горячем цикле — есть, и принципиальная: лишние аллокации, промахи кеша, работа сборщика мусора. Численный код, обработка изображений, физика, парсеры на мегабайтных входах — там, где важна локальность памяти, наивная чистота проигрывает в разы.
Что с этим делают:
- Персистентные структуры с разделением памяти — копия не O(n), а O(log n). Цена всё равно есть: лишняя косвенность и худшая локальность кеша. Об этом честно: https://courses.digitable.life/post/functional-programming/12-persistent-data-structures/.
- Локальная мутация под чистым фасадом —
STв Haskell, приватные буферы,transientв Clojure. - Ослабление правил в горячих 3% кода. Профилируйте, а не гадайте.
Кривая обучения и цена абстракций
Команда, которая вчера писала сервисы с DI-контейнером, не начнёт завтра думать в терминах трансформеров монад. Реальные наблюдения из проектов:
- Первые два-три месяца скорость разработки падает. Это нормально и это надо заложить в план.
- Абстракции ФП композируются, но отладка композиции сложнее. Стектрейс через десять комбинаторов читается хуже, чем через пять методов. В Haskell к этому добавляется ленивость: место падения не совпадает с местом ошибки.
- Есть реальный риск «астронавтики»: код, где для чтения конфига построен стек из
ReaderTповерхExceptTповерхStateT, и джуниор не может внести правку. Иногда обычная функция с тремя параметрами лучше.
Где чистота прямо мешает
- Алгоритмы, спроектированные под мутацию. Union-Find со сжатием путей, сортировка на месте, хеш-таблицы с открытой адресацией, обход графа с массивом
visited. Чистые аналоги существуют, но обычно медленнее и сложнее. См. https://courses.digitable.life/post/data-structures/00-overview/. - Работа с изначально императивным API. Canvas, OpenGL, драйверы, файловые дескрипторы, большинство SDK. Оборачивать всё в чистые абстракции — сизифов труд; правильнее держать тонкую нечистую границу.
- Простой CRUD. Если функция — это «прочитать строку из БД и отдать JSON», в ней нет логики, которую стоит выделять в чистое ядро. Разделение даст только лишний слой.
- Раздувание сигнатур. Явное протаскивание часов, конфига, генератора случайных чисел через шесть уровней вызовов утомляет. Это лечится (Reader-паттерн, https://courses.digitable.life/post/functional-programming/13-effects-and-io/), но лечение — тоже сложность.
Практический вывод: чистота — это градиент, а не выключатель. Цель не «100% чистый код», а «чистое ядро, где живёт вся сложная логика, и тонкая грязная оболочка вокруг».
Функциональное ядро, императивная оболочка
Это самый ценный архитектурный приём, который следует из чистоты. Формулировка Gary Bernhardt из доклада «Boundaries»; у Mark Seemann тот же приём называется impureim sandwich.
HTTP, БД, часы, конфиг"] OUT["Применить эффекты:
запись в БД, письма, метрики"] end subgraph Core["Функциональное ядро — толстое, вся логика"] direction TB C1["Валидация"] --> C2["Расчёт решения"] --> C3["Список событий"] end IN -->|"чистые данные"| C1 C3 -->|"описание эффектов"| OUT style Core stroke-width:2px
Правило: решения принимает ядро, действия совершает оболочка. Ядро возвращает не «сделано», а «вот что надо сделать». Оболочка глупая: считать → вызвать ядро → записать.
# Оболочка: единственное место, где есть IO. Логики почти нет — тестируем интеграционно.
def handle_order(order_id) do
with {:ok, order} <- Repo.fetch_order(order_id), # эффект: чтение
today = Date.utc_today(), # эффект: часы
max_rate = Application.get_env(:billing, :max), # эффект: конфиг
decision = Billing.decide_discount(order, today, max_rate) do
Enum.each(decision.events, &EventBus.publish/1) # эффект: запись
Repo.save_total(order_id, decision.final_total) # эффект: запись
{:ok, decision.final_total}
end
end
Как понять, что вы делаете это правильно: количество юнит-тестов с моками стремится к нулю. Если у вас в тесте пять mock-ов, значит логика перемешана с эффектами и живёт в оболочке.
Тот же принцип в других обличьях: Ports & Adapters (гексагональная архитектура), Redux-редьюсер (state, action) => newState, чистые компоненты React (документация прямо этого требует), event sourcing, где decide и evolve — чистые функции. Подробнее — https://courses.digitable.life/post/functional-programming/16-architecture-and-practice/ и https://courses.digitable.life/post/architecture-patterns/00-overview/.
Типичные ошибки
Считать функцию чистой, если она «ничего не пишет». Чтение изменяемого глобального состояния — тоже нарушение. getConfig() нечиста, даже если только читает.
Мутировать «свой» объект, который на самом деле чужой. Классика в JS/Python:
// НЕЧИСТАЯ: sort мутирует исходный массив
function topThree(items: Item[]): Item[] {
return items.sort((a, b) => b.score - a.score).slice(0, 3);
}
// ЧИСТАЯ: копия перед сортировкой
function topThree(items: readonly Item[]): Item[] {
return [...items].sort((a, b) => b.score - a.score).slice(0, 3);
// или items.toSorted(...) в ES2023
}
def add_tag(tags: list[str] = []) -> list[str]: # ЛОВУШКА: изменяемый дефолт
tags.append("new") # мутирует общий объект между вызовами
return tags
def add_tag(tags: tuple[str, ...] = ()) -> tuple[str, ...]: # чисто
return tags + ("new",)
Прятать эффект за геттером. Свойство order.total, которое лениво ходит в БД, — это эффект, замаскированный под чтение поля. Худший вид, потому что читателя кода ничто не предупреждает.
Считать, что типы за вас всё проверят. В TypeScript readonly — иллюзия времени компиляции; в рантайме объект мутируем. В Python аннотации не проверяются вообще. Настоящую гарантию даёт только система эффектов (Haskell, PureScript, Effect-TS, Unison) — во всех остальных языках чистота держится на дисциплине и ревью.
Тестировать оболочку так же тщательно, как ядро. Смысл разделения в том, что 90% тестов — быстрые юнит-тесты ядра, а оболочка покрывается парой интеграционных. Если наоборот — разделение не окупилось.
Уходить в чистоту там, где нет логики. Чистое ядро для функции, которая просто перекладывает поля, — накладные расходы без выгоды.
Инструменты, которые помогают удерживать чистоту
- TypeScript:
readonly,as const,Readonly<T>, ESLint-правилаfunctional/no-let,functional/immutable-data, библиотеки Immer и Effect (полноценная система эффектов в типах). - Python:
frozen=Trueв@dataclass,tuple/frozenset,types.MappingProxyType,functools.lru_cache, линтер flake8-mutable против изменяемых дефолтов. - Elixir: данные неизменяемы на уровне BEAM — чистота почти «по умолчанию», эффекты локализованы в процессах и
GenServer. См. https://courses.digitable.life/post/elixir/00-overview/. - Haskell: чистота проверяется компилятором.
IOв типе — единственный способ совершить эффект; всё остальное гарантированно чисто. - C#/Java/Kotlin: records,
readonly struct,final, immutable-коллекции (Guava,System.Collections.Immutable),data class+copy(). См. https://courses.digitable.life/post/functional-programming/15-fp-in-mainstream-languages/.
Мини-итог
- Чистая функция = детерминизм + отсутствие наблюдаемых эффектов. Это буквально математическая функция.
- Следствие — референциальная прозрачность: вызов можно заменить результатом. Отсюда безопасные рефакторинги, кеширование, параллелизм, локальное рассуждение.
- Эффект — это скрытый вход (часы, глобальные, IO) или скрытый выход (мутация, запись, исключение). Локальная мутация эффектом не является.
- Приём переписывания один: скрытые входы → параметры, скрытые выходы → часть результата. Сигнатура растёт — и это хорошо, она перестаёт врать.
- Цена реальна: аллокации вместо записи на месте, худшая локальность кеша, кривая обучения, раздутые сигнатуры, сложная отладка композиции.
- Архитектурный вывод: толстое чистое ядро с логикой + тонкая нечистая оболочка. Ядро решает, оболочка делает.
Следующая опора — неизменяемость данных. Мы уже опирались на неё («не мутируй аргумент»), но не разобрали, как жить без мутации на практике и во сколько это обходится.
Материалы
- John Hughes, «Why Functional Programming Matters» — классическое эссе о том, зачем всё это.
- Ben Moseley, Peter Marks, «Out of the Tar Pit» — про состояние как главный источник сложности.
- Gary Bernhardt, «Boundaries» — доклад, из которого вырос термин functional core / imperative shell.
- Mark Seemann, «Impureim sandwich» — практический паттерн изоляции эффектов.
- Rich Hickey, «Simple Made Easy» — о разнице между простым и знакомым.
- Abelson, Sussman, SICP, глава 3.1 — каноничный разбор того, что ломает модель подстановки.
- React: Keeping Components Pure — чистота как требование продакшн-фреймворка.
Что дальше
Неизменяемость: как работать с данными, ничего не меняя — почему «не мутируй» не означает «копируй всё», как устроены структурное разделение и copy-on-write, и сколько это стоит в разных языках.