Замыкания и цикл

Легендарные «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.
Проверьте себя
1. Что выведет цикл: for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); }
A0 1 2
B3 3 3
C0 0 0
D2 2 2
2. Почему замена var на let в заголовке цикла даёт 0, 1, 2?
Alet делает колбэк setTimeout синхронным
Blet создаёт новую привязку переменной на каждой итерации, и каждое замыкание держит свою
Clet копирует значение переменной внутрь колбэка при его создании
Dlet поднимает переменную в глобальную область видимости