Пока мне не объяснили их вот так…

Как понятно из названия, замыкания JavaScript всегда были для меня немного таинственны. Я прочитал множество статей, я использовал замыкания в своей работе, иногда я использовал замыкания даже не осознавая, что я их использовал.

И только совсем недавно у меня был разговор, в котором мне, наконец, объяснили эту тему должным образом. Я попробую использовать такой же подход для объяснения в этой статье. Также хотелось бы отдать дань уважения CodeSmith и их серии Javasript The Hard Parts.

Перед тем, как начать

Перед тем как начать изучать замыкания, вам нужно очень хорошо понимать и другие вещи. Одна из них — это область выполнения.

В этой статье есть хороший учебник об области выполнения. Цитата из этой статьи:

При выполнении кода в JavaScript очень важна область его выполнения и ситуация может быть одна из следующих:

Глобальный код — обычное окружение, в котором ваш код выполняется изначально.

Код функции — всякий раз, когда поток выполнения попадает в тело функции.

(…)

(…), давайте думать о термине области выполнения, как об окружении, в котором работает рассматриваемый код.

Другими словами, когда мы запускаем свой код, то он выполняется в глобальной области выполнения. Некоторые переменные объявлены внутри глобальной области выполнения. Что же происходит, когда программа выполняет функцию? Несколько шагов:

  1. JavaScript создаёт новую область выполнения — локальную область выполнения.
  2. Локальная область выполнения имеет свой набор переменных, которые будут локальными для этой области выполнения.
  3. Новая область выполнения передаётся в стек выполнения. Думайте о стеке выполнения, как о механизме слежения за ходом исполнения программы.

Когда же функция заканчивается? Когда она встречает return или закрывающую скобкуt }. Когда функция заканчивается, происходит следующее:

  1. Локальные области выполнения выходят из стека выполнения.
  2. Функция отправляет return-значение обратно в область вызова. Область вызова — это область, которая вызвала эту функцию. Это может быть другая локальная область выполнения или глобальная. Что делать со значением return будет разбираться вызвавшая область выполнения. Возвращаемым значением может быть объект, массив, функция, значение истинности, да и вообще что угодно. Если у функции отсутствует return, то вернётся undefined.
  3. Локальная область выполнения разрушается. Это важно. Разрушается. Все переменные, которые были объявлены внутри локальной области выполнения стираются. Они больше не доступны. Поэтому они называются локальными переменными.

Простой базовый пример

Перед тем, как перейти к замыканиям, давайте взглянем на следующий кусок кода. Он выглядит достаточно просто. Любой читатель этой статьи, скорее всего, должен понять, что он делает.

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. В строке 1 мы объявляем переменную a в глобальной области выполнения и присваиваем ей значение 3.
  2. Дальше всё становится похитрее. Строки с 2 по 5 идут вместе. Что здесь происходит? Мы объявляем новую переменную addTwo в глобальной области выполнения. Всё, что находится между двумя кавычками { }  присваивается addTwo. Код внутри функции не оценивается, не выполняется, а просто хранится в переменной для дальнейшего использования.
  3. Теперь мы на строке 6. Она выглядит просто, но всё немного не так, как кажется на первый взгляд. Сначала мы объявляем новую переменную в глобальной области выполнения и называем её b. Как только переменная объявлена, её значение undefined.
  4. Далее, всё ещё на строке 6, мы видим оператор присваивания. Мы готовимся присвоить переменной b новое значение. Далее мы видим, как вызывается функция. Когда вы видите переменную, за которой следуют круглые скобки (…) — это сигнал о том, что была вызвана функция. Забегая вперёд мы знаем, что каждая функция что-то возвращает (будь то значение, объект или undefined). Что бы не вернула функция, это будет присвоено переменной b.
  5. Но для начала нам нужно вызвать функцию addTwo. JavaScript начнёт искать в памяти глобальной области выполнения переменную addTwo. Ох, он нашёл одну. Она описана во втором шаге (на строках 2-5) и содержит описание функции. Заметьте, что переменная a используется как аргумент для этой функции. JavaScript начнёт искать переменную a в памяти глобальной области выполнения, найдёт её значение (3) и использует его как аргумент функции. Функция готова к выполнению.
  6. Теперь область выполнения сменится. Новая локальная область выполнения создана. Назовём её «область выполнения addTwo». Область выполнения закидывается в стек вызова. Какая первая вещь, которую мы делаем в локальной области выполнения?
  7. Вы можете попробовать ответить: «Новая переменная ret объявлена в локальной области выполнения». Это неправильный ответ. Правильный ответ следующий: сперва нам нужно посмотреть на параметры функции. Новая переменная x объявлена в локальной области выполнения. И поскольку аргументом послужило значение 3, то переменной х присвоено значение 3.
  8. Следующим шагом в локальной области выполнения объявляется переменная ret. Значением переменной будет  undefined. (строка 3)
  9. Всё ещё строка 3. Необходимо сложить значения. Сначала нам нужно значение x. JavaScript начнёт искать значение x. Сначала он будет смотреть в локальной области выполнения. В итоге он найдёт значение 3. Вторым операндом будет значение 2. Результат сложения (5) присваивается переменной ret.
  10. Строка 4. Мы возвращаем значение переменной ret. Ещё один взгляд на локальную область выполнения. ret содержит значение 5. Функция возвращает значение 5. Функция заканчивается.
  11. Строки 4-5. Функция заканчивается. Локальная область выполнения разрушается. Переменные x и ret стёрты. Они больше не существуют. Область выскакивает из стека вызова и возвращаемое значение возвращается к вызывающей области. В этом примере вызывающая область это глобальная область выполнения, так как функция addTwo была вызвана из глобальной области выполнения.
  12. Теперь вы возвращаемся к тому месту, где мы были в пункте 4. Возвращаемое значение (число 5) присваивается переменной b. Мы всё ещё на строке 6 нашей маленькой программы.
  13. Я не буду вдаваться в подробности, но в строке 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). Далее я подробнее объясню пример выше, чтобы прояснить это. Если вы понимаете, как работают области видимости, то можете пропустить.

  1. Объявление новой переменной vall  в глобальной области выполнения и присваивание ей значения 2.
  2. Строки 2-5. Объявление новой переменной multiplyThis и присваивание ей описания функции.
  3. Строка 6. Объявление новой переменной multiplied в глобальной области выполнения.
  4. Извлечение переменной multiplyThis из памяти глобальной области выполнения и выполнение её как функции. Использование значения 6 как аргумента.
  5. Новый вызов функции = новая область выполнения. Создание новой локальной области выполнения.
  6. В локальной области выполнения объявление переменной n и присваивание значения 6.
  7. Строка 3. В локальной области выполнения объявление переменной  ret.
  8. Строка 3 (продолжение). Выполнение всех умножений с двумя операндами, значениями переменных n и vall. Поиск переменной n в локальной области выполнения. Мы объявили её в шестом шаге. Её содержимое 6. Поиск переменной vall в локальной области выполнения. Локальная область выполнения не содержит переменной с названием vall. Давайте проверим вызвавшую область. Вызвавшая область — это глобальная область выполнения. Давайте поищем vall в глобальной области выполнения. Ну вот, она нашлась. Это было описано в первом шаге. Её значение 2.
  9. Строка 3 (продолжение). Умножить два операнда и присвоить результат переменной  ret. 6 * 2 = 12. Теперь значение ret 12.
  10. Возвращение переменной ret. Локальна область выполнения разрушена вместе с переменными ret и n. Переменная vall не разрушена, так как является частью глобальной области выполнения.
  11. Назад к строке 6. В области вызова число 12 присвоено переменной multiplied.
  12. Наконец строка 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. Строка 1. Мы объявляем переменную val в глобальной области выполнения и присваиваем ей число 7.
  2. Строки 2-8. Мы объявляем переменную с названием createAdder в глобальной области выполнения и присваиваем ей описание функции. Строки 3-7 описывают функцию. Как и до этого, пока что мы не будем выполнять эту функцию. Мы просто храним её описание в этой переменной (createAdder).
  3. Строка 9. Мы объявляем новую переменную adder в глобальной области выполнения. Временно значение этой функции будет undefined.
  4. Всё ещё строка 9. Мы видим скобки (), а значит нам надо вызвать или выполнить функцию. Давайте запросим память глобальной области выполнения и поищем переменную createAdder. Она была создана во втором шаге. Отлично, вызываем.
  5. Вызываем функцию. Теперь мы на строке 2. Новая локальная область выполнения создана. Мы можем создавать локальные переменные в новой области выполнения. Движок добавляет новую область стеку вызова. У функции нет аргументов, поэтому движемся сразу к её телу.
  6. Всё ещё строки 3-6. У нас объявление новой функции. Мы создаём переменную addNumbers в локальной области выполнения. Это важно.  addNumbers существуют только в локальной области выполнения. Мы храним определение функции в локальной переменной addNumbers.
  7. Теперь мы на строке 7. Мы возвращаем содержимое переменной addNumbers. Движок начинает искать переменную addNumbers и находит. Это определение функции. Поэтому мы возвращаем определение функции для addNumbers. Всё, что находится между скобок на строках 4 и 5 создаёт определение функции. Мы также убираем локальную область выполнения из стека вызова.
  8. После return локальная область выполнения разрушается. addNumbers больше не существует. Тем не менее, описание функции всё ещё существует, так как оно было получено из функции и присвоено переменной adder. Это переменная, которая мы создали в третьем шаге.
  9. Теперь мы на строке 10. Мы определяем новую переменную sum в глобальной области выполнения. Временное значение undefined.
  10. Теперь нам нужно выполнить функцию. Какую функцию? Функцию, которая хранится в переменной adder. Мы ищем её в глобальной области выполнения и, конечно же, находим. Эта функция берёт два параметра.
  11. Давайте извлечём два этих параметра, чтобы вызвать функцию и передать правильные аргументы. Первый параметр — это переменная val, которую мы определили в первом шаге. Её значение 7, а значение второго параметра 8.
  12. Теперь нам нужно выполнить функцию. Определение функции описано в строках 3-5. Новая локальная область выполнения создана. Внутри локальной области создано две новых переменных, a и b. Им соответствуют значения 7 и 8, как аргументы, которые мы передали функции в предыдущем шаге.
  13. Строка 4. Объявлена новая переменная ret. Она объявлена в локальной области выполнения.
  14. Строка 4. Сложение выполнено. Мы сложили значения переменной a и b. Результат (15)  присвоен переменной ret.
  15. Функция возвращает переменную ret. Локальная область выполнения разрушена, убрана из стека вызова, переменные ab и ret больше не существуют.
  16. Возвращённое значение присвоено переменной sum, которую мы определили в шаге 9.
  17. Мы выводим значение переменной 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. Строки 1-8. Мы создаём новую переменную createCounter в глобальной области выполнения и присваиваем ей описание функции.
  2. Строка 9. Мы объявляем новую переменную increment в глобальной области выполнения.
  3. Снова строка 9. Нам нужно вызвать функцию createCounter и присвоить возвращённое ей значение переменной increment.
  4. Строки 1-8. Вызываем функцию. Создаём новую локальную область выполнения.
  5. Строка 2. В локальной области выполнения объявляем новую переменную counter. Число 0 присваивается counter.
  6. Строки 3-6. Объявляем новую переменную myFunction. Эта переменная объявлена в локальной области выполнения. Пока что контентом этой переменной является описание другой функции. Эта функция описана в строках 4 и 5.
  7. Строка 7. Возвращаем содержимое переменной myFunction. Локальная область выполнения удалена. myFunction и counter больше не существуют. Управление возвращено вызвавшей области.
  8. Строка 9. В вызвавшей области, глобальной области выполнения, значение, возвращаемое функцией createCounter присвоено переменной increment. Переменная  increment теперь содержит определение функции. Определение функции, которое было возвращено из createCounter. Она больше не называется myFunction, но имеет то же определение. В глобальной области она называется increment.
  9. Строка 10. Объявление новой переменной c1.
  10. Строка 10 (продолжение). Смотрим на переменную increment. Это функция. Вызываем её. Она содержит определение функции, которое было возвращено ранее и было описано в строках 4-5.
  11. Создаём новую область выполнения. Без параметров. Начинаем выполнение функции.
  12. Строка 4. counter = counter + 1. Ищем переменнуюcounter в локальной области выполнения. Мы только что создали эту область выполнения и ещё не создавали никаких переменных. Давайте посмотрим в глобальной области выполнения. Никакой переменной с названием counter не найдено. JavaScript будет обрабатывать это как counter = undefined + 1, объявив при этом новую переменную с названием counter, и присвоив ей значение 1, так как undefined это что-то вроде 0.
  13. Строка 5. Мы возвращаем значение переменной counter, а именно число 1. Мы уничтожаем локальную область выполнения вместе с переменной counter.
  14. Возвращаемся к строке 10. Возвращённое значение (1) присвоено c1.
  15. Строка 11. Мы повторяем шаги 10-14, c2 также получает значение 1.
  16. Строка 12. Мы повторяем шаги 10-14, c3 также получает значение 1.
  17. Строка 13. Мы выводим в консоль значения переменных c1c2 и c3.

Попробуйте сами и посмотрите, что произойдёт. Вы заметите, что в консоль не попадают значения 11 и 1, как вы можете ожидать после объяснений выше. Вместо этого в логи попадают 12и 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. Строки 1-8. Мы создаём новую переменную createCounter в глобальной области выполнения и присваиваем ей описание функции. Так же, как и раньше.
  2. Строка 9. Мы объявляем новую переменную increment в глобальной области выполнения. Так же, как и раньше.
  3. Снова строка 9. Нам нужно вызвать функцию createCounter и присвоить возвращённое ей значение переменной increment. Так же, как и раньше.
  4. Строки 1-8. Вызываем функцию. Создаём новую локальную область выполнения. Так же, как и раньше.
  5. Строка 2. В локальной области выполнения объявляем новую переменную counter. Число 0 присваивается counter. Так же, как и раньше.
  6. Строки 3-6. Объявляем новую переменную myFunction. Эта переменная объявлена в локальной области выполнения. Пока что контентом этой переменной является описание другой функции. Эта функция описана в строках 4 и 5. Но также мы создаём замыкание, которое является частью функции. Замыкание хранит переменные из своей области видимости. В нашем случае это переменная КАУНТЕР (значение которой 0).
  7. Строка 7. Возвращаем содержимое переменной myFunction. Локальная область выполнения удалена. myFunction и counter больше не существуют. Управление возвращено вызвавшей области. Таким образом мы возвращаем описание функции и её замыкание, рюкзак с переменными, которые были в области видимости во время её создания.
  8. Строка 9. В вызвавшей области, глобальной области выполнения, значение, возвращаемое функцией createCounter присвоено переменной increment. Переменная  increment теперь содержит определение функции (и замыкание). Определение функции, которое было возвращено из createCounter. Она больше не называется myFunction, но имеет то же определение. В глобальной области она называется increment.
  9. Строка 10. Объявление новой переменной c1.
  10. Строка 10 (продолжение). Смотрим на переменную increment. Это функция. Вызываем её. Она содержит определение функции, которое было возвращено ранее и было описано в строках 4-5 (в которой также хранится и рюкзак с переменными).
  11. Создаём новую область выполнения. Без параметров. Начинаем выполнение функции.
  12. Строка 4. counter = counter + 1. Ищем переменную counter. Перед тем, как поискать в локальной или глобальной области выполнения, давайте посмотрим в нашем рюкзаке. Проверяем замыкание. Оказывается, замыкание содержит переменную counter со значением 0. После выражения на строке 4 её значение установлено в 1. И она снова хранится в рюкзаке. Теперь замыкание хранит переменную counter со значением 1.
  13. Строка 5. Мы возвращаем значение переменной counter, а именно число 1. Мы уничтожаем локальную область выполнения.
  14. Возвращаемся к строке 10. Возвращённое значение (1) присвоено c1.
  15. Строка 11. Мы повторяем шаги 10-14. В этот раз, когда мы посмотрим в нашем замыкании, то увидим, что переменная counter хранит значение 1. Оно было задано в 12-ом шаге, или на 4-ой строке программы. Это значение было увеличено и сохранено как 2 в замыкании инкремент-функции. Таким образом c2 присваивается 2.
  16. Строка 12. Мы повторяем шаги 10-14, c3 получает значение 3.
  17. Строка 13. Мы выводим в консоль значения переменных c1c2 и 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

Предыдущая статьяДелаем Node.js быстрым: инструменты, техники и советы для создания эффективных серверов на Node.js Часть вторая
Следующая статьяДелаем Node.js быстрым: инструменты, техники и советы для создания эффективных серверов на Node.js Часть третья