Перейти к содержанию
Innovialabs

Чистые функции и иммутабельность: предсказуемый код

Практика4 мин

Коротко

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

Короткий ответ

У чистой функции два свойства:

  1. Одинаковый вход — одинаковый выход: она зависит только от своих аргументов.
  2. Нет побочных эффектов: она не меняет аргументы, глобальные переменные, файлы, базу данных или экран.

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

Как общее изменяемое состояние порождает баги

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(), спрятанные внутри «чистой» логики.
  • Копирование огромных структур в горячих циклах без замеров.
  • Насильственная иммутабельность везде, где локальная изменяемая переменная внутри одной функции проще и безвредна: локальная мутация, которая не выходит наружу, не делает функцию нечистой.

Частые вопросы

Копировать небольшие объекты дёшево, а при структурном разделении новым становится только изменённый путь. Для очень больших данных и горячих циклов сначала измерьте; помочь могут библиотеки персистентных структур данных.

Поможем с этим

Если хотите не разбираться самим, а получить результат — этим мы занимаемся каждый день.

Обсудить проект

Читайте также

Есть идея или задача? Расскажите нам

Ответим в течение рабочего дня и предложим решение. Консультация бесплатная.