Неизменяемость: как работать с данными, ничего не меняя
Баг, с которого всё начинается
Прежде чем определять неизменяемость, посмотрим на боль, которую она лечит. Вот код на Python, который писал каждый:
class Order:
def __init__(self, items=[]): # ловушка №1: изменяемый аргумент по умолчанию
self.items = items # ловушка №2: сохраняем чужой список по ссылке
def add(self, item):
self.items.append(item) # ловушка №3: мутируем на месте
return self
a = Order()
b = Order()
a.add("книга")
print(b.items) # ['книга'] — заказ b «увидел» товар заказа a
Список по умолчанию создаётся один раз при определении функции, и все заказы делят один объект. Это классика, её знают. Но вторая ловушка коварнее и живёт в проде годами:
корзина = ["молоко"]
заказ = Order(корзина)
заказ.add("хлеб")
print(корзина) # ['молоко', 'хлеб'] — мы изменили чужие данные
Order не копировал список — он взял ссылку. Теперь у одного объекта в памяти два имени, и изменение через одно имя видно через другое. Это называется алиасинг (aliasing), и он источник целого семейства багов: «данные поменялись сами», «в логах одно, в базе другое», «работает, пока не включишь второй поток».
Классический ответ ООП — защитное копирование (self.items = list(items)) и возврат копий из геттеров. Это работает, но требует дисциплины в каждой точке кода: забыл один list(...) — и утечка мутации возвращается. Джошуа Блох посвятил этому целых два пункта в «Effective Java» (Item 50 «Make defensive copies when needed», Item 17 «Minimize mutability»), и его вывод честен: проще сделать объект неизменяемым, чем помнить про копии.
Функциональное программирование доводит эту мысль до предела: данные не меняются никогда. Не «стараемся не менять», а «изменить физически нельзя». Всё остальное в этой статье — про то, как с таким ограничением жить и почему оно окупается.
Что такое неизменяемость на самом деле
Тут важно развести три разные вещи, которые в разговоре сливаются в одну.
Значение — это то, что не имеет истории и идентичности. Число 42 не «становится» 43. Строка «привет» не меняется — из неё делают другую строку. Значения вне времени.
Ячейка (переменная) — это имя, привязанное к значению. Привязку можно переназначить.
Объект — область памяти, у которой есть идентичность (адрес) и состояние, которое может меняться.
Неизменяемость — это про третье: запрет менять состояние по адресу. И вот здесь живёт ошибка №1 у новичков — const не делает данные неизменяемыми:
const user = { name: "Аня", tags: ["admin"] };
user.name = "Боря"; // разрешено: const запрещает переприсваивание имени,
// а не изменение объекта, на который оно указывает
// user = {}; // а вот это — ошибка компиляции
const замораживает ячейку, а не объект. Для объекта нужен либо рантайм-запрет (Object.freeze), либо типовой (readonly, Readonly<T>), либо — лучше всего — язык, где мутации просто нет.
Второе разделение — поверхностная и глубокая неизменяемость:
const u = Object.freeze({ name: "Аня", tags: ["admin"] });
u.name = "Боря"; // молча игнорируется (в strict mode — TypeError)
u.tags.push("root"); // РАБОТАЕТ: заморожен только внешний объект
Object.freeze поверхностный. Глубокая заморозка требует рекурсивного обхода, а это уже цена. В TypeScript Readonly<T> тоже поверхностный:
type User = { name: string; tags: string[] };
const u: Readonly<User> = { name: "Аня", tags: ["admin"] };
u.name = "Боря"; // ошибка типов — хорошо
u.tags.push("root"); // компилируется! tags остался мутабельным string[]
// правильно — рекурсивный тип:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends (infer E)[]
? readonly DeepReadonly<E>[]
: T[K] extends object
? DeepReadonly<T[K]>
: T[K];
};
const v: DeepReadonly<User> = { name: "Аня", tags: ["admin"] };
// v.tags.push("root"); // ошибка: у readonly-массива нет push
Третье разделение — где проходит граница. Есть языки, где неизменяемость встроена (Haskell, Elixir/Erlang, Clojure, F#), и языки, где она достигается дисциплиной и типами (TypeScript, Python, Java, C#, Kotlin). Разница огромная: в первом случае вы не можете ошибиться, во втором — можете, и это вопрос ревью и линтеров.
Что мы получаем взамен
Ограничение окупается пятью очень конкретными вещами.
1. Локальность рассуждения. Если функция получила структуру, никто не изменит её из-под ног. Читая 30 строк кода, вы не обязаны знать остальные 30 тысяч. Это главная причина, почему ФП масштабируется на большие команды: контракт «я не трогаю ваши данные» соблюдается компилятором, а не договорённостью.
2. Бесплатная потокобезопасность. Гонка данных требует двух условий: разделяемое состояние и его изменение. Убираем второе — гонка невозможна по построению.
нужен мьютекс или CAS Note over T1,T2: с неизменяемыми данными
обе стороны читают одну версию,
а новая версия создаётся атомарно
подменой одной ссылки
Это причина, по которой Erlang/Elixir выдерживают миллионы процессов без единого мьютекса на уровне прикладного кода: сообщение между процессами — копия или ссылка на неизменяемый терм, менять его некому. Подробнее в статье https://courses.digitable.life/post/functional-programming/14-concurrency/.
3. Дешёвые снимки, undo и аудит. Если старая версия не разрушается, «предыдущее состояние» — это просто сохранённая ссылка. Отсюда time-travel debugging в Redux, undo/redo в редакторах, MVCC в базах данных и весь event sourcing.
4. Корректное равенство и кэширование. Неизменяемый объект можно безопасно использовать как ключ словаря, мемоизировать по нему результат, положить в кэш. Мутабельный объект в роли ключа — классическая утечка: изменили поле, хеш поехал, элемент «пропал» из HashMap.
5. Ссылочная прозрачность. Выражение можно заменить его значением. Это фундамент всей остальной книги: без неё нет ни честной мемоизации, ни свободного переупорядочивания, ни безопасного распараллеливания. См. https://courses.digitable.life/post/functional-programming/01-pure-functions/.
Как обновлять, ничего не меняя
Главный вопрос практика: «а как тогда менять данные?» Ответ: не менять, а порождать новую версию с изменением. Синтаксис для этого есть в любом приличном языке.
Elixir
В Elixir мутации нет физически, поэтому язык даёт специальный синтаксис обновления структур:
defmodule Order do
# структура с обязательными полями
defstruct [:id, status: :draft, items: [], total: 0]
# «изменение» — это функция старая_версия -> новая_версия
def add_item(%Order{items: items, total: total} = order, item) do
%Order{order | items: [item | items], total: total + item.price}
# ^^^^^ синтаксис обновления: копия order с заменой двух полей
end
def pay(%Order{status: :draft} = order), do: %Order{order | status: :paid}
def pay(%Order{status: s}), do: {:error, {:cannot_pay_from, s}}
end
order = %Order{id: 1}
paid = order |> Order.add_item(%{name: "книга", price: 500}) |> Order.pay()
order.status # :draft — исходный заказ цел
paid.status # :paid
Обратите внимание на [item | items] — новый список создаётся добавлением головы, а хвост переиспользуется физически, без копирования. Это первый пример структурного разделения, о котором дальше.
Для вложенных структур в стандартной библиотеке есть put_in/update_in — прообраз линз:
state = %{user: %{profile: %{city: "Москва", tags: ["admin"]}}}
# без помощников это выглядело бы как три вложенных обновления
new_state = put_in(state, [:user, :profile, :city], "Казань")
tagged = update_in(state, [:user, :profile, :tags], &["root" | &1])
state.user.profile.city # "Москва" — не тронут
new_state.user.profile.city # "Казань"
TypeScript
type Item = { readonly name: string; readonly price: number };
type Order = {
readonly id: number;
readonly status: "draft" | "paid" | "shipped";
readonly items: readonly Item[];
};
// обновление через spread — поверхностная копия с заменой полей
const addItem = (o: Order, item: Item): Order => ({
...o,
items: [...o.items, item], // новый массив, элементы переиспользуются
});
const pay = (o: Order): Order => ({ ...o, status: "paid" });
const o1: Order = { id: 1, status: "draft", items: [] };
const o2 = pay(addItem(o1, { name: "книга", price: 500 }));
// o1 не изменился
Уже на трёх уровнях вложенности spread превращается в кашу:
// боль: обновить город внутри профиля внутри пользователя
const next = {
...state,
user: {
...state.user,
profile: { ...state.user.profile, city: "Казань" },
},
};
Именно эта боль породила линзы (оптику) — компонуемые пары «геттер + функциональный сеттер». Они полностью раскрываются в статье про композицию, https://courses.digitable.life/post/functional-programming/05-composition-and-currying/; в экосистеме TypeScript это monocle-ts и optics-ts. Прагматичная альтернатива — библиотека Immer, которая даёт императивный синтаксис поверх copy-on-write:
import { produce } from "immer";
const next = produce(state, (draft) => {
draft.user.profile.city = "Казань"; // пишем «как будто мутируем»
draft.user.profile.tags.push("root"); // draft — это Proxy
});
// state не изменился; next переиспользует все нетронутые ветки
Immer перехватывает записи через Proxy, помечает изменённые узлы и на выходе копирует только путь до них. Это ровно то самое path copying, что на картинке ниже, только реализованное поверх обычных JS-объектов. Цена — накладные расходы прокси на каждое чтение внутри produce (в горячих циклах на десятки тысяч операций это заметно, см. раздел про цену).
Python
from dataclasses import dataclass, replace, field
@dataclass(frozen=True, slots=True) # frozen=True запрещает присваивание полям
class Item:
name: str
price: int
@dataclass(frozen=True, slots=True)
class Order:
id: int
status: str = "draft"
items: tuple[Item, ...] = () # tuple, а не list: list сделал бы объект мутабельным
def add(self, item: Item) -> "Order":
return replace(self, items=self.items + (item,))
def pay(self) -> "Order":
return replace(self, status="paid")
o1 = Order(id=1)
o2 = o1.add(Item("книга", 500)).pay()
# o1.status == "draft", o2.status == "paid"
# o1.items = () попытка присваивания -> FrozenInstanceError
Два практических нюанса. Первый: frozen=True даёт поверхностную неизменяемость — положите внутрь list, и объект снова мутабельный (поэтому здесь tuple). Второй: frozen=True автоматически даёт __hash__, то есть объект можно класть в set и использовать ключом словаря — что для мутабельного dataclass запрещено намеренно.
Для по-настоящему персистентных коллекций в Python есть pyrsistent (PVector, PMap, PSet) — со структурным разделением, а не копированием.
Haskell
Каноническая запись. Здесь неизменяемость — не стиль, а свойство языка: = означает математическое определение, а не присваивание.
data Status = Draft | Paid | Shipped deriving (Show, Eq)
data Item = Item { name :: String, price :: Int } deriving (Show, Eq)
data Order = Order
{ orderId :: Int
, status :: Status
, items :: [Item]
} deriving (Show, Eq)
-- синтаксис обновления записи: o { поле = значение } создаёт НОВУЮ запись
addItem :: Item -> Order -> Order
addItem i o = o { items = i : items o }
pay :: Order -> Order
pay o = o { status = Paid }
o1 :: Order
o1 = Order { orderId = 1, status = Draft, items = [] }
o2 :: Order
o2 = pay (addItem (Item "книга" 500) o1)
Ленивость Haskell делает эту картину ещё интереснее: o { status = Paid } не копирует поля немедленно, а строит thunk. Подробности — в https://courses.digitable.life/post/functional-programming/11-laziness-and-streams/.
Мейнстрим: records, with, copy
Идея дошла до всех крупных языков, и синтаксис везде похож:
// C# 9+
public record Order(int Id, string Status, ImmutableArray<Item> Items);
var o1 = new Order(1, "draft", ImmutableArray<Item>.Empty);
var o2 = o1 with { Status = "paid" }; // нереразрушающая мутация (non-destructive mutation)
// record даёт равенство по значению и корректный GetHashCode бесплатно
// Kotlin
data class Order(val id: Int, val status: String, val items: List<Item> = emptyList())
val o1 = Order(1, "draft")
val o2 = o1.copy(status = "paid")
// внимание: kotlin.collections.List — интерфейс только для чтения,
// но не гарантия неизменяемости: за ним может стоять ArrayList,
// который кто-то держит и мутирует. Для гарантии — kotlinx.collections.immutable
Java начиная с 16 имеет record, а List.of(...) возвращает по-настоящему неизменяемый список. Подробный обзор — в https://courses.digitable.life/post/functional-programming/15-fp-in-mainstream-languages/.
Наивная цена: почему копирование убивает
Тут начинается честный разговор. Если «обновление» — это копия, то цикл из N обновлений — это O(N²) работы:
# ПЛОХО: каждый шаг копирует весь кортеж — O(n) на шаг, O(n^2) суммарно
items = ()
for i in range(100_000):
items = items + (i,) # ~5 миллиардов операций копирования элементов
// та же ловушка в JS — очень частая в reduce
const result = data.reduce((acc, x) => [...acc, transform(x)], [] as T[]);
// O(n^2) по времени И по мусору: n промежуточных массивов
Для 100 элементов это неважно. Для 100 000 — приложение умирает. Три способа выжить:
- Использовать структуры со структурным разделением (следующий раздел) — правильный ответ по умолчанию.
- Локальная мутабельность внутри чистой функции. Если мутабельный буфер не выходит наружу, функция снаружи остаётся чистой. Это законный приём, а не читерство:
def transform_all(data: tuple[int, ...]) -> tuple[int, ...]:
buf = [] # мутабельный, но локальный: наружу не утечёт
for x in data:
buf.append(x * 2)
return tuple(buf) # O(n), а не O(n^2); функция снаружи чиста
В Haskell это оформлено типами: монада ST даёт настоящие мутабельные массивы, но runST гарантирует, что мутации не покидают вычисление, и результат остаётся чистым значением. См. https://courses.digitable.life/post/functional-programming/13-effects-and-io/.
- Накопление в обратном порядке. В Elixir идиома «добавляй в голову, разверни в конце» — O(n) вместо O(n²):
# ПЛОХО: ++ копирует левый список целиком, O(n^2)
Enum.reduce(list, [], fn x, acc -> acc ++ [f.(x)] end)
# ХОРОШО: cons в голову O(1), один reverse в конце O(n)
list |> Enum.reduce([], fn x, acc -> [f.(x) | acc] end) |> Enum.reverse()
# ЕЩЁ ЛУЧШЕ: за вас это уже сделали
Enum.map(list, &f/1)
Структурное разделение: как это работает
Ключевая идея, которая делает неизменяемость практичной: если старые данные нельзя изменить, их можно безопасно переиспользовать. Новая версия не копирует всю структуру — она копирует только путь от корня до изменённого места и берёт ссылки на всё остальное.
Самый простой случай — односвязный список. cons (добавление в голову) стоит O(1) и не копирует ничего: новая ячейка просто указывает на старый список. Поэтому в ФП списки растут с головы, а не с хвоста.
Для словарей и векторов используются широкие деревья: HAMT (Hash Array Mapped Trie) и RRB-деревья с ветвлением 32. Глубина дерева на миллион элементов — всего 4 уровня (32⁴ ≈ 1 048 576), поэтому обновление копирует 4 узла по 32 ссылки. Формально это O(log₃₂ n), практически — «почти константа с большим множителем».
неизменяемой коллекции"] --> B{"Размер коллекции"} B -->|"до ~100 элементов"| C["Обычная копия
spread / tuple / List.of
Просто и быстро"] B -->|"тысячи и больше"| D{"Много ли обновлений
подряд?"} D -->|"Одно-два"| E["Персистентная структура
HAMT / RRB / Immutable.js"] D -->|"Пакет изменений"| F["Transient / mutable builder
внутри одной операции"] F --> G["Заморозить результат
и вернуть наружу"] C --> H["Наружу — неизменяемое значение"] E --> H G --> H
Отдельно стоит transient — приём из Clojure и Immutable.js: внутри одной операции коллекция временно переключается в мутабельный режим (потому что известно, что ссылок на неё больше ни у кого нет), а на выходе «застывает». Это даёт скорость мутабельного билдера при неизменяемом интерфейсе:
import { List } from "immutable";
// без withMutations: 10 000 промежуточных версий
const slow = data.reduce((l, x) => l.push(x), List<number>());
// с withMutations: одна версия, внутри — мутабельный проход
const fast = List<number>().withMutations((l) => {
for (const x of data) l.push(x);
});
Глубокий разбор внутреннего устройства HAMT, RRB и реальных замеров — в https://courses.digitable.life/post/functional-programming/12-persistent-data-structures/.
Честная цена неизменяемости
Обещал разобрать — разбираю без рекламы.
Аллокации и давление на GC. Каждое обновление порождает мусор. На управляемых рантаймах (JVM, BEAM, .NET, V8) молодое поколение собирается дёшево, и короткоживущие копии почти бесплатны — но «почти» перестаёт работать при десятках миллионов обновлений в секунду. В Erlang/BEAM ситуация приятнее: у каждого процесса своя куча, GC локальный и не останавливает мир.
Локальность кеша. Вот главный молчаливый убийца. Плоский int[] лежит в памяти подряд и читается со скоростью префетчера. Персистентный вектор — это дерево из указателей: каждый уровень спуска потенциально промах кеша. На последовательном проходе разница легко достигает 5–20× не в пользу неизменяемых структур. Это не аргумент против ФП, это аргумент за то, чтобы числодробилки писать на массивах.
Порядки величин, о которых стоит помнить (грубые ориентиры, всегда меряйте у себя):
| Операция | Мутабельная структура | Персистентная структура |
|---|---|---|
| Чтение по индексу | O(1), 1 обращение | O(log₃₂ n), 3–5 обращений |
| Обновление элемента | O(1), без аллокаций | O(log₃₂ n), 3–5 узлов в мусор |
| Добавление в конец | O(1) амортизированно | O(log₃₂ n) амортизированно |
| Полный проход | максимальная локальность | в разы медленнее из-за промахов кеша |
| Копия структуры целиком | O(n) | O(1) — она уже «копия» |
| Сравнение версий | O(n) | O(1) при совпадении ссылок |
Обратите внимание на две последние строки: там неизменяемость выигрывает не в разы, а асимптотически. Именно на них строится быстрый shouldComponentUpdate/memo в React: если ссылка та же — данные точно те же, сравнивать нечего.
Где неизменяемость откровенно мешает:
- Числодробилки: линейная алгебра, обработка изображений, DSP, симуляции. Здесь нужен плотный массив и мутация на месте.
- Ввод-вывод и сетевые буферы: копировать мегабайты на каждый чанк — абсурд.
- Встраиваемые системы и жёсткое реальное время: непредсказуемые аллокации и GC-паузы недопустимы.
- Горячие циклы с миллионами итераций внутри одного вызова.
- Большие изменяемые графы с циклами: неизменяемый граф с обратными ссылками строится крайне неудобно (нужны либо идентификаторы вместо ссылок, либо ленивое связывание).
Кривая обучения и читаемость. Код с глубокими spread-обновлениями или тяжёлой оптикой честно сложнее для новичка, чем user.profile.city = "Казань". Отладка тоже другая: вместо «где это поле изменилось» ищешь «где создалась версия с этим полем». Команде нужна общая договорённость, иначе половина кода будет неизменяемой, а половина — нет, и вы получите худшее из двух миров.
Альтернативный ответ — Rust. Rust решает ту же проблему иначе: мутация разрешена, но система владения гарантирует, что в момент изменения на данные нет других живых ссылок (&mut T уникален). Гонки данных исключены, при этом сохранена скорость мутации на месте. Это важно понимать: неизменяемость — не единственный способ купить безопасность, а один из двух известных.
Типичные ошибки
- Считать
const/val/finalнеизменяемостью. Замораживается имя, не объект. - Полагаться на поверхностный
freeze/frozen. Вложенныйlistвнутри «неизменяемого» объекта разрушает все гарантии. - Отдать наружу ссылку на внутреннюю коллекцию. Геттер, возвращающий
self._items(а не копию или неизменяемую обёртку) — дыра в инкапсуляции. - Принять коллекцию в конструктор и сохранить по ссылке. Тот самый первый пример статьи. Либо копируйте на входе, либо принимайте заведомо неизменяемый тип.
items.push(x)вместо[...items, x]в reducer. Redux/React не увидит изменения, потому что ссылка та же. Симптом: «состояние в devtools правильное, а компонент не перерисовался».O(n²)-накопление через+,++, spread в цикле. Самая частая причина «ФП медленный».- Мутабельный объект как ключ словаря. Изменили поле — потеряли элемент.
- Глубокая заморозка в горячем пути в проде.
deepFreezeполезен в dev-сборке как страховка, но в проде это лишний обход всей структуры. Хорошая практика: включать по флагу окружения. - Думать, что неизменяемость сама даёт корректную конкурентность. Она убирает гонки данных, но не убирает гонки логики: два процесса могут одновременно прочитать одну версию и построить две расходящиеся новые. Нужен арбитр — атомарная ссылка, STM, актор или база с оптимистичной блокировкой.
Как это применяют в проде
- Value objects в DDD.
Money,Email,DateRange— неизменяемые по определению, с равенством по значению. См. трек https://courses.digitable.life/post/ddd/00-overview/. - Состояние UI. Redux, Zustand, Elm, SwiftUI: всё состояние — одно неизменяемое дерево, обновления — чистые функции. Отсюда time-travel и предсказуемая перерисовка.
- События и сообщения. Событие в event sourcing или сообщение в Kafka — исторический факт, менять его бессмысленно. Kafka-лог физически append-only.
- Конфигурация. Загруженный конфиг — неизменяемое значение; «перезагрузка конфига» — атомарная подмена ссылки, а не правка полей на живую.
- Базы данных. MVCC в PostgreSQL — та же идея:
UPDATEсоздаёт новую версию строки, старая живёт, пока её видят транзакции. Datomic доводит это до логического конца — база как неизменяемый набор фактов во времени. - Инфраструктура. Immutable infrastructure: контейнер не патчится, а пересобирается; выкатка — подмена образа. Ровно тот же принцип на другом уровне.
- Git. Коммит неизменяем, ветка — просто указатель, дерево переиспользует неизменившиеся blob’ы. Git — это персистентная структура данных, которой вы пользуетесь каждый день.
Практический рецепт для команды, которая не пишет на Haskell: неизменяемость по умолчанию, мутация — осознанное локальное исключение. Все DTO, события, конфиги и доменные значения — неизменяемые. Мутабельные буферы допустимы внутри функции, если не выходят наружу. Всё, что пересекает границу модуля или потока, — неизменяемо. Это архитектурный паттерн «функциональное ядро, императивная оболочка», который разбирается в https://courses.digitable.life/post/functional-programming/16-architecture-and-practice/.
Мини-итог
- Неизменяемость лечит алиасинг — источник багов «данные изменились сами».
constзамораживает имя, а не объект;freezeиfrozenповерхностны.- Обновление — это порождение новой версии:
%{s | f: v}, spread,replace,with,copy, record update. - Наивное копирование в цикле даёт O(n²) — используйте структурное разделение, transient-режим или локальный мутабельный буфер.
- Path copying делает обновление O(log₃₂ n), а копию структуры и сравнение версий — O(1).
- Цена реальна: мусор, промахи кеша, кривая обучения. Числодробилкам и буферам нужна мутация, и это нормально.
- Неизменяемость убирает гонки данных, но не гонки логики — арбитр всё равно нужен.
Источники
- Chris Okasaki, «Purely Functional Data Structures» — каноническая книга про персистентные структуры.
- Joshua Bloch, «Effective Java», Item 17 «Minimize mutability», Item 50 «Make defensive copies when needed».
- Rich Hickey, доклад «Are We There Yet?» — разделение identity/state/value: https://github.com/matthiasn/talk-transcripts/blob/master/Hickey_Rich/AreWeThereYet.md
- Phil Bagwell, «Ideal Hash Trees» (2001) — основа HAMT: https://lampwww.epfl.ch/papers/idealhashtrees.pdf
- RRB-Trees, Bagwell & Rompf (2011): https://infoscience.epfl.ch/record/169879/files/RMTrees.pdf
- Документация Immer: https://immerjs.github.io/immer/
- Immutable.js: https://immutable-js.com/
- pyrsistent: https://github.com/tobgu/pyrsistent
- Elixir,
Kernel.put_in/3и структуры: https://hexdocs.pm/elixir/Kernel.html#put_in/3 - MDN,
Object.freeze: https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze - PostgreSQL, глава про MVCC: https://www.postgresql.org/docs/current/mvcc.html
- Обзорная статья о ФП в треке парадигм: https://courses.digitable.life/post/paradigms/03-functional/
Что дальше
Мы научились работать с данными, ничего не меняя. Следующий шаг — научиться работать с самим кодом как с данными: передавать функции в аргументах, возвращать их из функций и захватывать окружение в замыканиях.