Я никогда не понимал замыкания в JavaScript. Часть вторая

Я никогда не понимал замыкания в JavaScript. Часть вторая

Nuances of programming

Перевод статьи Olivier De Meulder: I never understood JavaScript closures

Предыдущие части: Часть 1

Наконец, замыкания

Взгляните на следующий код и попробуйте выяснить что произойдёт.

 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.

Заключение

Способ, с помощью которого я навсегда запомнил замыкания - это сравнение их с рюкзаком. Когда функция создана и передаётся куда-либо, или возвращается из другой функции, то она носит с собой рюкзак. А в этом рюкзаке хранятся все переменные, которые были в области видимости во время создания этой функции.


Статью перевёл Дмитрий Хирш