Осваиваем замыкания в Javascript

Sergey Shambir
11 min readMay 18, 2017

--

Вы читаете вольный перевод статьи Let’s Learn JavaScript Closures

Замыкания — это фундаментальная концепция в Javascript, которую любой опытный разработчик знает и применяет.

В Интернете полно прекрасных объяснений, отвечающих на вопрос “что” делают замыкания, но очень мало объяснений “зачем”.

Я уверен, что понимание внутреннего устройства позволяет разработчику лучше освоить свой инструментарий, и этот пост осветит вопросы “как” и “почему” касательно замыканий.

Искренне надеюсь, что после прочтения статьи вы сможете использовать силу замыканий в своих повседневных задачах. Приступим!

Что такое замыкания?

Замыкания — это крайне функциональная штука, реализованная в Javascript и в большинстве других языков. Процитируем описание с MDN:

Замыкания — это функции, ссылающиеся на независимые (свободные) переменные. Другими словами, функция, определённая в замыкании, «запоминает» окружение, в котором она была создана.

Независимые переменные — это все переменные, которые не были переданы как параметры и не были объявлены как локальные. Посмотрим на пример.

Пример 1: numberGenerator

В примере выше функция numberGenerator создаёт локальную переменную num (число), а также локальную функцию checkNumber (функцию, печатающую num в консоль разработчика). Локальная функция checkNumber сама по себе не объявляет локальных переменных, но благодаря механизму замыкания ей доступна переменная из внешнего окружения функции numberGenerator. В результате она может пользоваться переменной num, созданной во время вызова функции numberGenerator, даже после возврата из вызова numberGenerator.

Пример 2: sayHello

В этом примере мы покажем, что замыкание в локальной функции содержит все переменные, объявленные в окружении внешней функции.

Заметно, что переменная hello объявлена после анонимной функции, но всё равно доступна для неё. Это происходит из-за ключевого слова var, которое делает переменную доступной сразу во всей области видимости функции сразу после начала вызова (не волнуйтесь, мы расскажем подробно об областях видимости в этом же посте).

Подытожим высокоуровневый смысл замыканий

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

Чтобы понять, как такое возможно, мы начнём восхождение с подножий до вершин, где лежит волшебная долина замыканий. Сперва мы достигнем понимания контекста, в котором происходит вызов функции, известного также как “контекст выполнения” (execution context)

Контекст выполнения

Контекст выполнения — это абстрактная концепция, в рамках которой спецификация языка Javascript, известная как ECMAScript, объясняет модель выполнения кода после запуска. Контекст бывает либо глобальным, с которого начинается исполнения скрипта, либо контекстом выполнения вызова, который начинается с момента входа в тело функции.

Контекст выполнения вызова

В каждый момент времени активен только один контекст выполнения. Именно поэтому Javascript называют “однопоточным”, имея ввиду, что только одна инструкция исполняется в один момент времени. Типичный браузер отслеживает контексты выполнения с помощью стека.

Стек — это структура данных, действующая по принципу “первый добавленный убран последним” (Last In First Out), то есть последний объект, добавленный на стек, окажется на его вершине и вылетит первым при извлечении объекта. Добавлять объекты можно только на вершину стека, и удалять их можно только с вершины.

Активный контекст выполнения находится на вершине стека. Он снимается со стека, когда выполнение кода активного контекста завершается, и выполнение продолжается в коде предыдущего контекста, который теперь оказался на вершине стека.

Более того, если в данный момент исполняется код из некоторого контекста выполнения, это ещё не значит, что он должен завершить выполнение до запуска другого потока выполнения. Есть временные интервалы, на которых активный контекст засыпает, и другой контекст выполнения становится активным контекстом. Заснувший контекст выполнения может позднее проснуться на той точке, в которой он ранее заснул. Каждый раз при подобной замене одного контекста выполнения другим будет создаваться новый контекст выполнения, который попадёт на вершину стека, и станет активным контекстом.

Посмотрите на практический пример, показывающий работу этой концепции в браузере:

В момент выполнения функции boop вы можете увидеть активный контекст выполнения в отладчике браузера:

Когда вызов функции boop возвращает управление, его контекст выполнения выталкивается из стека, и выполнение продолжается в контексте выполнения вызова bar:

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

  • Состояние вычисления (code evaluation state): всё состояние контекста, необходимое для исполнения, засыпания и продолжения вычисления кода, связанного с одним контекстом выполнения
  • Функция (function): объект функции, с вызовом которой связан контекст выполнения, либо null, если контекст выполнения связан со скриптом или модулем
  • Область (realm): набор внутренних объектов, глобальное окружение ECMAScript и связанные с ним ресурсы, а также весь код на ECMAScript, который загружается в области видимости глобального окружения
  • Лексическое окружение: используется для сопоставления “идентификаторов-ссылок”, используемых в коде внутри контекста выполнения
  • Таблица переменных (variable environment) — таблица, связанная с лексическим окружением, в которой в качестве ключей занасены все имена переменных, используемые в инструкциях объявления переменных

Если всё это выглядит черезчур сложным, не беспокойтесь: среди всех этих сущностей интереснее всего для нас лексическое окружение, потому что именно оно участвует в процессе сопоставления “идентификаторов-ссылок”, созданных в коде внутри контекста выполнения. Идентификаторы — это имена сущностей, таких как переменные и функции. Напомним, что наша первоначальная цель — понять, почему мы можем обращаться к созданным переменным даже после возврата из функции (т.е. возврата из контекста выполнения). Для понимания нам пригодится лексическое окружение!

Примечание: технически, и таблица переменных, и лексическое окружение нужны для реализации замыкания. Но мы можем упростить модель, объединив обе сущности под одним термином “Окружение” (Environment). А если вы желаете подробно изучить разницу между лексическим окружением и таблицей переменных, есть отличная статья от Alex Rauschmayer.

Лексическое окружение (Lexical Environment)

Из определения:

Лексическое окружение — это термин, описывающий связывание идентификаторов с переменными и функциями, основанное на лексической вложенности кода, написанного на ECMAScript (Javascript). Лексическое окружение состоит из таблицы символов и ссылки на внешнее лексическое окружение (нулевой для глобального окружения). Обычно лексическое окружение связано с синтаксической конструкцией в коде на ECMAScript, такой как объявление функции, блок кода или блок catch в try-инструкции, и новое лексическое окружение создаётся каждый раз при вычислении такого кода — ECMAScript-262/6.0.

Давайте разберём его по пунктам:

  • описывающий связывание идентификаторов с переменными и функциями”: лексическое окружение отвечает за управление данными в виде переменных и функций (или, если кратко, “символов”) в коде. Другими словами, оно задаёт смысл доступных идентификаторов. Например, если мы имеем строку кода console.log(x / 10), то смысл использования идентификатора x появляется, если кто-то задаёт связь между этим идентификатором и символом. Лексическое окружение задаёт связь идентификаторов с переменными и функциями через таблицу символов.
  • Лексическое окружение состоит из таблицы символов”: таблица символов в виде ассоциативного массива (хеш-массива или бинарного дерева) даёт простой способ хранения записей обо всех идентификаторах и их привязки к символам внутри лексического окружения. Каждое лексическое окружение имеет таблицу символов.
  • основанное на лексической вложенности кода”: это интересный нюанс, означающий, что вложенное окружение ссылается на внешнее окружение, а это внешнее окружение, в свою очередь, может иметь ссылку на окружающее его окружение. И только глобальное окружение не имеет внешнего окружения. Это можно сравнить с луковицей: есть внешний слой, и все остальные слои вложены друг в друга

В псевдокоде можно описать окружение следующим образом:

  • новое лексическое окружение создаётся каждый раз при вычислении такого кода”: каждый раз при вызове функции создаётся новое лексическое окружение. Это важно — и мы вернёмся к данной оговорке ближе к концу. Примечание: функция — не единственный способ создать лексическое окружение. Оно также создаётся в блоке кода, окружённом фигурными скобками, и в теле ветки “catch” инструкции “try”. В данном посте мы сосредоточимся на окружениях, создаваемых при вызове функции.

Каждый контекст выполнения имеет лексическое окружение. Это окружение хранит переменные и их значения, имеет ссылку на внешнее окружение. Лексическим окружением может быть глобальное окружение, или окружение модуля (содержащее привязки имён к символам, объявленным в этом модуле), или окружение функции (созданное в процессе её вызова).

Цепочка областей видимости (Scope Chain)

На основе определения выше легко понять, что окружение получает доступ к символам родительского окружения, а родительское окружение имеет доступ к своему родителю, и так далее. Объединённое множество идентификаторов, доступ к которым имеет текущее окружение, носит имя “область видимости” (scope). В процессе движения от родительского окружения к дочернему число доступных идентификаторов возрастает, и сами области видимости складываются в “цепочку областей видимости”.

Рассмотрим пример такой вложенности:

Легко заметить, что функция bar вложена внутрь функции foo. На схеме показана визуализация этой иерархии:

Иерархия цепочки областей видимости (т.е. цепочки окружений, связанных с функциями) сохраняется в момент создания функции перед началом интерпретации скрипта. То есть иерархия определена статически расположением в исходном коде (это также называется “лексическим связыванием”, англ. lexical scoping).

Давайте кратко пройдёмся по различиям между “динамической областью видимости” и “статической областью видимости”, что поможет понять, как статические области видимости (или лексические области видимости) позволяют реализовать замыкание.

Обзор: динамические и статические области видимости

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

Напротив, в случае статических областей видимости переменные в пределах одного контекста записываются при подготовке программы к выполнению. Иначе говоря, лишь структура исходного кода программы определяет, на какие переменные ссылается код.

Два примера ниже иллюстрируют разницу между динамическими и статическими областями видимости.

Пример 1:

Со статическими областями видимости возвращаемое bar() значение зависит от переменной x, доступной в момент создания foo(). Из-за лексической структуры кода идентификатор x в функции foo() ссылается на переменную x, инициализированную числом 10.

С динамическим областями видимости всё зависит от стека объявлений переменных, формируемого во время выполнения, поэтому значение x зависит от состояния программы перед вызовом foo(). При вызове bar() на стек объявлений переменных добавляется новая запись о переменной x, инициализированной числом 2, и в результате foo() вернёт 7.

Пример 2:

Принцип тот же — если области видимости динамические, то значение и местоположение переменной myVar определяется в момент вызова функции. Если же области видимости статические, то связывание переменных с идентификаторами в коде происходит в момент создания объекта функции, то есть при выполнении кода с объявлением функции. Объявление функций в старом стиле начинается со слова function, а в новой версии языка появились arrow-функции.

Вы наверняка заметили, что динамические области видимости вызывают некоторую неоднозначность: при чтении кода не очевидно, в какую переменную обратится идентификатор в коде.

Замыкания (Closures)

Мы рассмотрели все механизмы, которые необходимы для замыканий, и теперь можем их коснуться:

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

Таким образом, функция “запоминает окружение (или область видимости, англ. scope), поскольку функция фактически сохраняет ссылку на окружение (и тем самым удерживает в памяти таблицу переменных функции).

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

Теперь мы понимаем, как работают области видимости, и на основе этих данных можем описать в псевдокоде окружения в этом примере:

После вызова функции test мы получим 45, что является значением, возвращённым функцией bar, поскольку foo вернула объект функции bar. Функция bar имеет доступ к локальной переменной y из функции foo, а также имеет доступ к переменной x, поскольку функция foo имеет доступ к глобальной области видимости. По-английски это называется “scope-chain lookup”, т.е. поиск по цепочке областей видимости.

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

На деле в Javascript происходит иначе: при замыкании данные родительского контекста удерживаются в памяти в области, называемой “кучей” (она называется так из-за структуры данных, которую операционная система использует для реализации кучи). Это позволяет сохранять контекст выполнения даже после того, как его выкинули со стека.

Понравилось? Отлично! Мы достаточно освоили абстрактные механизмы, и можем взглянуть ещё на несколько деревьев.

Пример 1:

Это одна из классических ошибок — у нас есть цикл for и мы пытаемся воспользоваться счётчиком цикла в анонимной функции внутри цикла:

Используя только что усвоенный материал, мы легко обнаружим ошибку: давайте визуализируем окружение после выполнения цикла for в этом примере:

Было бы неверно предположить, что цепочки областей видимости отличаются для пяти функций в массиве result. Вместо этого каждое обновление переменной i обновляет область видимости, которую делят между собой все пять анонимных функций. Это можно исправить путём добавления нового контекста исполнения, в котором копия значения i поступит извне как параметр функции.

Мы исправили проблему! Но есть и более правильный путь: использовать ключевое слово let, которое иначе объявляет переменную — запись о переменной создаётся на каждой итерации цикла.

Example 2:

В этом примере мы покажем как каждый вызов функции создаёт новое замыкание:

В этом примере мы видим, что каждый вызов функции iCantThinkOfAName создаёт новое замыкание, достаточно взглянуть на foo или bar. Последующие любого из замыканий обновляют переменные внутри замыкания, что демонстрирует возможность использования переменных из любого замыкания в функции doSomething уже после того, как функция iCantThinkOfAName возвращает управление.

Example 3:

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

Благодаря тому что функции add и subtract хранят ссылку на окружение функции mysteriousCalculator, они могут использовать переменные из этого окружения для вычисления результата.

Example 4:

В последнем примере мы покажем важное применение замыканий: хранение приватной ссылки на переменную из внешней области видимости:

Это очень мощная техника — она обеспечивает функции guessPassword эксклюзивный доступ к переменной password, и в то же время приводит к недоступности переменной password снаружи.

Подытожим

  • Контекст выполнения (execution context) — это абстракция, используемая спецификацией язык ECMAScript для сопровождения выполнения кода; в каждый момент времени только один контекст выполнения выполняет код
  • Каждый контекст выполнения имеет лексическое окружение (lexical environment), которое хранит таблицу привязки имён переменных к их значениям, а также удерживает ссылку на родительское окружение.
  • Набор идентификаторов, доступных из окружения, называется областью видимости. Мы можем вкладывать области видимости друг в друга, создавая цепочку областей видимости
  • Каждая вызванная функция имеет контекст выполнения, который включает в себя ссылку на лексическое окружение, определяющее смысл переменных в функции и хранящее ссылку на родительское окружение. Фактически, функция “запоминает” данное окружение благодаря сохранению ссылки на родительское окружение. Это и называется замыканием.
  • Замыкание для вложенной функции создаётся каждый раз, когда вызывается внешняя функция. Другими словами, замыкание создаётся ещё до выполнения вложенной функции.
  • Область видимости для замыкания является лексической, то есть определяется статически положением функции в исходном коде
  • Замыкания имеют огромное практическое значение. Один из классических приёмов — хранить ссылку на приватную переменную, недоступную для изменения из внешнего контекста.

Заключительные заметки

Я надеюсь, что эта статья была полезной и создала в вашем сознании модель, описывающую механизм замыканий в Javascript. Вы легко заметите, как глубокое понимание особенностей выполнения функций и создания замыканий сберегает вам много времени при отладке и избавляет от многих головных болей при разработке.

Дальнейшее чтение

В целях краткости мы не затронули несколько тем, которые могли бы оказаться интересными для ряда читателей. Ниже вы найдёте ссылки, которыми хотим поделиться:

--

--