Что такое замыкания, как они работают, захват по ссылке и по значению, ловушки с циклом и поздним связыванием, примеры на 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 и многое другое.