Замыкания: как функция помнит своё окружение

замыкания≈ 8 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Функция в JavaScript — не просто список инструкций. Вместе с ней рождается память о месте, где её объявили: она помнит переменные-соседей и сохраняет к ним доступ даже тогда, когда внешний код давно отработал. Это и называется замыканием.

Разделы темы:·

Когда движок создаёт функцию, он не отрывает её от контекста. К функции незримо прикрепляется ссылка на то место, где она была объявлена, — на её лексическое окружение. Окружение — это набор переменных, которые были рядом в момент рождения функции.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Внутри greet нет переменной user — и это не мешает. Не найдя имя у себя, функция смотрит в окружение, где была создана, затем в окружение снаружи него, и так по цепочке до самого верха. Если имени нет нигде — только тогда будет ReferenceError. Это та же цепочка, что в статье «Область видимости», — здесь у неё появляется вторая половина: окружение живёт дольше вызова.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Замыкание — это функция вместе с её лексическим окружением. Отдельной «конструкции замыкания» в языке нет: в JavaScript замыканием является каждая функция, просто в большинстве случаев мы этого не замечаем.

Главное правило звучит почти как афоризм: функция ищет переменные там, где её написали, а не там, где её позвали. Вызвать её можно откуда угодно — окружение от этого не изменится.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Внутри run есть своя переменная phrase, но say о ней ничего не знает: она родилась снаружи и смотрит только в своё окружение. Такую область видимости называют лексической, или статической, — её видно прямо в тексте программы, без запуска.

Обратная сторона правила — самая полезная: функция, созданная внутри другой функции, наследует все её локальные переменные. Так получаются фабрики функций.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Самое неожиданное для новичка: внешняя функция уже завершилась, а её переменные всё ещё живы. Пока существует вложенная функция, которая на них ссылается, окружение никуда не исчезает.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Переменная count не глобальная и не «висит» на объекте — до неё вообще нельзя дотянуться снаружи. Единственный способ её изменить — вызвать функцию, которая её замкнула.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Каждый вызов внешней функции создаёт новое окружение — поэтому два счётчика из одной фабрики считают порознь.

Классическая задача с собеседований: складываем в массив три функции внутри цикла и ждём 0, 1, 2. Получаем другое.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

var не знает про блоки: переменная i одна на всю функцию (или на весь скрипт). Все три функции замкнули не три разных значения, а одну и ту же ячейку — а к моменту вызова цикл уже отработал и оставил в ней 3.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

С let на каждой итерации создаётся новая переменная i, и в неё копируется текущее значение. Три итерации — три независимых окружения, каждая функция замкнула своё. Это, пожалуй, самая практичная причина забыть про var.

До ES6 новую область видимости создавала только функция. Поэтому переменную цикла заворачивали в немедленно вызываемую функцию: параметр j — это уже отдельная переменная для каждой итерации.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Замыкание легко принять за трюк для собеседований, но вы пользуетесь им в каждой второй строке. Любой колбэк, который читает переменную снаружи себя, — замыкание.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Второе применение — приватные данные. Модификатора «приватно» для обычных переменных в языке нет, но замыкание даёт то же самое: наружу торчат только те функции, которые вы вернули.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Обработчики событий, таймеры, кеши, функции с настройками, модули — всё это держится на том, что функция помнит своё окружение. Замыкания не нужно «применять» специально: их нужно понимать, чтобы не удивляться поведению собственного кода. Приёмы, которые из них выросли — приватные данные, фабрики, мемоизация, — собраны в статье «Замыкания на практике: счётчики и приватность».

Замыкание держит окружение живым ровно столько, сколько жива сама функция. Пока ссылка на неё где-то хранится — в массиве обработчиков, в таймере, в объекте — переменные не соберёт сборщик мусора. Утечки возникают не из-за замыканий, а из-за забытых ссылок: снимите обработчик, обнулите ссылку — и окружение освободится.

Движки к тому же экономны: обычно в окружении остаются только те переменные, к которым функция реально обращается. Полагаться на эту оптимизацию в коде не стоит, но и бояться каждого замыкания не нужно.

  • Замыкание — это функция вместе с лексическим окружением, в котором она была создана.
  • Переменные ищутся по цепочке окружений от места объявления функции, а не от места вызова.
  • Каждый вызов внешней функции создаёт новое окружение, поэтому счётчики из одной фабрики независимы.
  • var в цикле даёт одну переменную на все итерации, let — свою на каждую; отсюда классические 3, 3, 3.
  • Замыкания не экзотика, а фундамент: колбэки, фабрики функций и приватное состояние работают на них.

1/3Функция say объявлена в глобальной области и печатает phrase. Внутри функции run объявлена своя переменная phrase со значением «внутри», и оттуда вызывается say(). Что попадёт в консоль?

2/3Фабрика makeCounter создаёт счётчики с переменной count внутри. Мы вызвали её дважды и получили две функции. Делят ли они одну переменную count?

3/3В цикле for с var i от 0 до 2 мы трижды кладём в массив функцию, возвращающую i. Что вернут эти функции после цикла?

Счётчик с шагом

Напишите фабрику makeStepper(step): она возвращает функцию, которая при каждом вызове увеличивает внутренний счётчик на step и возвращает новое значение. Значение должно храниться в замыкании — никаких глобальных переменных.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Переменную-накопитель объявляем внутри фабрики. Она попадает в окружение возвращаемой функции и переживает завершение makeStepper.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Каждый вызов makeStepper создаёт новое окружение, поэтому два степпера считают независимо.

Починить цикл

Массив должен хранить функции, возвращающие 0, 1 и 2. Сейчас все три возвращают тройку. Исправьте код, изменив ровно одно слово.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Достаточно заменить var на let: тогда на каждой итерации создаётся новая переменная i, и каждая стрелочная функция замыкает собственную копию.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Если var изменить нельзя (например, это чужой код), помогает обёртка из немедленно вызываемой функции — приём из раздела про цикл.