Функциональное программирование Функции первого класса и высшего порядка, замыкания
0%

Функции первого класса и высшего порядка, замыкания

Функции первого класса и высшего порядка, замыкания

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

Звучит как техническая мелочь. На практике это тот самый рычаг, из которого вырастают 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 такой гарантии не даёт.

Каждая стрелка — независимо тестируемый шаг. Каждый узел — значение, которое можно распечатать в отладке. Сравните с одним 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) []

Грэм Хаттон посвятил этому классическую работу «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. То, что этот код работает, — и есть замыкание.

Замыкание: что реально хранится в памяти

Замыкание — это пара «код функции + захваченное окружение». Функция «замыкает» свободные переменные (те, что не являются её параметрами и не объявлены внутри) на окружение, в котором была создана, и уносит его с собой.

Анатомия замыкания: код, окружение и две функции, делящие одну ячейку

Ключевые следствия, которые чаще всего понимают неправильно:

  1. Захватывается переменная, а не её значение (в языках с изменяемыми биндингами: JS, Python, C#, Java-через-обёртки). Если после создания замыкания переменная изменится — замыкание увидит новое значение.
  2. Несколько замыканий из одного скоупа делят одну ячейку. В примере на схеме inc и get работают с одним n — это делает их согласованными и одновременно опасными в многопоточке.
  3. Захваченное окружение живёт, пока живо замыкание. Кадр стека уничтожается, ячейка переезжает в кучу. Отсюда все утечки памяти на замыканиях.

В языках с неизменяемыми биндингами (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 универсален: остальные — его частные случаи.
  • Замыкание = код + захваченное окружение. Захватывается переменная, не значение (кроме языков с неизменяемыми биндингами). Окружение живёт, пока жива функция, — отсюда и приватное состояние, и утечки.
  • Замыкание и объект изоморфны. Один метод — берите замыкание; несколько связанных — берите объект.
  • Цена реальна: аллокации, отказ от инлайнинга, лишние проходы по коллекциям, безымянные стек-трейсы, кривая обучения. Оптимизируйте по профайлеру, а не по предчувствию.

Что дальше

Мы научились передавать поведение и хранить состояние в замыканиях. Но остался вопрос, который в ФП решается принципиально иначе, чем в императивном коде: как вообще повторять действия, если нет изменяемого счётчика цикла? Ответ — рекурсия, и вместе с ней приходит проблема переполнения стека и её решения.

Рекурсия, хвостовые вызовы и как не переполнить стек

Источники и что почитать:

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов