Я раньше никогда не понимал замыкания в JavaScript …

Spread the love
  • 2
    Shares

Что такое замыкание и как оно работает? Очередной достаточно частый вопрос на собеседованиях. Я всегда думал что я знаю что ответить на этот вопрос. И всегда отвечал что типа такого… Замыкание это когда функция низшего порядка получается доступе к переменным функции высшего порядка. Но знать приблизительное определение и реально разбираться в замыкание не совсем одно и тоже. Давайте рассмотрим простейший пример:

function createCounter() {
   let counter = 0
   const myFunction = function() {
     counter = counter + 1
     return counter
   }
   return myFunction
 }
const increment = createCounter()
const c1 = increment()
const c2 = increment()
const c3 = increment()
console.log('example increment', c1, c2, c3)

Как по вашему что должно быть выведено в консоль? Если вы точно знаете ответ, то можете пропустить эту статью. А если думаете что 1,1,1 или если у вас возникло какое либо сомнение, рекомендую продолжить чтение далее….

Статья получилась большой, с длинными последовательностями описания маленьких примеров. Но если вас это не смутит и вы пройдетесь по этим описаниям, в награду получите более полное понимание того как работает замыкание.


До того как мы начнем

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

Это статья хорошо объясняет что такое Контекст исполнения (Execution Context). Основные понятия из статьи:

Когда код выполняется, среда, в которой он выполняется, очень важна и определяется как то так:

Глобальный код (Global code —  Среда по умолчанию, где код выполняется в первый раз.

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

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


Другими словами, когда запускается программа, ее код начинается исполнятся в глобальном контексте. Как правило некоторое количество переменных объявляются в глобальном контексте выполнения. Мы называем их глобальными переменными. А что происходит когда программа вызывает функцию? Вот список шагов:

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

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

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

  1. Объявление новой переменной val1 в глобальном контексте и назначение ей значение 2.
  2. Строки 2–5. Объявление новой переменной multiplyThis и назначение ей определение функции.
  3. Строка 6. Объявление новой переменной multiplied в глобальном контексте.
  4. Получение переменной multiplyThis  из глобального контекста и выполнение функции. Передача значения  в качестве аргумента.
  5. Вызов функции = новый контекст . Создается новый локальный контекст.
  6. В локальном контексте объявляется переменная n и ей назначается значение 6.
  7. Строка 3. В локальном контексте объявляется переменная ret.
  8. Строка 3 (продолжение). Выполняется умножение с двумя операндами; переменными n и val1. Переменная n берется из локального контекста. Мы объявили ее в строке 6. Ее значение 6. Ищется переменная val1 в локальном контексте. В локальном контексте ее нет. Далее ищется в вызываемом контексте. В данном случае вызываемый контекст является глобальным. И там она находится. Она была определена в строке 1. Ее значение равно 2.
  9. Строка 3 (продолжение). Умножаются два операнда и результат назначается переменной ret. 6 * 2 = 12. Значение ret в данный момент 12.
  10. Далее из функции возвращается значение ret. Локальный контекст уничтожается вместе с переменными ret и n. Переменная val1 не уничтожается, так как она находится в глобальном контексте.
  11. Строка 6. В вызывающем контексте переменной  multiplied назначается значение 12.
  12. И наконец строка 7, значение переменной multiplied отображается в консоле.

Таким образом, в этом примере мы продемонстрировали, что функция имеет доступ к переменным, которые определены в контексте вызова. Формальное название этого явления – лексическая область видимости (lexical scope).

А что если функция возвращает функцию

В первом примере функция 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. Она была определена в строке 2. Далее запускается функцию.
  5. Строка 2. Создается новый локальные контекст. Движок JavaScript добавляет новый контекст в стек выполнения.
  6. Строки 3–6. Объявляется новая функция. В локальном контексте создается новая переменная addNumbers. Это важно! addNumbers существует только в локальном контексте. Далее определение функции сохраняется в локальной переменной  addNumbers.
  7. Строка 7. Возвращается содержимое переменной addNumbers. В ней находится определение функции (строки с 4 по 5). Далее удаляется локальный контекст из стека вызовов.
  8. Удаляется локальный контекст и переменная addNumbers более не существует. Однако определение функции все еще существует, он возвращается из функции и назначается переменной adder; это переменная было создана в строке 3.
  9. Строка 10. Определяется новая переменная sum в глобальном контексте. И ей временно назначается значение undefined.
  10. Далее нам нужно выполнить функцию которая была определена в переменной adder. Движок ищет и находит ее в глобальном контексте. У этой функции есть два параметра.
  11. Давайте разберемся с параметрами. Первый переменная val определена в строке 1, ее значение 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.
  6. Строки 3–6. В локальном контексте объявляется новая переменная myFunction. Значение переменной объявление еще одной функции (строки 4 и 5).
  7. Строка 7. Возвращается содержимое переменной myFunction. Локальный контекст удаляется. Переменные myFunction и counter больше не существуют. Контроль передается в вызывающий контекст.
  8. Строка 9. Вызывающий контекст в данном случае является глобальным контекстом, значение возвращаемое createCounter назначается increment. Сейчас в этой переменной определение функции. И оно больше не помечено именем myFunction, внутри глобального контекста оно названо increment.
  9. Строка 10. Объявляется новая переменная (c1).
  10. Строка 10. Ищется переменная increment, после того как оно будет найдено и определено что это функция, она будет запущена.
  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. Так что здесь происходит?

Каким-то образом функция increment запоминает значение счетчика.

Переменная counter находится в глобальном контексте ? Попытайтесь выполнить console.log(counter)и вы получите undefined.

Может быть, когда мы вызываем increment, каким-то образом он возвращается к функции, в которой он был создан (createCounter)? Как это вообще работает? Переменная increment содержит определение функции, а не то откуда она вызвалась.

Так что должен быть другой механизм. И он называется Замыкание Мы наконец дошли до этого!

Вот как это работает. Всякий раз, когда вы объявляете новую функцию и присваиваете ее переменной, вы сохраняете определение функции, а также замыкание. Замыкание содержит все переменные, которые находятся в области действия на момент создания функции. Это аналог рюкзака. Определение функции идет как бы с “небольшим рюкзаком”. И в своем “рюкзаке” оно хранит все переменные, которые находились в области действия (scope) на момент создания определения функции.

Итак, наше объяснение выше было неверным, давайте попробуем еще раз, но на этот раз правильно.

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.
  6. Строка 3–6. В локальном контексте объявляется новая переменная myFunction. Переменная содержит объявление еще одной функции строки 4 и 5. Сейчас так же создается замыкание и включается в определение функции. Замыкание содержит переменные, находящиеся в области видимости, в данном случае переменная  counter  (со значением  0).
  7. Строка 7. Возвращается содержимое переменной myFunction. Локальный контекст удаляется. Переменные myFunction и counter перестают существовать. Контроль передается в вызывающий контекст. Таким образом, мы возвращаем определение функции и ее замыкание, “рюкзак” с переменными, которые находились в области видимости в момент ее создания.
  8. Строка 9. В вызывающем контексте, то есть в глобальном возвращаемое значение функцией createCounter передается в переменную increment. Переменная 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  при закрытии функции increment. И переменная  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.

Заключение

Аналогия о которой я всегда буду помнить в случае замыканий – это аналогия с рюкзаком. Когда функция создается и передается или возвращается из другой функции, она несет с собой этот самый “рюкзак”. И в этом “рюкзаке” находятся все переменные, которые были в области видимости, когда функция была объявлена.

Статья написана на основе I never understood JavaScript closures


Spread the love
  • 2
    Shares

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *