Что такое замыкания, как они работают, захват по ссылке и по значению, ловушки с циклом и поздним связыванием, примеры на JavaScript, Python, Go, Java, C#, Rust, Kotlin и Swift.
Замыкания — подробно
Функция без окружения — это просто код. Функция с окружением — это замыкание, и именно оно делает возможными callbacks, декораторы и горутины.
Когда программист впервые слышит слово «замыкание», оно
звучит загадочно. На деле замыкание — это одна из самых
естественных абстракций в программировании: функция,
которая помнит место, где она родилась. Без замыканий
не было бы ни setTimeout, ни декораторов,
ни goroutine, ни лямбд.
Проблема в том, что в разных языках замыкания устроены по-разному. Где-то это синтаксический сахар, а где-то — фундамент всей модели памяти и конкурентности. Эта страница — попытка разложить замыкания по полочкам и показать их на живом коде.
Что такое замыкание: определение
Замыкание (closure) — это функция, которая ссылается на переменные из внешней области видимости и сохраняет к ним доступ даже после того, как внешняя функция завершила выполнение.
Формально замыкание = функция + её лексическое окружение. Лексическое окружение — это набор переменных, доступных функции в момент её определения.
function makeCounter() {
let count = 0;
return function () {
count += 1;
return count;
};
}
const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3
Функция, возвращённая из makeCounter,
продолжает видеть переменную count, хотя
сама makeCounter уже завершилась. Это
и есть замыкание.
Из чего состоит замыкание
Внутри любого замыкания есть три вещи:
- Код функции — что именно она делает.
- Ссылки на свободные переменные — те, что не объявлены внутри самой функции.
- Среда выполнения — где эти переменные физически хранятся (стек, куча, объект окружения).
Именно третий пункт — источник всех различий между языками. Где хранится окружение, кто им владеет, когда оно уничтожается — от этого зависит и семантика, и производительность, и типичные баги.
Замыкания в JavaScript
JavaScript — язык, где замыкания встречаются на каждом шагу, часто незаметно для самого программиста.
function makeAdder(x) {
return function (y) {
return x + y;
};
}
const add5 = makeAdder(5);
add5(3); // 8
Особенности JavaScript:
- Замыкания захватывают переменные по ссылке, а не по значению. Если переменная меняется — замыкание увидит новое значение.
-
varимеет функциональную область видимости,letиconst— блочную. Это напрямую влияет на то, что именно захватит замыкание. -
Классическая ловушка с
varв цикле:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 3, 3, 3 — все замыкания видят одну и ту же i
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 0, 1, 2 — let создаёт новую привязку на каждой итерации
Ещё одна тонкость: замыкания в JS удерживают объекты от сборки мусора. Если замыкание живёт долго и ссылается на большой объект — этот объект не будет освобождён. Это классическая причина утечек памяти в SPA.
Замыкания в Python
В Python замыкания работают похоже, но с важным отличием:
переменные захватываются по ссылке, а для
изменения внешней переменной нужно ключевое слово
nonlocal.
def make_counter():
count = 0
def counter():
nonlocal count
count += 1
return count
return counter
c = make_counter()
c() # 1
c() # 2
Без nonlocal Python решит, что count —
локальная переменная внутри counter, и выдаст
UnboundLocalError.
Особенности Python:
- Замыкание захватывает имя, а не значение. Позднее связывание — источник классической ловушки с лямбдами в цикле.
-
lambda— тоже замыкание, но только из одного выражения. -
Для захвата значения «намертво» используют аргумент
по умолчанию:
lambda x=i: x.
funcs = [lambda: i for i in range(3)]
[f() for f in funcs] # [2, 2, 2]
funcs = [lambda i=i: i for i in range(3)]
[f() for f in funcs] # [0, 1, 2]
Замыкания в Go
Go — язык с явными замыканиями и одной очень известной исторической ловушкой.
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
До Go 1.22 переменная цикла была одной на весь цикл, и замыкания захватывали её по ссылке:
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i)
}()
}
// до Go 1.22: 3, 3, 3 (или любая перестановка)
// с Go 1.22: 0, 1, 2
Начиная с Go 1.22 переменная цикла создаётся заново на каждой итерации, и проблема исчезла. Но код, написанный до этого, всё ещё живёт в проде.
Ещё одна особенность: замыкания в Go захватывают переменные по ссылке, и если замыкание уходит в горутину — нужно следить за временем жизни захваченных переменных.
Замыкания в Java
В Java замыкания появились только в Java 8 вместе с лямбдами. И здесь работает жёсткое правило: захватывать можно только эффективно финальные переменные (effectively final).
int x = 10;
Runnable r = () -> System.out.println(x);
// x нельзя менять после этого
Почему так? Потому что Java захватывает значение, а не ссылку. Если бы переменная могла меняться, было бы неясно, какое значение должно попасть в замыкание.
Обходной путь — использовать массив или
AtomicInteger:
int[] count = {0};
Runnable r = () -> count[0]++;
Внутри Java реализует замыкания через invokedynamic
и скрытые классы, но с точки зрения программиста это
просто функция + захваченные значения.
Замыкания в C#
C# — один из немногих языков, где замыкания реализованы через поднятие переменных в кучу (hoisting). Компилятор создаёт скрытый класс, куда переносит захваченные переменные.
Func<int> MakeCounter() {
int count = 0;
return () => ++count;
}
Особенности C#:
- Захват по ссылке, как в JS и Python.
-
Классическая ловушка с
foreachбыла исправлена в C# 5: переменная цикла теперь создаётся заново на каждой итерации. - Замыкания могут заметно влиять на производительность из-за аллокаций в куче — в hot path это важно.
Замыкания в Rust
Rust — самый строгий язык в этом списке. Здесь замыкания явно делятся на три типа по способу захвата:
-
Fn— захватывает по ссылке (&T), может вызываться многократно. -
FnMut— захватывает по изменяемой ссылке (&mut T), может менять окружение. -
FnOnce— захватывает по значению (T), может быть вызван только один раз.
let mut count = 0;
let mut inc = || { count += 1; };
inc();
inc();
println!("{}", count); // 2
Rust заставляет программиста явно думать о том, кто владеет захваченными данными и как долго живёт замыкание. Это неудобно поначалу, но полностью исключает целый класс багов с временем жизни.
Замыкания в Kotlin
Kotlin, как и Java, работает на JVM, но замыкания здесь гораздо удобнее: можно захватывать изменяемые переменные.
fun makeCounter(): () -> Int {
var count = 0
return { ++count }
}
Внутри Kotlin оборачивает изменяемые переменные
в Ref-объекты, чтобы обойти ограничение JVM.
Это удобно, но стоит помнить: за такое удобство
платят аллокациями.
Замыкания в Swift
Swift называет замыкания closures и требует явного указания списка захвата, если нужно контролировать владение:
var count = 0
let inc = { count += 1 }
inc()
Для работы с классами и избежания циклов сильных ссылок
используется [weak self] или
[unowned self]:
someObject.onComplete = { [weak self] in
self?.doSomething()
}
Это прямое следствие того, что Swift использует ARC — automatic reference counting, и замыкания могут удерживать объекты.
Сводная таблица
Язык Захват Изменение Особенность
-------- ------------ -------------- ------------------------------
JS по ссылке свободно var vs let, утечки памяти
Python по ссылке nonlocal позднее связывание
Go по ссылке свободно ловушка цикла до Go 1.22
Java по значению только final effectively final
C# по ссылке свободно hoisting в кучу
Rust Fn/FnMut/ явно владение и времена жизни
FnOnce
Kotlin по ссылке свободно обёртка Ref
Swift по ссылке свободно ARC, weak/unowned
Типичные ловушки
- Захват переменной цикла. Классика в JS, Python, Go (до 1.22), C# (до 5).
- Утечки памяти. Замыкание живёт дольше, чем нужно, и держит большой объект — JS, Swift, C#.
- Гонки данных. Замыкание уходит в другой поток и меняет общую переменную — Go, Java, C#.
- Циклические ссылки. Замыкание ссылается на объект, который ссылается на замыкание — Swift, JS.
- Неожиданное позднее связывание. Замыкание видит последнее значение переменной, а не то, что было при создании — Python, JS.
Практические советы
-
Если замыкание должно захватить значение,
а не переменную — захватите его явно:
lambda x=i: xв Python,[i]в Swift, копия в локальную переменную в JS. - Не держите в замыканиях большие объекты дольше, чем нужно — это прямой путь к утечкам.
- В многопоточном коде проверяйте, какие переменные захватывает замыкание и кто их ещё видит.
-
В Rust предпочитайте
Fn, если можете — это самый дешёвый вариант. - Помните: замыкание — это не «функция с памятью», а функция + окружение. Окружение живёт столько, сколько живёт замыкание.
Мини-тест: угадай вывод
Проверь себя — что напечатает каждый фрагмент?
// 1. JavaScript
const fns = [];
for (var i = 0; i < 3; i++) fns.push(() => i);
console.log(fns.map(f => f()));
// 2. Python
fns = [lambda: i for i in range(3)]
print([f() for f in fns])
// 3. Go (1.22+)
var fns []func() int
for i := 0; i < 3; i++ {
fns = append(fns, func() int { return i })
}
for _, f := range fns { fmt.Print(f(), " ") }
// 4. Java
List<Supplier<Integer>> fns = new ArrayList<>();
for (int i = 0; i < 3; i++) {
int x = i;
fns.add(() -> x);
}
fns.forEach(f -> System.out.print(f.get() + " "));
Ответы:
- JavaScript:
[3, 3, 3]—varсоздаёт одну переменную на весь цикл. - Python:
[2, 2, 2]— позднее связывание, все лямбды видят финальноеi. - Go 1.22+:
0 1 2— переменная цикла создаётся заново. - Java:
0 1 2— явная копия вxработает всегда.
Что запомнить
- Замыкание = функция + лексическое окружение.
- Главное различие между языками — как захватываются переменные: по ссылке или по значению.
- Захват по ссылке даёт гибкость, но приводит к ловушкам с поздним связыванием и гонками.
- Захват по значению безопаснее, но требует явных обходных путей для изменяемых переменных (Java, Rust).
- Владение и время жизни окружения — ключ к пониманию утечек памяти и багов с конкурентностью.
- Замыкания — не украшение языка, а базовая абстракция, на которой строятся callbacks, promises, декораторы, goroutines и многое другое.