Пока мне не объяснили их вот так…
Как понятно из названия, замыкания JavaScript всегда были для меня немного таинственны. Я прочитал множество статей, я использовал замыкания в своей работе, иногда я использовал замыкания даже не осознавая, что я их использовал.
И только совсем недавно у меня был разговор, в котором мне, наконец, объяснили эту тему должным образом. Я попробую использовать такой же подход для объяснения в этой статье. Также хотелось бы отдать дань уважения CodeSmith и их серии Javasript The Hard Parts.
Перед тем, как начать
Перед тем как начать изучать замыкания, вам нужно очень хорошо понимать и другие вещи. Одна из них — это область выполнения.
В этой статье есть хороший учебник об области выполнения. Цитата из этой статьи:
При выполнении кода в JavaScript очень важна область его выполнения и ситуация может быть одна из следующих:
Глобальный код — обычное окружение, в котором ваш код выполняется изначально.
Код функции — всякий раз, когда поток выполнения попадает в тело функции.
(…)
(…), давайте думать о термине области выполнения, как об окружении, в котором работает рассматриваемый код.
Другими словами, когда мы запускаем свой код, то он выполняется в глобальной области выполнения. Некоторые переменные объявлены внутри глобальной области выполнения. Что же происходит, когда программа выполняет функцию? Несколько шагов:
- JavaScript создаёт новую область выполнения — локальную область выполнения.
- Локальная область выполнения имеет свой набор переменных, которые будут локальными для этой области выполнения.
- Новая область выполнения передаётся в стек выполнения. Думайте о стеке выполнения, как о механизме слежения за ходом исполнения программы.
Когда же функция заканчивается? Когда она встречает return
или закрывающую скобкуt }
. Когда функция заканчивается, происходит следующее:
- Локальные области выполнения выходят из стека выполнения.
- Функция отправляет return-значение обратно в область вызова. Область вызова — это область, которая вызвала эту функцию. Это может быть другая локальная область выполнения или глобальная. Что делать со значением return будет разбираться вызвавшая область выполнения. Возвращаемым значением может быть объект, массив, функция, значение истинности, да и вообще что угодно. Если у функции отсутствует
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, мы видим оператор присваивания. Мы готовимся присвоить переменной
b
новое значение. Далее мы видим, как вызывается функция. Когда вы видите переменную, за которой следуют круглые скобки(…)
— это сигнал о том, что была вызвана функция. Забегая вперёд мы знаем, что каждая функция что-то возвращает (будь то значение, объект илиundefined
). Что бы не вернула функция, это будет присвоено переменнойb
. - Но для начала нам нужно вызвать функцию
addTwo
. JavaScript начнёт искать в памяти глобальной области выполнения переменнуюaddTwo
. Ох, он нашёл одну. Она описана во втором шаге (на строках 2-5) и содержит описание функции. Заметьте, что переменнаяa
используется как аргумент для этой функции. JavaScript начнёт искать переменнуюa
в памяти глобальной области выполнения, найдёт её значение (3) и использует его как аргумент функции. Функция готова к выполнению. - Теперь область выполнения сменится. Новая локальная область выполнения создана. Назовём её «область выполнения addTwo». Область выполнения закидывается в стек вызова. Какая первая вещь, которую мы делаем в локальной области выполнения?
- Вы можете попробовать ответить: «Новая переменная
ret
объявлена в локальной области выполнения». Это неправильный ответ. Правильный ответ следующий: сперва нам нужно посмотреть на параметры функции. Новая переменнаяx
объявлена в локальной области выполнения. И поскольку аргументом послужило значение3
, то переменной х присвоено значение3
. - Следующим шагом в локальной области выполнения объявляется переменная
ret
. Значением переменной будетundefined
. (строка 3) - Всё ещё строка 3. Необходимо сложить значения. Сначала нам нужно значение
x
. JavaScript начнёт искать значениеx
. Сначала он будет смотреть в локальной области выполнения. В итоге он найдёт значение3
. Вторым операндом будет значение2
. Результат сложения (5
) присваивается переменнойret
. - Строка 4. Мы возвращаем значение переменной
ret
. Ещё один взгляд на локальную область выполнения.ret
содержит значение5
. Функция возвращает значение5
. Функция заканчивается. - Строки 4-5. Функция заканчивается. Локальная область выполнения разрушается. Переменные
x
иret
стёрты. Они больше не существуют. Область выскакивает из стека вызова и возвращаемое значение возвращается к вызывающей области. В этом примере вызывающая область это глобальная область выполнения, так как функцияaddTwo
была вызвана из глобальной области выполнения. - Теперь вы возвращаемся к тому месту, где мы были в пункте 4. Возвращаемое значение (число
5
) присваивается переменнойb
. Мы всё ещё на строке 6 нашей маленькой программы. - Я не буду вдаваться в подробности, но в строке 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 в том, как он ищет переменные. Если он не может найти переменную в локальной области переменной, то он будет пытаться найти в вызвавшей её области. И если не найдёт в этой вызвавшей области, то будет искать в вызвавшей уже эту область области. И так до тех пор, пока не дойдёт до глобальной области выполнения. (а если не найдёт и там, то значением будет undefined
). Далее я подробнее объясню пример выше, чтобы прояснить это. Если вы понимаете, как работают области видимости, то можете пропустить.
- Объявление новой переменной
vall
в глобальной области выполнения и присваивание ей значения2
. - Строки 2-5. Объявление новой переменной
multiplyThis
и присваивание ей описания функции. - Строка 6. Объявление новой переменной
multiplied
в глобальной области выполнения. - Извлечение переменной
multiplyThis
из памяти глобальной области выполнения и выполнение её как функции. Использование значения6
как аргумента. - Новый вызов функции = новая область выполнения. Создание новой локальной области выполнения.
- В локальной области выполнения объявление переменной
n
и присваивание значения6
. - Строка 3. В локальной области выполнения объявление переменной
ret
. - Строка 3 (продолжение). Выполнение всех умножений с двумя операндами, значениями переменных
n
иvall
. Поиск переменнойn
в локальной области выполнения. Мы объявили её в шестом шаге. Её содержимое6
. Поиск переменнойvall
в локальной области выполнения. Локальная область выполнения не содержит переменной с названиемvall
. Давайте проверим вызвавшую область. Вызвавшая область — это глобальная область выполнения. Давайте поищемvall
в глобальной области выполнения. Ну вот, она нашлась. Это было описано в первом шаге. Её значение2
. - Строка 3 (продолжение). Умножить два операнда и присвоить результат переменной
ret
. 6 * 2 = 12. Теперь значениеret
12
. - Возвращение переменной
ret
. Локальна область выполнения разрушена вместе с переменнымиret
иn
. Переменнаяvall
не разрушена, так как является частью глобальной области выполнения. - Назад к строке 6. В области вызова число
12
присвоено переменнойmultiplied
. - Наконец строка 7. Мы отображаем значение переменной
multiplied
в консоли.
Итак, в этом примере нам нужно запомнить то, что у функции есть доступ к переменным в вызвавшей её области. Формальное название такого феномена — область видимости.
Функция, которая возвращает функцию
В первом примере функция 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. Новая локальная область выполнения создана. Мы можем создавать локальные переменные в новой области выполнения. Движок добавляет новую область стеку вызова. У функции нет аргументов, поэтому движемся сразу к её телу.
- Всё ещё строки 3-6. У нас объявление новой функции. Мы создаём переменную
addNumbers
в локальной области выполнения. Это важно.addNumbers
существуют только в локальной области выполнения. Мы храним определение функции в локальной переменнойaddNumbers
. - Теперь мы на строке 7. Мы возвращаем содержимое переменной
addNumbers
. Движок начинает искать переменнуюaddNumbers
и находит. Это определение функции. Поэтому мы возвращаем определение функции дляaddNumbers
. Всё, что находится между скобок на строках 4 и 5 создаёт определение функции. Мы также убираем локальную область выполнения из стека вызова. - После
return
локальная область выполнения разрушается.addNumbers
больше не существует. Тем не менее, описание функции всё ещё существует, так как оно было получено из функции и присвоено переменнойadder
. Это переменная, которая мы создали в третьем шаге. - Теперь мы на строке 10. Мы определяем новую переменную
sum
в глобальной области выполнения. Временное значениеundefined
. - Теперь нам нужно выполнить функцию. Какую функцию? Функцию, которая хранится в переменной
adder
. Мы ищем её в глобальной области выполнения и, конечно же, находим. Эта функция берёт два параметра. - Давайте извлечём два этих параметра, чтобы вызвать функцию и передать правильные аргументы. Первый параметр — это переменная
val
, которую мы определили в первом шаге. Её значение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
присваиваетсяcounter
. - Строки 3-6. Объявляем новую переменную
myFunction
. Эта переменная объявлена в локальной области выполнения. Пока что контентом этой переменной является описание другой функции. Эта функция описана в строках 4 и 5. - Строка 7. Возвращаем содержимое переменной
myFunction
. Локальная область выполнения удалена.myFunction
иcounter
больше не существуют. Управление возвращено вызвавшей области. - Строка 9. В вызвавшей области, глобальной области выполнения, значение, возвращаемое функцией
createCounter
присвоено переменнойincrement
. Переменнаяincrement
теперь содержит определение функции. Определение функции, которое было возвращено изcreateCounter
. Она больше не называетсяmyFunction,
но имеет то же определение. В глобальной области она называетсяincrement
. - Строка 10. Объявление новой переменной
c1
. - Строка 10 (продолжение). Смотрим на переменную
increment
. Это функция. Вызываем её. Она содержит определение функции, которое было возвращено ранее и было описано в строках 4-5. - Создаём новую область выполнения. Без параметров. Начинаем выполнение функции.
- Строка 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
. Как так вышло?
Каким-то образом инкремент-функция запоминает значение counter
. Как это работает?
Разве counter
часть глобальной области выполнения? Если вы попробуете написать console.log(counter)
, то получите значение undefined
. Значит этот вариант неверен.
Может быть, когда вы вызываете increment
, то каким-то образом оно возвращается к той функции, где было создано (createCounter
)? Как это вообще может работать? Переменная increment
содержит описание функции, а не место, в котором она описана. А значит это тоже неверно.
А значит должен быть должен быть иной механизм. Замыкание. Мы наконец нашли его, потерянный кусочек пазла.
Вот как это работает. Когда вы объявляете новую функцию и присваиваете её переменной, то в этой переменной вы храните не только определение функции, но и её замыкание. Замыкание содержит все переменные, которые находятся в области видимости во время создания функции. Это аналогично рюкзаку. Определение функции идёт вместе с маленьким рюкзаком. А хранятся в нём все переменные, которые были в поле видимости в то время, когда функция создавалась.
Это значит, что все наши объяснения выше были неверны. Давайте попробуем ещё раз, но теперь более верно.
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
присваиваетсяcounter
. Так же, как и раньше. - Строки 3-6. Объявляем новую переменную
myFunction
. Эта переменная объявлена в локальной области выполнения. Пока что контентом этой переменной является описание другой функции. Эта функция описана в строках 4 и 5. Но также мы создаём замыкание, которое является частью функции. Замыкание хранит переменные из своей области видимости. В нашем случае это переменная КАУНТЕР (значение которой 0). - Строка 7. Возвращаем содержимое переменной
myFunction
. Локальная область выполнения удалена.myFunction
иcounter
больше не существуют. Управление возвращено вызвавшей области. Таким образом мы возвращаем описание функции и её замыкание, рюкзак с переменными, которые были в области видимости во время её создания. - Строка 9. В вызвавшей области, глобальной области выполнения, значение, возвращаемое функцией
createCounter
присвоено переменнойincrement
. Переменнаяincrement
теперь содержит определение функции (и замыкание). Определение функции, которое было возвращено изcreateCounter
. Она больше не называетсяmyFunction,
но имеет то же определение. В глобальной области она называется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
в замыкании инкремент-функции. Таким образом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
.
Заключение
Способ, с помощью которого я навсегда запомнил замыкания — это сравнение их с рюкзаком. Когда функция создана и передаётся куда-либо, или возвращается из другой функции, то она носит с собой рюкзак. А в этом рюкзаке хранятся все переменные, которые были в области видимости во время создания этой функции.
Перевод статьи Olivier De Meulder: I never understood JavaScript closures