Я никогда не понимал замыкания в JavaScript. Часть первая
Nuances of programmingПеревод статьи Olivier De Meulder: I never understood JavaScript closures
Пока мне не объяснили их вот так...
Как понятно из названия, замыкания 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
или закрывающую фигурную скобку }
.
На этом конец первой части. Во второй мы перейдём непосредственно к самим замыканиям и их примерам.
Статью перевёл Дмитрий Хирш