Функции первого класса и высшего порядка, замыкания
В предыдущих статьях трека мы разобрали чистые функции и неизменяемость — два свойства, которые делают код предсказуемым. Но предсказуемость сама по себе не даёт выразительности. Выразительность приходит от другой идеи: функция — это такое же значение, как число или строка. Её можно положить в переменную, передать аргументом, вернуть из другой функции, засунуть в список или словарь.
Звучит как техническая мелочь. На практике это тот самый рычаг, из которого вырастают map/filter/reduce, декораторы, middleware, инъекция зависимостей без DI-контейнера, стратегии без иерархий классов и почти весь остальной курс. Обзорный взгляд на парадигму есть в статье Функциональное программирование — здесь мы копаем вглубь.
Боль первая: два цикла, отличающиеся одной строкой
Начнём не с определения, а с кода, который вы писали сто раз.
# Нужно: суммы заказов и email-адреса активных пользователей
def total_amounts(orders):
result = []
for o in orders:
result.append(o.amount * (1 + o.vat)) # ← вся разница здесь
return result
def user_emails(users):
result = []
for u in users:
result.append(u.email.lower()) # ← и здесь
return result
Две функции. Идентичны на 90%: завести аккумулятор, пройти коллекцию, добавить, вернуть. Отличается одно выражение. Классические средства абстракции тут бессильны: вынести в общую функцию нельзя, потому что переменной части — не данные, а кусок поведения.
Обычный ответ ООП — паттерн «Стратегия»: интерфейс с одним методом, два класса-реализации, передача экземпляра в цикл. Работает, но цена — три новых сущности ради одной строки. Функциональный ответ: если функцию можно передать как значение, стратегия — это просто функция.
def map_list(f, xs):
result = []
for x in xs:
result.append(f(x)) # переменная часть пришла аргументом
return result
total_amounts = lambda orders: map_list(lambda o: o.amount * (1 + o.vat), orders)
user_emails = lambda users: map_list(lambda u: u.email.lower(), users)
Мы только что вывели map. Не выучили — вывели, из потребности параметризовать поведение, а не данные.
Функция первого класса (first-class function) — функция, которую язык считает полноценным значением: её можно связать с именем, передать, вернуть, хранить в структуре данных. Функция высшего порядка (higher-order function, HOF) — функция, которая принимает функцию аргументом и/или возвращает функцию. Первое — свойство языка, второе — приём, который это свойство открывает.
Термин восходит к лямбда-исчислению Чёрча (1936), где вообще всё — функция, включая числа. Отдельный трек портала посвящён лямбда-исчислению целиком; здесь нам хватит интуиции: функция — значение.
Каноническая тройка: map, filter, reduce
Три цикла покрывают большую часть повседневной работы с коллекциями. Дадим типы — они объясняют суть точнее прозы.
map :: (a -> b) -> [a] -> [b] -- преобразовать каждый
filter :: (a -> Bool) -> [a] -> [a] -- оставить подходящие
foldl :: (b -> a -> b) -> b -> [a] -> b -- свернуть в одно значение
Читается так: map берёт функцию из a в b и список a, возвращает список b той же длины. filter берёт предикат, длину может уменьшить, но тип элементов сохраняет. foldl (он же reduce) берёт комбинирующую функцию, начальное значение и список — и схлопывает всё в одно значение произвольного типа.
Сигнатура — это контракт, который читается за секунду. Увидев (a -> b) -> [a] -> [b], вы уже знаете: элементы не потеряются, не переставятся, не продублируются. Ни один for такой гарантии не даёт.
только оплаченные"] B -->|"map(o => o.amount)"| C["List<Money>
суммы"] C -->|"reduce(+, 0)"| D["Money
итог"] style A stroke:#3b82f6 style B stroke:#3b82f6 style C stroke:#10b981 style D stroke:#f59e0b
Каждая стрелка — независимо тестируемый шаг. Каждый узел — значение, которое можно распечатать в отладке. Сравните с одним for, внутри которого перемешаны фильтрация, преобразование и накопление: чтобы понять его, нужно держать в голове состояние трёх переменных на каждой итерации.
Один и тот же конвейер на четырёх языках:
# Elixir: оператор |> делает конвейер буквальным
total =
orders
|> Enum.filter(& &1.paid) # & — краткая запись анонимной функции
|> Enum.map(& &1.amount)
|> Enum.sum()
// TypeScript: методы массива, всё строго типизировано
const total: number = orders
.filter(o => o.paid) // Order[] -> Order[]
.map(o => o.amount) // Order[] -> number[]
.reduce((a, b) => a + b, 0);
# Python: генераторное выражение идиоматичнее, чем map/filter
total = sum(o.amount for o in orders if o.paid)
# Явный вариант — когда преобразование уже есть как именованная функция
from functools import reduce
total = reduce(operator.add, map(attrgetter("amount"), filter(is_paid, orders)), 0)
-- Haskell: композиция функций, точка = «сначала правая, потом левая»
total :: [Order] -> Money
total = sum . map amount . filter paid
Обратите внимание на Python: идиоматичный ответ здесь — не map/filter, а генераторное выражение. Гвидо ван Россум неоднократно писал, что comprehension читается лучше (история удаления reduce из builtins). ФП-идея (декларативное описание преобразования) остаётся, синтаксис меняется. Идиоматичность важнее чистоты стиля — это правило мы будем повторять весь курс.
Почему reduce — главный из трёх
map и filter — частные случаи fold. Это не красивая метафора, а буквальный факт:
map f = foldr (\x acc -> f x : acc) []
filter p = foldr (\x acc -> if p x then x : acc else acc) []
универсальный комбинатор"] --> M["map"] F --> FL["filter"] F --> S["sum, product, length"] F --> G["groupBy"] F --> R["reverse"] F --> A["any, all"] F --> J["join / concat"] style F stroke:#f59e0b,stroke-width:2px
Грэм Хаттон посвятил этому классическую работу «A tutorial on the universality and expressiveness of fold» — там же доказательства, что любая функция вида «обойти список и что-то накопить» выразима через foldr.
Практический вывод: если у вас в коде остался цикл, который map/filter не покрывают, почти всегда это reduce. Пример — группировка, которую многие до сих пор пишут вручную:
// Группировка заказов по клиенту через reduce
const byCustomer = orders.reduce<Record<string, Order[]>>((acc, o) => {
(acc[o.customerId] ??= []).push(o);
return acc;
}, {});
// С 2024 в браузерах и Node 21+ есть встроенный вариант — используйте его
const byCustomer2 = Object.groupBy(orders, o => o.customerId);
# Elixir: reduce с аккумулятором-мапой; Map.update/4 избавляет от if
Enum.reduce(orders, %{}, fn o, acc ->
Map.update(acc, o.customer_id, [o], &[o | &1])
end)
# Но в стандартной библиотеке уже есть готовое — не изобретайте:
Enum.group_by(orders, & &1.customer_id)
Отдельно про foldl и foldr — разница не косметическая. foldl сворачивает слева, хвостово-рекурсивен, работает за O(1) дополнительной памяти в языках с оптимизацией хвостовых вызовов, но на бесконечном списке не завершится. foldr сворачивает справа, требует O(n) стека в строгих языках, зато в ленивом Haskell умеет работать с бесконечными списками, если комбинирующая функция ленива по второму аргументу. Подробно — в статьях о рекурсии и ленивости.
Сложность конвейера: filter → map → reduce — три прохода, O(3n) по времени и O(n) по дополнительной памяти на промежуточные коллекции против O(n)/O(1) у одного ручного цикла. Для списка на 100 элементов это неважно, для 10 миллионов — важно. Решение не «вернуться к циклам», а ленивые последовательности (Stream в Elixir, генераторы в Python, Seq в F#, Iterator в Rust), которые сливают проходы в один. К этому вернёмся в статье о ленивости.
Боль вторая: функции нужно параметризовать заранее
Второй сюжет. Вы пишете валидатор и хотите проверку «длина не больше N». Написать maxLength20, maxLength50, maxLength255? Передавать N в каждый вызов, засоряя сигнатуру? Ни то ни другое.
Нужна функция, которая производит функцию:
// Фабрика предикатов: возвращаем функцию, помнящую n
const maxLength = (n: number) => (s: string): boolean => s.length <= n;
const validators = [maxLength(20), maxLength(255)]; // две разные функции
validators[0]("привет"); // true
Внутренняя стрелочная функция обращается к n, которого в её собственных параметрах нет. n принадлежит уже завершившемуся вызову maxLength. То, что этот код работает, — и есть замыкание.
Замыкание: что реально хранится в памяти
Замыкание — это пара «код функции + захваченное окружение». Функция «замыкает» свободные переменные (те, что не являются её параметрами и не объявлены внутри) на окружение, в котором была создана, и уносит его с собой.
Ключевые следствия, которые чаще всего понимают неправильно:
- Захватывается переменная, а не её значение (в языках с изменяемыми биндингами: JS, Python, C#, Java-через-обёртки). Если после создания замыкания переменная изменится — замыкание увидит новое значение.
- Несколько замыканий из одного скоупа делят одну ячейку. В примере на схеме
incиgetработают с однимn— это делает их согласованными и одновременно опасными в многопоточке. - Захваченное окружение живёт, пока живо замыкание. Кадр стека уничтожается, ячейка переезжает в кучу. Отсюда все утечки памяти на замыканиях.
В языках с неизменяемыми биндингами (Elixir, Erlang, Haskell) пункт 1 отпадает: захватывается значение, потому что переменная и не может измениться. Это одна из причин, по которым ФП и замыкания так хорошо уживаются.
n = 10
f = fn -> n end # захвачено значение 10
n = 20 # это НЕ мутация: создан новый биндинг
f.() # => 10, а не 20
n = 10
f = lambda: n # захвачена ячейка переменной n
n = 20
f() # => 20
Классический баг: замыкание в цикле
Самая известная ловушка на планете. Python:
funcs = [lambda: i for i in range(3)]
[f() for f in funcs] # [2, 2, 2] — все замкнулись на ОДНУ переменную i
# Лечение: связать значение через параметр по умолчанию (вычисляется при создании)
funcs = [lambda i=i: i for i in range(3)]
[f() for f in funcs] # [0, 1, 2]
# Или через functools.partial — честнее и читаемее
from functools import partial
funcs = [partial(lambda i: i, i) for i in range(3)]
JavaScript болел тем же с var:
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 3, 3, 3
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 0, 1, 2
let в ES2015 создаёт новую ячейку на каждой итерации — язык исправил семантику, а не научил программистов. Go прошёл тот же путь: до версии 1.22 переменная цикла была одна на весь цикл, и for _, v := range xs { go func(){ use(v) }() } был источником гонок; в Go 1.22 семантику изменили на «новая переменная на итерацию». C# сделал это для foreach в версии 5.0.
Мораль: если ваш язык или его версия старая — проверяйте, а не надейтесь. И держите правило: захватывать в цикле стоит только то, что вы явно связали внутри тела итерации.
Замыкание против объекта
Знаменитый обмен репликами на Lambda the Ultimate: «объекты — это замыкания для бедных» / «замыкания — это объекты для бедных» (оригинал). Формально они эквивалентны: объект — это запись функций, разделяющих состояние; замыкание — функция, владеющая приватным состоянием. В SICP этот изоморфизм разбирается ещё в главе 3 (полный текст книги).
Практическое правило выбора: один метод — замыкание, несколько связанных методов — объект/модуль. Класс с единственным методом execute() почти всегда должен быть функцией. Обратное тоже верно: если вы вернули словарь из пяти замыканий и передаёте его повсюду — вы написали объект неудобным синтаксисом.
Где HOF окупаются в проде
Декораторы и обёртки поведения
Ретраи, кеш, логирование, метрики, таймауты — всё это ортогонально бизнес-логике. HOF позволяет добавлять их снаружи, не трогая саму функцию.
type Fn<A extends unknown[], R> = (...args: A) => Promise<R>;
// Обёртка: возвращает функцию с той же сигнатурой, но с ретраями
function withRetry<A extends unknown[], R>(
fn: Fn<A, R>,
attempts = 3,
baseDelayMs = 100,
): Fn<A, R> {
return async (...args: A): Promise<R> => {
let lastError: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await fn(...args);
} catch (e) {
lastError = e;
// экспоненциальная задержка: 100, 200, 400 мс
await new Promise(r => setTimeout(r, baseDelayMs * 2 ** i));
}
}
throw lastError;
};
}
// Обёртка: мемоизация по ключу; кеш живёт в замыкании, снаружи недоступен
function withCache<A extends unknown[], R>(
fn: Fn<A, R>,
keyOf: (...args: A) => string,
): Fn<A, R> {
const cache = new Map<string, Promise<R>>();
return (...args: A): Promise<R> => {
const key = keyOf(...args);
let hit = cache.get(key);
if (!hit) {
hit = fn(...args);
cache.set(key, hit);
// не кешируем неудачу: иначе один сбой отравит кеш навсегда
hit.catch(() => cache.delete(key));
}
return hit;
};
}
const fetchUser = withCache(withRetry(rawFetchUser), id => `user:${id}`);
Порядок обёрток — не деталь вкуса, а решение с последствиями:
withCache(withRetry(f)) кеширует итог всех ретраев — обычно то, что нужно. withRetry(withCache(f)) будет ретраить попадания в кеш — бессмысленно и вредно. Читайте композицию обёрток снаружи внутрь.
В Python тот же приём — синтаксис декораторов, и половина инструментов уже в stdlib: functools.lru_cache, functools.cache, contextlib.contextmanager.
from functools import lru_cache, wraps
import time, logging
def timed(fn): # HOF: функция -> функция
@wraps(fn) # сохраняет __name__, __doc__, сигнатуру
def inner(*args, **kwargs):
t0 = time.perf_counter()
try:
return fn(*args, **kwargs)
finally:
logging.info("%s: %.1f мс", fn.__name__, (time.perf_counter() - t0) * 1e3)
return inner
@timed
@lru_cache(maxsize=1024) # применяется первым: ближе к функции
def heavy(n: int) -> int:
...
@wraps обязателен: без него функция теряет имя и докстроку, ломаются логи, inspect, и половина фреймворков, которые читают метаданные. Это цена, которую платят за обёртки.
Инъекция зависимостей без контейнера
Тестируемость обычно достигают интерфейсами и моками. С функциями первого класса зависимость — просто параметр.
defmodule Billing do
# send_email передаётся аргументом с дефолтом; в тестах подменяется функцией
def charge(order, send_email \\ &Mailer.send/1) do
with {:ok, receipt} <- Payments.capture(order) do
send_email.({:receipt, order.email, receipt})
{:ok, receipt}
end
end
end
# В тесте: никаких моков, просто другая функция
test "письмо уходит" do
me = self()
Billing.charge(order, fn msg -> send(me, msg) end)
assert_receive {:receipt, _, _}
end
Это тот же паттерн «Стратегия», но без интерфейса, класса-реализации и фабрики. Мартин Фаулер обсуждает границу применимости в заметке о функциональной DI — короткая версия: одна-две зависимости прекрасно передаются функциями, десять — уже просят структуру (модуль, запись, окружение).
Middleware и пайплайны
Веб-фреймворки почти поголовно построены на HOF: middleware — это функция handler -> handler.
type Handler = (req: Request) => Promise<Response>;
type Middleware = (next: Handler) => Handler;
const withAuth: Middleware = next => async req =>
req.headers.get("authorization")
? next(req)
: new Response("unauthorized", { status: 401 });
const withLogging: Middleware = next => async req => {
const started = Date.now();
const res = await next(req);
console.log(req.url, res.status, Date.now() - started);
return res;
};
// Композиция middleware — это reduceRight по списку обёрток
const compose = (ms: Middleware[]): Middleware =>
next => ms.reduceRight((acc, m) => m(acc), next);
const app = compose([withLogging, withAuth])(businessHandler);
Plug в Elixir, Express/Koa в Node, ASP.NET Core pipeline, http.Handler в Go — всё это одна и та же идея. Разница только в синтаксисе и в том, называет ли сообщество это «функциями высшего порядка».
Обратные вызовы, сортировки, компараторы
Самое незаметное применение: sort(cmp), on_click(handler), Promise.then(f), Enum.reduce_while/3, EventEmitter.on. Как только вы это заметили — становится видно, что вся асинхронность в JS исторически построена на функциях первого класса, а async/await лишь скрывает их за синтаксисом.
Каррирование и частичное применение — коротко
maxLength(20) из примера выше — частичное применение вручную. Есть систематический подход: каррирование превращает функцию от N аргументов в цепочку функций от одного. В Haskell все функции каррированы по умолчанию, поэтому map (* 2) работает без церемоний.
add :: Int -> Int -> Int -- на самом деле Int -> (Int -> Int)
add x y = x + y
add5 = add 5 -- частичное применение бесплатно
const add = (x: number) => (y: number) => x + y;
const add5 = add(5);
Полный разбор — в статье Композиция, каррирование, частичное применение. Здесь важно только запомнить связь: каррирование существует потому, что функции — значения; замыкание — механизм, который делает частичное применение возможным.
Цена: где HOF и замыкания стоят дорого
Курс обещал честность, поэтому раздел обязательный.
Аллокации. Каждое созданное замыкание с непустым окружением — объект в куче. В горячем цикле xs.map(x => x * k) создаёт замыкание один раз (хорошо), но for (…) { arr.push(() => x) } создаёт n объектов. В Go компилятор делает escape-анализ и оставляет замыкание на стеке, если оно не «убегает» (FAQ по стеку и куче); проверяется через go build -gcflags=-m. В Java лямбда без захвата переменных инстанцируется один раз и переиспользуется, с захватом — создаётся на каждый вызов; детали в документе Брайана Гётца о трансляции лямбд.
Косвенный вызов и инлайнинг. Прямой вызов JIT инлайнит охотно. Вызов через переменную-функцию инлайнится, только если движок доказал, что там всегда одна и та же функция (мономорфный call site). Как только через одну HOF проходят четыре разные лямбды, сайт становится мегаморфным, инлайнинг отключается, и цикл замедляется в разы. Именно поэтому «горячие» участки в V8 и HotSpot иногда переписывают обратно в развёрнутые циклы — но только после профилирования, не превентивно.
Промежуточные коллекции. filter().map().reduce() в строгих языках — три прохода и два мусорных массива. На больших данных переходите на ленивые обёртки (Stream в Elixir, генераторы в Python, Iterator в Rust — там это ещё и zero-cost) либо принимайте один явный цикл в конкретной горячей точке.
Стек-траслы и отладка. Стек из десяти anonymous и inner вместо осмысленных имён — реальная боль на дежурстве. Лечится дисциплиной: именуйте функции (const parseRow = (r) => … даёт имя в стеке), используйте @wraps в Python и не вкладывайте лямбды глубже двух уровней.
Утечки памяти. Захваченное окружение живёт, пока жива функция. Обработчик события, замкнувший на себя весь request или узел DOM, удержит их навсегда. Типичный сценарий: подписка на глобальную шину внутри компонента, которую забыли отписать, — утекает не только замыкание, но и всё, что оно видит.
Кривая обучения. reduce, вложенные возвращаемые функции и point-free стиль читаются легко тем, кто их пишет, и тяжело всем остальным. Кодовая база, где каждый for заменён на flatMap с каррированными хелперами, — не победа ФП, а проблема онбординга. Здоровый ориентир: если выражение требует комментария длиннее себя, разверните его обратно.
Где ФП мешает. Плотные численные циклы, парсеры с ручной оптимизацией, embedded с фиксированной памятью, код с жёстким бюджетом GC-пауз — там косвенные вызовы и аллокации замыканий недопустимы. Ответ — не отказ от ФП в проекте, а изоляция: функциональное ядро и императивные «горячие точки», см. Архитектура на ФП.
Типичные ошибки
- Захват изменяемой переменной цикла — разобрано выше; проверяйте версию языка.
- Мутация внутри
map.items.map(x => { x.seen = true; return x })— этоforEachс побочным эффектом, замаскированный под преобразование. Читатель ждёт отmapчистоты. См. чистые функции. reduceтам, где нужен цикл. Свёртка, которая мутирует аккумулятор, накапливает три разных значения в кортеже и содержитifна пять веток, — читается хуже честногоfor.reduceхорош, когда комбинирующая функция коротка и ассоциативна.- Замыкание вместо параметра. Функция, тайно читающая переменную из внешнего скоупа вместо явного аргумента, — скрытая зависимость. Особенно больно, когда эта переменная — конфиг, меняющийся в рантайме.
- Кеш в замыкании без границ.
withCacheиз примера выше растёт бесконечно. В проде нужен LRU/TTL и метрика hit rate; иначе через неделю аптайма получите OOM. - Замыкание с общим состоянием в конкурентном коде. Два потока, инкрементирующие захваченный счётчик, — гонка. В Elixir/Erlang проблема не возникает по построению (значения неизменяемы), в JS — из-за однопоточности, в Java/Go/C# — возникает и кусается. Подробнее в статье о конкурентности.
Как это выглядит в разных языках: короткая сводка
| Язык | Синтаксис лямбды | Захват | Особенность |
|---|---|---|---|
| Haskell | \x -> x + 1 |
по значению (всё неизменяемо) | функции каррированы по умолчанию |
| Elixir | fn x -> x + 1 end, &(&1 + 1) |
по значению | вызов замыкания через точку: f.(1) |
| TypeScript | (x) => x + 1 |
по ссылке на биндинг | let/const — новая ячейка на итерацию |
| Python | lambda x: x + 1 |
по ссылке (late binding) | многострочные лямбды невозможны — используйте def |
| Go | func(x int) int { … } |
по ссылке, escape-анализ | переменная цикла пофикшена в 1.22 |
| Java | x -> x + 1 |
только effectively final | лямбда без захвата кешируется JVM |
| C# | x => x + 1 |
по ссылке | Func<>/Action<>, статические лямбды с static |
Практический разбор ФП-инструментов в неспециализированных языках — в статье ФП в обычных языках.
Мини-итог
- Функция первого класса — функция как значение: передать, вернуть, сохранить. Это свойство языка.
- Функция высшего порядка — принимает и/или возвращает функцию. Это приём, который убирает дублирование, когда переменной частью является поведение, а не данные.
map/filter/reduce— не «функциональные красивости», а именованные формы циклов, каждая со своим контрактом, читаемым прямо из сигнатуры.reduceуниверсален: остальные — его частные случаи.- Замыкание = код + захваченное окружение. Захватывается переменная, не значение (кроме языков с неизменяемыми биндингами). Окружение живёт, пока жива функция, — отсюда и приватное состояние, и утечки.
- Замыкание и объект изоморфны. Один метод — берите замыкание; несколько связанных — берите объект.
- Цена реальна: аллокации, отказ от инлайнинга, лишние проходы по коллекциям, безымянные стек-трейсы, кривая обучения. Оптимизируйте по профайлеру, а не по предчувствию.
Что дальше
Мы научились передавать поведение и хранить состояние в замыканиях. Но остался вопрос, который в ФП решается принципиально иначе, чем в императивном коде: как вообще повторять действия, если нет изменяемого счётчика цикла? Ответ — рекурсия, и вместе с ней приходит проблема переполнения стека и её решения.
Рекурсия, хвостовые вызовы и как не переполнить стек
Источники и что почитать:
- Harold Abelson, Gerald Jay Sussman. Structure and Interpretation of Computer Programs, гл. 1.3 и 3.2 — полный текст
- Graham Hutton. A tutorial on the universality and expressiveness of fold — PDF
- MDN. Closures — developer.mozilla.org
- Brian Goetz. Translation of Lambda Expressions — OpenJDK
- Go Blog. Fixing for loops in Go 1.22 — go.dev/blog/loopvar-preview
- Python docs. functools — docs.python.org
- Elixir docs. Enum и Stream — hexdocs.pm/elixir/Enum.html