Я раньше никогда не понимал замыкания в JavaScript …
Что такое замыкание и как оно работает? Очередной достаточно частый вопрос на собеседованиях. Я всегда думал что я знаю что ответить на этот вопрос. И всегда отвечал что типа такого… Замыкание это когда функция низшего порядка получается доступе к переменным функции высшего порядка. Но знать приблизительное определение и реально разбираться в замыкание не совсем одно и тоже. Давайте рассмотрим простейший пример:
function createCounter() { let counter = 0 const myFunction = function() { counter = counter + 1 return counter } return myFunction } const increment = createCounter() const c1 = increment() const c2 = increment() const c3 = increment() console.log('example increment', c1, c2, c3)
Как по вашему что должно быть выведено в консоль? Если вы точно знаете ответ, то можете пропустить эту статью. А если думаете что 1,1,1 или если у вас возникло какое либо сомнение, рекомендую продолжить чтение далее….
Статья получилась большой, с длинными последовательностями описания маленьких примеров. Но если вас это не смутит и вы пройдетесь по этим описаниям, в награду получите более полное понимание того как работает замыкание.
До того как мы начнем
Перед тем как мы продолжим, хотел оговорить некоторые важные понятия, которые нам понадобятся дальше. Одним из них является контекст исполнения.
Это статья хорошо объясняет что такое Контекст исполнения (Execution Context). Основные понятия из статьи:
Когда код выполняется, среда, в которой он выполняется, очень важна и определяется как то так:
Глобальный код (Global code ) — Среда по умолчанию, где код выполняется в первый раз.
Код функции (Function code) — всякий раз, когда поток выполнения входит в тело функции.
(…), давайте думать о термине контекст исполнения как о среде/области видимости (scope), в которой выполняется текущий код.
Другими словами, когда запускается программа, ее код начинается исполнятся в глобальном контексте. Как правило некоторое количество переменных объявляются в глобальном контексте выполнения. Мы называем их глобальными переменными. А что происходит когда программа вызывает функцию? Вот список шагов:
- JavaScript создает новый контекст исполнения, который становится локальным для текущего потока
- Этот локальный контекст исполнения будет иметь свой собственный набор переменных, и они будут локальными для этого контекста.
- Новый контекст переносится в стек исполнения. Думайте о стеке как о механизме, позволяющем отслеживать, где в текущий момент исполняется программа.
Что происходит когда функция заканчивает выполнение? Выполнение заканчивается когда встречается оператор return
или соответствующая закрывающая скобка }
:
- Локальные контексты исполнения извлекается из стека выполнения
- Функции отправляют возвращаемое значение обратно в вызывающий контекст. Вызывающий контекст — это контекст который вызвал эту функцию. Им может быть глобальный контекст или другой локальный контекст выполнения. Возвращаемое значение может быть объектом, массивом, функцией, boolean или чем угодно. Если функция не имеет оператора возврата
return
, возвращаетсяundefined
. - Локальный контекст уничтожается. Все переменные, которые были объявлены в локальном контексте выполнения, удаляются и они больше не доступны. Вот почему они называются локальными переменными.
Очень простой пример
Прежде чем мы перейдем к замыканиям, давайте рассмотрим следующий фрагмент кода. Он покажется очень простым, любой, кто читает эту статью, наверняка точно поймет, что он делает.
1: let a = 3 2: function addTwo(x) { 3: let ret = x + 2 4: return ret 5: } 6: let b = addTwo(a) 7: console.log(b)
Чтобы понять, как на самом деле работает движок JavaScript, давайте разберем этот пример подробно.
- В строке 1 объявляется новая переменная
a
в глобальном контексте выполнения и ей присваивается значение 3 - Далее становится чуть сложнее. В строках со 2 по 5 объявляется новая переменная с именем
addTwo
в глобальном контексте выполнения. И что ей назначается? Определение функции. Все, что находится между двумя скобками,{ }
присваиваетсяaddTwo
. Код внутри функции на данный момент не выполняется, он просто сохраняется в переменной для будущего использования. - Итак, теперь мы находимся на строке 6. Тут объявляется новая переменная
b
в глобальном контексте. Как только переменная объявлена, она имеет значениеundefined
. - Далее, все еще в строке 6, мы видим оператор присваивания. Далее идет вызов функции. Когда вы видите переменную, за которой следуют круглые скобки
(…)
, это сигнал о том, что это вызов функции. Забегая вперед, заметим что каждая функция возвращает что-либо (значение, объект илиundefined
). Все, что возвращается из функции, будет присвоено переменнойb
. - Но сначала нам нужно вызвать функцию с именем
addTwo
. Что бы ее запустить JavaScript нужно найти переменную с именемaddTwo
в глобальном контексте. Он ее найдет, так как мы ее объявили в строке 2 (или строки 2–5). Обратите внимание, что переменнаяa
передается в качестве аргумента функции. JavaScript так же будет искать переменнуюa
в глобальном контекста, и после того как, найдет ее, передаст число 3 в качестве аргумента функции. Теперь все готово что бы выполнить функцию. - Далее произойдет переключения контекста выполнения. Вначале создается новый локальный контекст. Затем он помещается в стек вызовов. Что в первую очередь должно быть выполнено в локальном контексте?
- У вас может возникнуть соблазн сказать: «В локально контексте будет объявлена новая переменная
ret
. Это не совсем верный ответ. Правильный ответ: сначала объявляются параметры функции. То есть в нашем случае объявляется новая переменнаяx
в локальном контексте. И поскольку значение 3 было передано в качестве аргумента, переменнойx
присваивается значение 3. - Следующий шаг: новая переменная
ret
объявляется в локальном контексте. Ее значение устанавливается как undefined. (строка 3) - Все еще на строка 3, сначала нам нужно значение переменной
x
. JavaScript начинает искать переменнуюx
. В начале она будет искаться в локальном контексте. И сразу же там будет найдена со значение 3. А вторым операндом будет число 2. Результат сложения (5) присваивается переменной ret. - Строка 4. Возвращается содержимое переменной ret. Еще один поиск в локальном контексте. ret содержит значение 5. Функция возвращает число 5. И на этом шаге функция завершится.
- Строки 4–5. Функция завершится. Локальный контекст будет уничтожен. Переменные x и ret так же уничтожаются. Контекст извлекается из стека вызовов, а возвращаемое значение возвращается в вызывающий контекст. В этом случае вызывающий контекст будет глобальным контекстом выполнения, потому что функция
addTwo
была вызвана из глобального контекста. - Мы все еще на шестой строчке. Возвращаемое значение (число 5) присваивается переменной b.
- В строке 7 содержимое переменной b выводится на консоль. В нашем примере это число 5.
Это было очень длинное объяснение очень простой программы, и мы еще даже не коснулись замыканий. Мы доберемся до них чуть позже, я обещаю. Но сначала нам нужно коснуться еще одного или двух пояснений.
Лексический контекст.
Далее я бы хотел коснуться некоторых аспектов лексического контекста. Посмотрите на следующий пример.
1: let val1 = 2 2: function multiplyThis(n) { 3: let ret = n * val1 4: return ret 5: } 6: let multiplied = multiplyThis(6) 7: console.log('example of scope:', multiplied)
Идея в том, что у нас есть переменные в локальном контексте и переменные в глобальном контексте. Сложность для JavaScript состоит в поиске переменных. Если JavaScript не может найти переменную в локальном контексте выполнения, она будет искать ее в вызывающем контексте, если она не найдет ее там далее она будет искать переменную в глобальном контексте. (И если JavaScript не найдет ее там, переменная станет undefined
). Рассмотрим пример выше. Если вы понимаете, как работает поиск, вы можете пропустить рассмотрения примера.
- Объявление новой переменной
val1
в глобальном контексте и назначение ей значение2
. - Строки 2–5. Объявление новой переменной
multiplyThis
и назначение ей определение функции. - Строка 6. Объявление новой переменной
multiplied
в глобальном контексте. - Получение переменной
multiplyThis
из глобального контекста и выполнение функции. Передача значения6
в качестве аргумента. - Вызов функции = новый контекст . Создается новый локальный контекст.
- В локальном контексте объявляется переменная
n
и ей назначается значение 6. - Строка 3. В локальном контексте объявляется переменная
ret
. - Строка 3 (продолжение). Выполняется умножение с двумя операндами; переменными
n
иval1
. Переменнаяn
берется из локального контекста. Мы объявили ее в строке 6. Ее значение6
. Ищется переменнаяval1
в локальном контексте. В локальном контексте ее нет. Далее ищется в вызываемом контексте. В данном случае вызываемый контекст является глобальным. И там она находится. Она была определена в строке 1. Ее значение равно2
. - Строка 3 (продолжение). Умножаются два операнда и результат назначается переменной
ret
. 6 * 2 = 12. Значениеret
в данный момент12
. - Далее из функции возвращается значение
ret
. Локальный контекст уничтожается вместе с переменнымиret
иn
. Переменнаяval1
не уничтожается, так как она находится в глобальном контексте. - Строка 6. В вызывающем контексте переменной
multiplied
назначается значение12
. - И наконец строка 7, значение переменной
multiplied
отображается в консоле.
Таким образом, в этом примере мы продемонстрировали, что функция имеет доступ к переменным, которые определены в контексте вызова. Формальное название этого явления — лексическая область видимости (lexical scope).
А что если функция возвращает функцию
В первом примере функция addTwo
возвращает номер. Мы знаем что функция может вернуть что угодно. Давайте рассмотрим пример когда функция возвращает функцию.
1: let val = 7 2: function createAdder() { 3: function addNumbers(a, b) { 4: let ret = a + b 5: return ret 6: } 7: return addNumbers 8: } 9: let adder = createAdder() 10: let sum = adder(val, 8) 11: console.log('example of function returning a function: ', sum)
- Строка 1. Объявлена переменная
val
в глобальном контексте и ей назначили значение7
. - Строки 2–8. Объявлена переменная
createAdder
в глобальном контексте и ей назначили определение функции. Строки с 3 по 7 определяют тело функции. Как и ранее на этом шаге сама функция не запускается. Только сохраняется ее определение в переменнойcreateAdder
. - Строка 9. Объявляется новая переменная с именем
adder
, в глобальном контексте. Временно ее значение в текущий моментundefined
. - Все еще строка 9. Так как мы видим скобки
()
; мы запускаем функцию на выполнение. В глобальном контексте ищется переменнаяcreateAdder
. Она была определена в строке 2. Далее запускается функцию. - Строка 2. Создается новый локальные контекст. Движок JavaScript добавляет новый контекст в стек выполнения.
- Строки 3–6. Объявляется новая функция. В локальном контексте создается новая переменная
addNumbers
. Это важно!addNumbers
существует только в локальном контексте. Далее определение функции сохраняется в локальной переменнойaddNumbers
. - Строка 7. Возвращается содержимое переменной
addNumbers
. В ней находится определение функции (строки с 4 по 5). Далее удаляется локальный контекст из стека вызовов. - Удаляется локальный контекст и переменная
addNumbers
более не существует. Однако определение функции все еще существует, он возвращается из функции и назначается переменнойadder
; это переменная было создана в строке 3. - Строка 10. Определяется новая переменная
sum
в глобальном контексте. И ей временно назначается значениеundefined
. - Далее нам нужно выполнить функцию которая была определена в переменной
adder
. Движок ищет и находит ее в глобальном контексте. У этой функции есть два параметра. - Давайте разберемся с параметрами. Первый переменная
val
определена в строке 1, ее значение7
, вторая переменная просто значение8
. - Далее идет выполнение функции. Функция определена в строках 3–5. Вначале создается новый локальный контекст. Внутри локального контекста создается две новые переменные:
a
иb
. У них значения7
и8
соотвественно. - Строка 4. В новом локальном контексте объявляется новая переменная
ret
. - Строка 4. Выполняется сложение переменных
a
иb
. Результат сложения (15
) назначается переменнойret
. - Переменная
ret
возвращается из функции. Локальный контекст уничтожается и удаляется из стека выполнения, переменныеa
,b
иret
более не существует. - Возвращаемое значение назначается переменной
sum
определенной в строке 9. - Значение переменной
sum
выводится в консоле.
Как и ожидалось, в консоли отобразиться 15. Этим примером я пытаюсь проиллюстрировать несколько моментов. Во-первых, определение функции может быть сохранено в переменной, определение функции невидимо для программы, пока функция не будет вызвано. Во-вторых, каждый раз, когда вызывается функция, создается (временно) локальный контекст выполнения. После завершения функции этот контекст исчезает. Функция завершает свое выполнение, когда в коде встречается return
или соответствующую закрывающую скобку}.
Ну и наконец, замыкание
Посмотрим на следующий пример и попытайтесь понять, что здесь происходит.
1: function createCounter() { 2: let counter = 0 3: const myFunction = function() { 4: counter = counter + 1 5: return counter 6: } 7: return myFunction 8: } 9: const increment = createCounter() 10: const c1 = increment() 11: const c2 = increment() 12: const c3 = increment() 13: console.log('example increment', c1, c2, c3)
Теперь, когда мы познакомились с предыдущими двумя примерами, давайте рассмотрим выполнение этого.
- Строки 1–8. Создается новая переменная
createCounter
в глобальном контексте и ей назначается определение функции. - Строка 9. В глобальном контексте объявляется новая переменная
increment
. - Строка 9. Вызывается функция
createCounter
и ее возвращаемое значение назначается переменнойincrement
. - Строки 1–8. Вызов функции. Создается новый локальный контекст.
- Строка 2. Внутри локального контекста, объявляется новая переменная
counter
. Ей назначается значение0
. - Строки 3–6. В локальном контексте объявляется новая переменная
myFunction
. Значение переменной объявление еще одной функции (строки 4 и 5). - Строка 7. Возвращается содержимое переменной
myFunction
. Локальный контекст удаляется. ПеременныеmyFunction
иcounter
больше не существуют. Контроль передается в вызывающий контекст. - Строка 9. Вызывающий контекст в данном случае является глобальным контекстом, значение возвращаемое
createCounter
назначаетсяincrement
. Сейчас в этой переменной определение функции. И оно больше не помечено именемmyFunction
, внутри глобального контекста оно названоincrement
. - Строка 10. Объявляется новая переменная (
c1
). - Строка 10. Ищется переменная
increment
, после того как оно будет найдено и определено что это функция, она будет запущена. - Создается новый контекст. Так как у функции нет параметров перейдем к ее выполнению.
- Строка 4.
counter = counter + 1
. Начинается поискcounter
в локальном контексте. Мы только что его создали и в нем пока еще ничего нет. Далее ищется в глобальном контексте. Но там тоже нет переменнойcounter
. Javascript будет рассматривать это выражение какcounter = undefined + 1
, далее объявит новую локальную переменнуюcounter
и назначит ей значение1
, так какundefined
воспринимается как0
. - Строка 5. Далее возвращается переменная
counter
, а точнее ее значение1
. Уничтожается локальный контекст и переменнаяcounter
. - Строка 10. Возвращаемое значение (
1
) назначается переменнойc1
. - Строка 11. Мы повторяем строки 10–14,
c2
получает1
. - Строка 12. Снова повторяются строки 10–14,
c3
получает1
. - Строка 13. Выводятся переменные
c1
,c2
иc3
.
Попробуйте сами запустить этот пример и посмотрите, что получится. Вы заметите, что на самом деле выводится не 1
, 1
, и 1
как мы только что рассмотрели выше. Вместо этого выводится 1
, 2
и 3
. Так что здесь происходит?
Каким-то образом функция increment запоминает значение счетчика.
Переменная counter
находится в глобальном контексте ? Попытайтесь выполнить console.log(counter)
и вы получите counter is not defined. Так что нет, не находится.
Может быть, когда мы вызываем increment
, каким-то образом он возвращается к функции, в которой он был создан (createCounter
)? Как это вообще работает? Переменная increment
содержит определение функции, а не то откуда она вызвалась.
Так что должен быть другой механизм. И он называется Замыкание Мы наконец дошли до этого!
Вот как это работает. Всякий раз, когда вы объявляете новую функцию и присваиваете ее переменной, вы сохраняете определение функции, а также замыкание. Замыкание содержит все переменные, которые находятся в области действия на момент создания функции. Это аналог рюкзака. Определение функции идет как бы с «небольшим рюкзаком». И в своем «рюкзаке» оно хранит все переменные, которые находились в области действия (scope) на момент создания определения функции.
Итак, наше объяснение выше было неверным, давайте попробуем еще раз, но на этот раз правильно.
1: function createCounter() { 2: let counter = 0 3: const myFunction = function() { 4: counter = counter + 1 5: return counter 6: } 7: return myFunction 8: } 9: const increment = createCounter() 10: const c1 = increment() 11: const c2 = increment() 12: const c3 = increment() 13: console.log('example increment', c1, c2, c3)
- Строки 1–8. В глобальном контексте создается новая переменная
createCounter
и ей назначается определение функции. - Строка 9. В глобальном контексте объявляется новая переменная
increment
. - Строка 9. Вызывается функция
createCounter
и ее возвращаемое значение передается переменнойincrement
. - Строки 1–8 . Вызов функции. Создается новый локальный контекст.
- Строка 2. Внутри локального контекста, объявляется новая переменная
counter
. Ее назначается значение0
. - Строка 3–6. В локальном контексте объявляется новая переменная
myFunction
. Переменная содержит объявление еще одной функции строки 4 и 5. Сейчас так же создается замыкание и включается в определение функции. Замыкание содержит переменные, находящиеся в области видимости, в данном случае переменнаяcounter
(со значением0
). - Строка 7. Возвращается содержимое переменной
myFunction
. Локальный контекст удаляется. ПеременныеmyFunction
иcounter
перестают существовать. Контроль передается в вызывающий контекст. Таким образом, мы возвращаем определение функции и ее замыкание, «рюкзак» с переменными, которые находились в области видимости в момент ее создания. - Строка 9. В вызывающем контексте, то есть в глобальном возвращаемое значение функцией
createCounter
передается в переменнуюincrement
. Переменная increment сейчас содержит определение функции (и замыкание). - Строка 10. Объявляется переменная
c1
. - Строка 10. Ищется переменная
increment
, после того как она будет найдена будет определенно ее содержимое. Она содержит определение функции в строках 4–5. (а так же содержит «рюкзак» с переменными) - Создается новый контекст выполнения. У функции нет параметров поэтому сразу переходим к ее выполнению.
- Строка 4.
counter = counter + 1
. Ищется переменнаяcounter
. Прежде чем начнется поиск в локальном и глобальном контексте давай те посмотрим что в нашем «рюкзаке». Точнее посмотрим что в замыкании. А вот замыкание содержит переменнуюcounter
, со значением0
. Поэтому выражению в строке 4, назначается значение1
. И это значение сохраняется в «рюкзаке». Замыкание сейчас содержит переменнуюcounter
со значением1
. - Строка 5. Возвращается содержимое переменной
counter
, а точнее ее значение1
. Далее уничтожается локальный контекст. - Строка 10. Возвращаемое значение (
1
) назначается переменнойc1
. - Строка 11. Повторяются строки 10–14. На этот раз, когда мы смотрим на наше замыкание, мы видим, что переменная
counter
имеет значение 1. Она была установлена в строке 12 или строке 4 программы. Его значение увеличивается и сохраняется со значением2
при закрытии функции increment. И переменнаяc2
получает значение2
. - Строка 12. Повторяются строки 10–14,
c3
получает значение3
. - Строка 13. Выводится значение переменных
c1
,c2
иc3
.
Итак, теперь мы понимаем, как это работает. Необходимо помнить, что когда функция объявляется, она содержит определение функции и замыкание. Так что такое замыкание?
Замыкание — это коллекция всех переменных в области видимости во время создания функции.
Вы можете спросить, любая функция имеет замыкание, даже если она создается в глобальной области видимости? Ответ — да. Функции, созданные в глобальной области видимости, так же создают замыкание. Но так как эти функции были созданы в глобальной области, они имеют доступ ко всем переменным в глобальной области. И концепция замыкания для них не очень актуальна.
Концепция замыканий становится актуальной, когда функция возвращает функцию. Возвращенная функция имеет доступ к переменным, которых нет в глобальной области видимости, но они существуют только в ее замыкание.
Не самые обычные замыкания
Иногда замыкания появляются, когда вы даже не замечаете этого. Возможно, вы видели похожий пример:
let c = 4 const addX = x => n => n + x const addThree = addX(3) let d = addThree(c) console.log('example partial application', d)
В случае, если стрелочные функции сбивает вас с толку, напишем эквивалент.
let c = 4 function addX(x) { return function(n) { return n + x } } const addThree = addX(3) let d = addThree(c) console.log('example partial application', d)
Мы объявляем функцию сумматора addX
, которая принимает один параметр (x
) и возвращает другую функцию.
Возвращаемая функция также принимает один параметр и добавляет его к переменной x
.
Переменная x
является частью замыкания. Когда переменная addThree
объявляется в локальном контексте, ей назначается определение функции и замыкание. Замыкание содержит переменную x
.
Так что теперь, когда addThree
вызывается и выполняется, она имеет доступ к переменной x
из своего замыкания и переменной n
, которая была передана в качестве аргумента.
В этом примере в консоль будет выведено число 7.
Заключение
Аналогия о которой я всегда буду помнить в случае замыканий — это аналогия с рюкзаком. Когда функция создается и передается или возвращается из другой функции, она несет с собой этот самый «рюкзак». И в этом «рюкзаке» находятся все переменные, которые были в области видимости, когда функция была объявлена.
Статья написана на основе I never understood JavaScript closures
могу сказать одно. counter никогда не будет undefined в данном примере замыкания.
Вы правы. Спасибо. Поправил перевод.
Для меня до сих пор непонятно одно — почему counter с каждым вызовом функции увеличивается. Почему let counter = 0 не сбрасывает при каждом вызове функции в 0?
То-есть этот «рюкзак» с переменными постоянно где-то храниться и изменяется?
еще интересно, что рюкзак-то общий на все вложенные, вот так это видно:
function createCounter() {
var numberOfCalls = 0;
window.a1=function() {
return ++numberOfCalls;
}
window.a2=function() {
return —numberOfCalls;
}
}
createCounter();
alert (a1()); //1
alert (a1()); //2
alert (a1()); //3
alert (a2()); //2
alert (a2()); //1