Замыкания и цикл
Легендарные «3, 3, 3»: код выглядит очевидным, а выводит совсем не то, чего ждёшь. Разбираемся, что замыкание держит на самом деле.
Замыкание (closure) — функция вместе со ссылкой на область видимости, в которой она была создана. Функция помнит не значения, а сами переменные: она видит их такими, какие они на момент вызова, а не на момент создания.
Этот вопрос любят, потому что он одним примером проверяет сразу три вещи: понимание области видимости, понимание асинхронности и умение объяснить разницу между var и let не заученной фразой, а по существу.
Вопрос
for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 0);
}
Результат:
3
3
3
Почему? Сложились две вещи.
Первая: var не знает про блочную область видимости. Переменная i одна на всю функцию (а если цикл на верхнем уровне — одна на весь скрипт). Цикл не создаёт три разные i — он трижды меняет одну и ту же.
Вторая: все три колбэка — макрозадачи. Они выполнятся только тогда, когда синхронный код (то есть весь цикл) закончится. А к этому моменту i уже равна 3 — именно из-за неё цикл и остановился.
Ключевая формулировка, которую ждёт интервьюер: замыкание захватывает переменную, а не её значение. Три колбэка смотрят в одну ячейку памяти.
Докажем, что дело не в setTimeout
Уберём асинхронность полностью — эффект останется.
function makeReader() {
let value = 'первое';
const read = () => console.log(value);
value = 'второе'; // меняем ПОСЛЕ создания функции
return read;
}
makeReader()(); // функция читает переменную в момент вызова
Результат:
второе
Функция read была создана, когда value была 'первое'. Но она сохранила ссылку на переменную, а не снимок значения — поэтому видит актуальное 'второе'.
Три способа починить цикл
1. let — правильный ответ
for (let i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 0);
}
Результат:
0
1
2
Здесь важно не сказать «let копирует значение» — это неправда и интервьюер зацепится. Правильная формулировка: let в заголовке for создаёт новую привязку переменной на каждой итерации. Перед каждым проходом движок заводит свежее лексическое окружение и копирует туда текущее значение i. Итого три разные переменные i — и каждое замыкание держит свою.
2. IIFE — как чинили до ES6
Если своей области видимости нет, её можно создать вручную: вызвать функцию и передать значение параметром. Параметр — это уже новая переменная на каждый вызов.
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(function () {
console.log(j);
}, 0);
})(i);
}
Результат:
0
1
2
3. Третий аргумент setTimeout
Малоизвестный, но валидный приём: всё после задержки setTimeout передаёт в колбэк аргументами.
for (var i = 0; i < 3; i++) {
setTimeout(function (n) {
console.log(n);
}, 0, i);
}
Результат:
0
1
2
Здесь i вычисляется в момент вызова setTimeout, то есть синхронно, и значение фиксируется в параметре n.
Как это работает под капотом
При создании функции движок сохраняет в ней скрытую ссылку на лексическое окружение — объект, где живут переменные окружающей области. Пока жива функция, живо и окружение: сборщик мусора не может его выбросить. Отсюда два следствия.
- Разделяемое состояние. Несколько функций, созданных в одном окружении, видят одни и те же переменные и могут «переговариваться» через них.
- Утечки памяти. Если замыкание держит ссылку на большой объект (например, DOM-узел или мегабайтный массив) и само живёт долго (висит обработчиком события или в
setInterval), объект не будет освобождён. Это частый follow-up: «чем опасны замыкания?».
Для цикла с let спецификация описывает отдельный шаг — CreatePerIterationEnvironment: перед каждой итерацией создаётся новое окружение, куда копируется значение из предыдущего. Именно этот шаг и делает всю магию. С var никакого шага нет — окружение одно.
Зачем замыкания на практике
Собеседование почти всегда переходит от «3, 3, 3» к «а где вы применяли замыкания сами?». Три ответа, которые всегда звучат убедительно.
Приватное состояние
function makeCounter() {
let count = 0; // снаружи достать нельзя
return {
inc: () => ++count,
get: () => count,
};
}
const a = makeCounter();
a.inc();
a.inc();
console.log('счётчик a:', a.get());
const b = makeCounter(); // независимое окружение
console.log('счётчик b:', b.get());
Результат:
счётчик a: 2
счётчик b: 0
Кеширование (memoize)
function memoize(fn) {
const cache = new Map(); // живёт ровно столько, сколько живёт обёртка
return function (n) {
if (cache.has(n)) {
console.log('взяли из кеша:', n);
return cache.get(n);
}
const result = fn(n);
cache.set(n, result);
return result;
};
}
const square = memoize(function (n) {
console.log('считаем заново:', n);
return n * n;
});
console.log(square(9));
console.log(square(9));
Результат:
считаем заново: 9
81
взяли из кеша: 9
81
Вызвать только один раз (once)
function once(fn) {
let called = false;
let result;
return function (...args) {
if (called) {
console.log('уже вызывали — возвращаю сохранённое');
return result;
}
called = true;
result = fn(...args);
return result;
};
}
const init = once(function (n) {
console.log('инициализация');
return n * 2;
});
console.log(init(21));
console.log(init(21));
Результат:
инициализация
42
уже вызывали — возвращаю сохранённое
42
Ровно на замыканиях устроены debounce, throttle, каррирование и вообще любая «функция, которая что-то помнит между вызовами».
Тот же баг, но в React: stale closure
Если вакансия про React, разговор про замыкания почти наверняка свернёт сюда. Эффект с пустым массивом зависимостей запоминает count из первого рендера — и держит это устаревшее значение вечно.
// ПЛОХО: интервал навсегда захватил count === 0 из первого рендера
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // всегда 0 + 1 === 1
}, 1000);
return () => clearInterval(id);
}, []); // count не в зависимостях — колбэк не пересоздаётся
// ХОРОШО: функциональное обновление не зависит от захваченной переменной
useEffect(() => {
const id = setInterval(() => {
setCount((prev) => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []);
Это тот же «3, 3, 3», только вместо переменной цикла — переменная рендера. Функция, созданная в первом рендере, живёт в интервале и видит окружение первого рендера. Умение назвать это «stale closure» и предложить функциональный setState — сильный сигнал для интервьюера.
Частые ошибки на собеседовании
- «
letкопирует значение в колбэк». Нет:letсоздаёт новую привязку переменной на каждой итерации. Замыкание по-прежнему захватывает переменную, просто теперь их три. - «Проблема из-за
setTimeout». Нет: асинхронность лишь проявляет баг. ЗаменитеsetTimeoutна сохранение функций в массив и вызов после цикла — получите те же «3, 3, 3». - «Замыкание — это функция внутри функции». Слишком поверхностно. Замыкание — это функция плюс сохранённая область видимости.
- Не уметь назвать минус. Замыкания удерживают память. Обработчик, который замкнул на себя большой объект и не был снят, — реальная утечка.
- Путать
var-хойстинг и замыкания. Это разные темы, хотя в этом примере они встречаются.
Итоги — шпаргалка
- Замыкание захватывает переменную, а не значение, и читает её в момент вызова.
var— одна переменная на всю функцию, поэтому три колбэка видят финальную3.letвforсоздаёт новую привязку на каждой итерации — отсюда0, 1, 2.- Альтернативы для
var: IIFE с параметром или третий аргументsetTimeout. - Практика замыканий: приватное состояние,
memoize,once,debounce, каррирование. - Цена: пока жива функция — жива вся захваченная область. Отсюда утечки памяти и stale closure в React.