Замыкания
Предпосылки: Функции (параметры, область видимости, стек вызовов), Память (стек, куча, время жизни), ООП (объект как группа данных с методами, нотация obj.field), Функциональное программирование (блок как аргумент, select/map/reduce).
← Функциональное программирование | Ошибки и исключения →
В расчёте комиссий через map — том же, что использовался в функциональном программировании, — блок обращался к переменной tax_rate, которая не была его параметром:
tax_rate = 0.15
commissions = order_totals.map do |total|
total * tax_rate
endtax_rate объявлена снаружи блока. Внутри блока её никто не передаёт, но она там работает. Почему способ передавать блоку данные из окружающего кода настолько полезен, что вокруг него выстроена большая часть современных коллекций, колбэков и обработчиков — видно на задаче, где без этой способности не получится ничего.
Задача, с которой начинается потребность
В маркетплейсе у финансового менеджера есть дневной список выплат:
payouts = [5000, 12000, 3000, 45000, 8000, 28000]По внутреннему правилу крупные выплаты одобряются вручную. Порог не зашит в программу: менеджер выбирает его сам — сегодня смотрит всё от 10 000, завтра от 25 000. Программа спрашивает порог и отбирает подходящие суммы:
puts "Минимальная сумма для ручного одобрения:"
limit = gets.chomp.to_i
# дальше нужно отфильтровать payouts так, чтобы остались суммы больше limitselect из функционального раздела — подходящий инструмент: он оставляет элементы, для которых переданное правило вернуло true. Остаётся записать это правило.
Попробуем обычной функцией:
def above_limit(amount)
amount > 10_000
end
large = payouts.select do |amount| above_limit(amount) endРаботает, но порог жёстко записан в коде. Переменная limit, введённая менеджером, в функцию не попадает: above_limit видит только свои параметры и глобальные имена, а limit — локальная переменная, которая лежит в scope вызывающего кода. Область видимости раньше защищала функции от случайных пересечений — здесь она же закрывает единственный путь от менеджерского ввода до правила фильтрации.
Напрашивается простое решение — добавить параметр:
def above_limit(amount, limit)
amount > limit
endВот теперь остановись и попробуй сам: как этим вызвать select? Где брать limit внутри payouts.select do |amount| ... end?
Почему параметр не спасает
selectсам зовёт переданное ему правило — и вызывает его ровно с одним аргументом: текущим элементом массива. Это его контракт: «дай мне функцию, которая для одного элемента говорит да или нет». Вторую переменнуюselectтуда не подставит. Оборачивание помогает чуть-чуть —payouts.select do |amount| above_limit(amount, limit) endсоблюдает контракт, — но не решает исходную проблему: значениеlimitвсё равно приходится брать из окружения блока. Параметр у функцииabove_limitисчез, но обращение к переменной из внешнего scope осталось. Вопрос, откуда блок берётlimit, не снят — он просто сместился на один уровень.
Контракт select — не прихоть. Именно благодаря ему select применим к любым массивам и любым условиям: он не знает, что в списке и по какому правилу мы фильтруем, — он просит только одно решающее правило для одного элемента. Чем шире круг задач, в которых пригодится такая функция, тем уже её контракт. А узкий контракт — это ровно та ситуация, где для работы нужно больше, чем контракт позволяет передать.
Оставался бы один выход — положить limit в глобальную переменную. Пока в программе один менеджер и один поток выполнения, это работает. Как только появляется второй менеджер, одновременно фильтрующий по своему порогу, две функции начинают мешать друг другу через общую глобальную переменную — и работать это перестаёт. Значит, нужен другой механизм.
Блок как функция с памятью
Решение выглядит почти неожиданно. Блок справляется сам:
limit = gets.chomp.to_i
large = payouts.select do |amount| amount > limit endКонтракт select соблюдён: блок принимает один аргумент amount, как положено. Внутри блока используется limit, которого нет в параметрах — но он откуда-то известен.
Ключ в том, что блок — это не просто кусок кода. Ruby, встречая do |amount| amount > limit end, создаёт объект, в котором хранится не только тело, но и ссылка на переменную limit из окружающего scope. Этот объект — код вместе с памятью о месте, где он был создан, — называется замыканием. Буквально: функция, «замкнутая» вокруг своего окружения.
Внешне замыкание ведёт себя как функция одного аргумента — и принимается туда, где ждут функцию одного аргумента. Внутри оно несёт с собой всё, что было видно в момент его создания. Узкий внешний контракт и широкий внутренний контекст, которые раньше не удавалось совместить, теперь живут в одном объекте.
Что именно запомнило замыкание
Замыкание запоминает не значение в момент создания, а саму переменную. Разница видна, если попросить замыкание не читать внешнюю переменную, а менять её. В примере ниже lambda { ... } создаёт анонимную функцию напрямую (без блока do..end), а counter.call её запускает:
def make_counter
count = 0
lambda { count = count + 1; count }
end
counter = make_counter
puts counter.call # 1
puts counter.call # 2
puts counter.call # 3Каждый вызов увеличивает count на единицу. Если бы лямбда копировала count в момент своего создания, при каждом вызове значение начиналось бы заново с нуля. А оно растёт — значит, внутри лямбды действительно живая связь с оригинальной переменной, а не снимок её значения.
Здесь ломается ещё одно ожидание. count — локальная переменная make_counter. По правилам из памяти она должна исчезнуть вместе с кадром стека, когда функция вернулась. А она живёт: её значение помнится между вызовами лямбды, уже давно после того, как make_counter завершилась.
Объяснение простое. Когда язык видит, что локальная переменная захвачена замыканием, он не может хранить её только в стеке — стек живёт по своим правилам, кадры исчезают при возврате из функции. Переменная переезжает в кучу, чтобы её время жизни было привязано к замыканию, а не к кадру вызова.
Обычная локальная переменная: Захваченная замыканием:
Стек: Стек:
[make_counter: count=0] [make_counter: ...]
v возврат v возврат
[ ] (кадр исчез, count исчез) [ ] (кадр исчез)
Куча:
[count=0] <- lambda ссылается сюда
и после возвратаRuby делает этот перенос невидимо — как именно, на уровне виртуальной машины, разбирается в Блоках, Proc и Lambda. JavaScript, Python, Java, C# — тоже прячут механику. В C++ и Rust выбор не скрыт: программист явно говорит, захватить переменную по значению (копия уедет внутрь объекта замыкания) или по ссылке (требуется, чтобы оригинал жил достаточно долго). Разница только в том, кто принимает решение.
Анонимная функция, лямбда и замыкание
Эти три названия часто путают — иногда и в книгах. Различие в одной фразе: это свойства на разных осях, и у одной и той же конструкции их может быть сразу несколько.
Анонимная (безымянная) функция — функция без имени, определяемая прямо в месте использования. В Ruby это блок или lambda { ... }, в других языках — x => x + 1 (JavaScript), lambda x: x + 1 (Python), [](int x) { return x + 1; } (C++). Свойство формы, не поведения.
Лямбда — чаще всего синоним анонимной функции. Название пришло из лямбда-исчисления, математической модели функций, разработанной Алонзо Чёрчем в 1930-х. В Ruby слово lambda закреплено за конкретной формой: объектом, который вызывают через .call. В большинстве других языков «лямбда» и «анонимная функция» — одно и то же.
Замыкание в этот ряд не встаёт. Оно не про форму записи, а про то, что функция умеет: у неё остаётся живая связь с переменными из окружающего scope. Поэтому одна и та же лямбда в одном контексте — замыкание, а в другом нет.
Отсюда следует, что не всякая анонимная функция — замыкание:
def double(x); x * 2; end— именованная функция, ничего не захватывает.lambda { |x| x * 2 }— анонимная функция, ничего не захватывает. Не замыкание в строгом смысле: замыкать нечего.lambda { |x| x > limit }— анонимная функция, захватываетlimit. Вот это замыкание.
В быту все анонимные функции часто называют замыканиями — потому что механика в языке одна и та же. Компилятор не различает «сейчас ты просто короткая функция, а вот сейчас замыкание»: он в обоих случаях создаёт объект, в котором может лежать список захваченных переменных. Если список пустой — замыкать нечего, но машинерия та же. Разбирать эти слова полезно в основном тогда, когда путаница мешает понять чужой код или спор.
Когда без замыкания действительно не обойтись
Большая часть анонимных функций на практике ничего не захватывает. nums.map { |n| n * 2 }, arr.filter(x => x > 0), list.sort { |a, b| a <=> b } — везде короткие безымянные функции, которые удобно записать на месте вместо отдельного def. Это лямбды, не замыкания. Если убрать из языка способность захватывать окружение, такой код продолжит работать.
Замыкание становится неизбежным там, где встречаются два условия:
Сигнатуру функции диктует принимающая сторона. select требует функцию одного аргумента, возвращающую логическое значение. Обработчик клика в UI — функцию без аргументов. sort — функцию, сравнивающую две вещи. Принимающая сторона задаёт узкий контракт не из вредности, а ради универсальности: чем меньше требований к передаваемой функции, тем шире круг задач, где её можно применить.
Функции для работы нужны данные, которых в этом контракте нет. Они лежат где-то в окружающем коде — локальная переменная вызывающей функции, параметр охватывающего метода, поле объекта, значение из ввода.
Две стороны несовместимы: сквозь узкий контракт данные не протащить, а отказаться от контракта нельзя — без него принимающая функция теряет смысл. Замыкание разрешает эту несовместимость. Снаружи оно точно соответствует контракту, внутри несёт с собой всё нужное.
Тот же механизм виден и в примере из функций. Проверка can_request_payout(profile_verified, bank_account_connected, review_state, requested_amount, available_balance) принимает пять параметров. В типичной задаче у менеджера открыт конкретный продавец, и нужно пройтись по всем его запросам на выплату, отфильтровав те, что можно проводить. Пять полей продавца на каждом вызове утомительны; удобнее сделать так:
seller = load_seller(id)
check = lambda do |request|
can_request_payout(
seller.profile_verified,
seller.bank_account_connected,
seller.review_state,
request.amount,
seller.available_balance
)
end
approved = requests.select do |request| check.call(request) endcheck снаружи — функция одного аргумента (request), ровно такая, как нужна select. Внутри она тащит с собой seller и использует его поля. Без замыкания ту же задачу пришлось бы решать либо глобальной переменной, либо отдельным классом с методом, либо каким-то механизмом «функция + контекст» вручную. Замыкание прячет эту обвязку в одну строку.
Как это выглядит в разных языках
Общая идея одинакова, но степень явности разная.
JavaScript. Функция в JS несёт с собой окружение, в котором была создана; когда она реально использует внешнее имя, это и есть замыкание. Захват идёт через ссылку на переменную, без церемоний.
let limit = 10000;
let large = payouts.filter(x => x > limit);Python. Похоже на JS. Одна тонкость: lambda в Python принимает только выражение, не блок, поэтому переприсвоить захваченную переменную внутри самой lambda в принципе нельзя. Чтобы это сделать, нужна вложенная def и слово nonlocal, которое отличает перепривязку внешнего имени от создания нового локального.
limit = 10000
large = list(filter(lambda x: x > limit, payouts))C++. Захват указывается явно перед телом лямбды: [limit] — копировать значение внутрь, [&limit] — держать ссылку на внешнее.
int limit = 10000;
auto above = [limit](int x) { return x > limit; };Такая явность делает цену видимой: копировать большой объект может быть дорого, а держать ссылку — опасно, если оригинал умрёт раньше замыкания. Программист решает сам.
Rust. По умолчанию режим захвата выводит компилятор: читает ли замыкание переменную, берёт ли по ссылке, или забирает внутрь по значению. Ключевое слово move форсирует последний вариант — нужно, например, когда замыкание передаётся в другой поток и не должно удерживать ссылку на чужой стек:
let limit = 10_000;
let large: Vec<_> = payouts.into_iter().filter(|&x| x > limit).collect();Компилятор проверяет, что замыкание не переживёт то, на что ссылается, — ошибки времени жизни ловятся ещё до запуска.
Java. Захватывать можно только переменные, которые не меняются после создания:
int limit = 10000;
var large = payouts.stream()
.filter(x -> x > limit)
.collect(Collectors.toList());Если бы limit переприсваивался после объявления, код не скомпилировался бы. Это сознательный выбор: захваченное значение не сможет измениться за спиной у того, кто читает код.
C. В стандартном ISO C замыканий нет (у GCC есть nested functions как расширение, но это вне стандарта и работает только там). Когда нужно передать функцию с контекстом, в C веками используют один и тот же паттерн: функция принимает лишний параметр void *context, а вызывающий код руками собирает структуру с данными и передаёт её указатель:
typedef struct { int limit; } ctx_t;
int above_limit(int amount, void *ctx) {
return amount > ((ctx_t *)ctx)->limit;
}Всё, что в языках выше делает компилятор автоматически — собрать структуру из захваченных переменных, привязать к ней функцию, передать куда нужно, — в C делает программист вручную. Это то же замыкание, только собранное пинцетом. Именно так устроены обработчики в pthread, callback’ах glib, Windows API и многих других C-библиотеках.
Во всех этих языках суть одна: функция несёт с собой контекст, в котором была создана. Различается только то, насколько явно язык показывает этот процесс и кто отвечает за корректность.
Откуда это взялось
Замыкания появились не как удобство, а как ответ на техническую проблему. В 1960-х в языках вроде Lisp функции стали значениями первого класса — их можно было класть в переменные, передавать аргументами, возвращать из других функций. И сразу обнаружилось, что этот красивый принцип сам по себе не работает.
Простейший пример, который сломал всё:
function makeAdder(x):
return function(y): return x + y
add5 = makeAdder(5)
print(add5(3)) # должно быть 8Внешняя функция makeAdder вызвана, отработала, её кадр стека уничтожен. А возвращённая из неё внутренняя функция продолжает ссылаться на x = 5, которого формально уже не существует. При попытке её вызвать x должен взяться неоткуда. Эту ситуацию назвали funarg problem — проблема функционального аргумента.
Решение оформилось в языке Scheme в середине 1970-х: когда внутренняя функция создаётся, язык запаковывает её не только с кодом, но и со ссылкой на окружение, в котором она появилась. Саму технику и слово closure — функция, «замкнутая» вокруг своего окружения — ввели раньше (Ландин в 1964-м, Мозес в 1970-м); Scheme сделала её первоклассной операцией языка и показала, что lexical closures можно применять практически.
После Scheme идея разошлась по языкам с функциями первого класса — Smalltalk, ML, Haskell, Perl, Python, Ruby, JavaScript, Swift, Rust. Сегодня она есть почти везде, где функцию можно передать как значение, — и везде по одной и той же причине: без неё передача функций как значений теряет большую часть смысла. Обычные функции первого класса без замыканий остаются — такие есть в C через указатели на функции, — но каждая передача требует ручной обвязки контекста. Замыкания — это то, что превращает функции первого класса из технически возможного средства в практически применимое.
То же и с tax_rate из первого примера: блок do |total| total * tax_rate end — это функция одного аргумента, которая внутри несёт с собой переменную из окружения. map принимает её как обычную функцию, потому что контракт формально соблюдён; работает она потому, что замыкание в момент создания записало ссылку на tax_rate. Вопрос «откуда блок знает tax_rate, если её нет в параметрах» оказался вопросом про устройство замыканий — и ответ на него заодно объясняет, почему передача функций как значений вообще работает на практике.
Дальше, когда программа перестаёт идти по плану — файл не найден, сеть оборвалась, банк вернул отказ, — обычного возврата значения перестаёт хватать. Нужен отдельный способ сообщить об ошибке и не потерять контекст, в котором она возникла.
Sources
Как Ruby реализует блоки и замыкания на уровне виртуальной машины — в Блоках, Proc и Lambda.
- McCarthy, J., 1960, Recursive Functions of Symbolic Expressions and Their Computation by Machine. Communications of the ACM.
- Moses, J., 1970, The Function of FUNCTION in LISP, or Why the FUNARG Problem Should Be Called the Environment Problem. SIGSAM Bulletin.
- Sussman, G. & Steele, G., 1975, Scheme: An Interpreter for Extended Lambda Calculus. MIT AI Memo 349.
- Abelson, H. & Sussman, G., 1996, Structure and Interpretation of Computer Programs. MIT Press.
- Thomas, D. et al., 2023, Programming Ruby 3.3. Pragmatic Bookshelf.