Пока мне не объяснили их вот так…
Как понятно из названия, замыкания 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. Теперь значениеret12. - Возвращение переменной
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





