Чистые функции и иммутабельность: предсказуемый код
Практика4 минРедакция Innovialabs
Коротко
Чистая функция возвращает одинаковый результат для одинаковых данных и ничего не меняет снаружи. Сочетайте чистые функции с неизменяемыми данными, выносите побочные эффекты на края — и большинство багов «где-то что-то поменялось» исчезнут.
Короткий ответ
У чистой функции два свойства:
- Одинаковый вход — одинаковый выход: она зависит только от своих аргументов.
- Нет побочных эффектов: она не меняет аргументы, глобальные переменные, файлы, базу данных или экран.
Иммутабельность означает, что созданные данные не изменяются — вместо этого создаётся новая версия. Вместе они делают код предсказуемым: функцию можно прочитать отдельно, протестировать без подготовки окружения и вызывать откуда угодно без опасений.
Как общее изменяемое состояние порождает баги
function applyDiscount(cart) {
cart.items.forEach((item) => {
item.price = item.price * 0.9;
});
return cart;
}
const preview = applyDiscount(cart);
// исходная cart тоже со скидкой — а при повторном вызове скидка применится дважды
Вызывающий код хотел превью, а изменилась исходная корзина. Любой другой компонент со ссылкой на cart теперь показывает неверные цены. Такие баги трудно искать: место, где данные меняются, далеко от места, где проявляется ошибка.
Типичные источники проблем:
- функции, которые изменяют свои аргументы;
- глобальные переменные или переменные модуля, которые меняются из разных мест;
- объекты, общие для нескольких потоков или асинхронных задач;
- кэши и синглтоны, которые возвращают всем один и тот же изменяемый объект.
Чистая версия
function applyDiscount(cart, rate) {
return {
...cart,
items: cart.items.map((item) => ({ ...item, price: item.price * (1 - rate) })),
};
}
Оригинал не тронут, результат зависит только от cart и rate, повторный вызов даёт тот же ответ.
Изоляция побочных эффектов
Без побочных эффектов программа бесполезна: нужно сохранять заказы, отправлять письма, писать логи. Цель — не убрать их, а вынести на края: тонкий слой читает вход и пишет выход, а чистое ядро принимает решения.
# чистое ядро: легко тестировать
def calculate_invoice(order, tax_rate):
subtotal = sum(i["price"] * i["qty"] for i in order["items"])
return {"subtotal": subtotal, "tax": subtotal * tax_rate}
# нечистая оболочка: только ввод-вывод
def handle_order(order_id, repo, mailer):
order = repo.get(order_id)
invoice = calculate_invoice(order, tax_rate=0.12)
repo.save_invoice(order_id, invoice)
mailer.send(order["email"], invoice)
Ставка налога здесь — просто пример аргумента. Бизнес-логику в calculate_invoice тестируют на обычных данных, моки нужны только для маленькой оболочки.
Чистоту ломают и скрытые входы: текущее время, случайные числа, переменные окружения. Передавайте их аргументами (now, seed, config), чтобы функция оставалась детерминированной.
Приёмы иммутабельности по языкам
JavaScript / TypeScript
constдля привязок (но он не замораживает сам объект).- Копирование через spread:
{ ...obj, field: value },[...arr, item]. - Немутирующие методы:
map,filter,concat,slice, а также более новыеtoSorted,toReversed,with, если их поддерживает ваша среда. Помните:sort,reverseиspliceмутируют массив. Object.freezeдля констант (заморозка поверхностная).- В TypeScript
readonlyиReadonly<T>ловят мутации на этапе компиляции.
Python
- Кортежи и
frozensetвместо списков и множеств для фиксированных данных. @dataclass(frozen=True)создаёт неизменяемые записи,dataclasses.replace(obj, field=value)— изменённую копию.- Избегайте изменяемых значений по умолчанию вроде
def f(items=[])— список общий для всех вызовов.
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Item:
name: str
price: float
cheaper = replace(Item("Pen", 10.0), price=9.0)
Kotlin
valвместоvar.List,Map,Set— интерфейсы только для чтения;MutableList— только там, где нужно.data classсо свойствамиvalиcopy()для обновлений.
data class Item(val name: String, val price: Double)
val item = Item("Pen", 10.0)
val cheaper = item.copy(price = 9.0)
Учтите: read-only List в Kotlin может опираться на изменяемый список в другом месте. Понимайте это как «вы не можете его изменить», а не «его не может изменить никто».
Частые ошибки
- Поверхностные копии: скопировали верхний уровень, но мутируете вложенный объект.
Date.now()илиMath.random(), спрятанные внутри «чистой» логики.- Копирование огромных структур в горячих циклах без замеров.
- Насильственная иммутабельность везде, где локальная изменяемая переменная внутри одной функции проще и безвредна: локальная мутация, которая не выходит наружу, не делает функцию нечистой.