Понимаем замыкания в JavaScript. Раз и навсегда

Тема измученная, старая, но всё равно в ней часто делаются ошибки. На самом деле замыкания могут показаться запутанными с первого взгляда, но по факту тут нет ничего сложного. В этой статье собраны переводы двух статей. Первая это теория, которая показывает то, что находится под капотом, а вторая это довольно интересные примеры для закрепления материала.

Переводы статей Understanding Closures in JavaScript и Making Sense Of JavaScript’s Closure With Some Examples

Замыкания это фундаментальная концепция JavaScript, которую должен понимать каждый разработчик пишущий на нем. Да, она сбивает с толку многих новичков в JS.

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

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

Давайте уже начнём.

Что такое замыкание?

Замыкание это функция у которой есть доступ к своей внешней функции по области видимости, даже после того, как внешняя функция прекратилась. Это говорит о том, что замыкание может запоминать и получать доступ к переменным, и аргументам своей внешней функции, даже после того, как та прекратит выполнение.

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

Что такое лексическая область видимости?

Лексическая область видимости это статическая область в JavaScript, имеющая прямое отношение к доступу к переменным, функциям и объектам, основываясь на их расположении в коде. Вот пример:

let a = 'global';
function outer() {
let b = 'outer';
function inner() {
let c = 'inner'
console.log(c); // выедет 'inner'
console.log(b); // выедет 'outer'
console.log(a); // выедет 'global'
}
console.log(a); // выедет 'global'
console.log(b); // выедет 'outer'
inner();
}
outer();
console.log(a); // выедет 'global'

Тут функция inner имеет доступ к переменным в своей области видимости, в области видимости функции outer и глобальной области видимости. Функция outer имеет доступ к переменным, объявленным в собственной области видимости и глобальной области видимости.

В общем, цепочка области видимости выше будет такой:

Global {
outer {
inner
}
}

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

Практические примеры замыкания

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

Пример 1:

function person() {
let name = 'Peter';

return function displayName() {
console.log(name);
};
}
let peter = person();
peter(); // выведет 'Peter'

В этом примере мы вызываем функцию person, которая возвращает внутреннюю функцию displayName и сохраняет эту внутреннюю функцию в переменную peter. Когда мы вызываем функцию peter (которая на самом деле ссылается к функции displayName), имя “Peter” выводится в консоль.

Но у нас же нет никакой переменной с именем name в displayName, так что эта функция как-то может получить доступ к переменной своей внешней функции person, даже после того, как та функция выполнится. Так что, функция displayName это ни что иное как замыкание.

Пример 2:

function getCounter() {
let counter = 0;
return function() {
return counter++;
}
}
let count = getCounter();
console.log(count());  // 0
console.log(count()); // 1
console.log(count()); // 2

И снова, мы храним анонимную внутреннюю функцию, возвращенную функцией getCounter в переменной count. Так как функция сount теперь замыкание, она может получать доступ к переменной counter в функции getCounter, даже после того, как та завершится.

Но обратите внимание, что значение counter не сбрасывается до 0 при каждом вызове count, как вроде бы она должна делать.

Так происходит, потому что при каждом вызове count(), создаётся новая область видимости, но есть только одна область видимости, созданная для getCounter, так как переменная counter объявлена в области видимости getCounter(), она увеличится при каждом вызове функции count, вместо того, чтобы сброситься до 0.

Как работают замыкания?

До этого момента мы обсуждали то, чем являются замыкания и их практические примеры. Сейчас давайте поймём то, как замыкания на самом деле работают в JavaScript.

Чтобы реально это понять, нам надо разобраться в двумя самыми важными концепциями в JavaScript, а именно, 1) Контекст выполнения и 2) Лексическое окружение.

Контекст выполнения

Это абстрактная среда, в которой JavaScript код оценивается и выполняется. Когда выполняется “глобальный” код, он выполняется внутри глобального контекста выполнения, а код функции выполняется внутри контекста выполнения функции.

Тут может быть только один запущенный контекст выполнения (JavaScript это однопоточный язык), который управляется стеком запросов.

Стек выполнения это стек с принципом LIFO (Последний вошёл, первый вышел), в котором элементы могут быть добавлены или удалены только сверху стека.

Запущенный контекст выполнения будет всегда сверху стека и когда запущенная функция завершится, её контекст выполнения выкинется из стека, запустив контекст выполнения, который стоит ниже в очереди.

Давайте посмотрим на пример кода, чтобы лучше понять контекст выполнения и стек:

Во время выполнения этого кода, движок JavaScript создаёт глобальный контекст вызова, для того, чтобы выполнить глобальный код и когда он доходит до вызова функции first(), он создаёт новый контекст выполнения для этой функции и ставит её на вершину стека вызовов.

Так что он будет выглядеть таким образом для кода выше:

Когда функция first() завершится, её стек выполнения удалится и начнется выполнение кода ниже. Так что оставшийся код в глобальной области видимости будет выполнен.

Лексическое окружение

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

Лексическое окружение это структура данных, которая хранит информацию по идентификаторам переменных. Тут идентификатор обозначает имя переменных/функций, а переменная настоящий объект[включая тип функции] или примитивное значение.

У лексического окружения есть два компонента: (1) запись в окружении и (2) отсылка к внешнему окружению.

  1. Запись в окружении(environment record) это место хранятся объявления переменной или функции.

2. Отсылка к внешнему окружению (reference to the outer environment) означает то, что у него есть доступ к внешнему (родительскому) лексическому окружению. Этот компонент самый важный для понимания того, как работают замыкания.

Лексическое окружение на самом деле выглядит так:

lexicalEnvironment = {
environmentRecord: {
<identifier> : <value>,
<identifier> : <value>
}
outer: < Reference to the parent lexical environment>
}

Теперь снова, давайте посмотрим на пример кода выше:

let a = 'Hello World!';
function first() {
let b = 25;
console.log('Inside first function');
}
first();
console.log('Inside global execution context');

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

globalLexicalEnvironment = {
environmentRecord: {
a : 'Hello World!',
first : < reference to function object >
}
outer: null
}

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

Когда движок создаёт контекст выполнения для функции first(), он также создаёт лексическое окружение для хранения переменных, объявленных в этой функции во время выполнения. Таким образом, лексическое окружение функции будет выглядеть вот так:

functionLexicalEnvironment = {
environmentRecord: {
b : 25,
}
outer: <globalLexicalEnvironment>
}

Внешнее лексическое окружение функции указывается в глобальном лексическом окружении, потому что функция окружена глобальной областью видимости в исходном коде.

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

А теперь детально о примерах замыканий

Теперь, когда мы поняли контекст выполнения и лексическое окружение, давайте вернёмся к замыканиям.

Пример 1:

function person() {
let name = 'Peter';

return function displayName() {
console.log(name);
};
}
let peter = person();
peter(); // prints 'Peter'

Когда выполняется функция person, JavaScript создаёт новый контекст выполнения и лексическое окружение для функции. После того, как эта функция завершится, она вернёт displayName функцию и назначится на переменную peter.

Таким образом, её лексическое окружение будет выглядеть так:

personLexicalEnvironment = {
environmentRecord: {
name : 'Peter',
displayName: < displayName function reference>
}
outer: <globalLexicalEnvironment>
}

Когда функция person завершится, её контекст выполнения выкинется из стека. Но её лексическое окружение всё ещё останется в памяти, так как на него ссылается лексическое окружение его внутренней функции displayName. Таким образом, её переменные всё ещё будут доступны в памяти.

При выполнении функции peter (которая на самом деле является отсылкой к функции displayName), JavaScript создаёт новый контекст выполнения и лексическое окружение для этой функции.

Так что его лексическое окружение будет выглядеть таким образом:

displayNameLexicalEnvironment = {
environmentRecord: {

}
outer: <personLexicalEnvironment>
}

В функции displayName нет переменной, её запись окружения будет пуста. Во время выполнения этой функции, JavaScript будет пытаться найти переменную name в лексическом окружении функции.

Так как там нет переменных в лексическом окружении функции displayName, она будет искать во внешнем лексическом окружении, то есть, лексическом окружении функции person, которое до сих пор в памяти. JavaScript найдёт эту переменную и name выводится в консоль.

Пример 2:

function getCounter() {
let counter = 0;
return function() {
return counter++;
}
}
let count = getCounter();
console.log(count());  // 0
console.log(count()); // 1
console.log(count()); // 2

Снова, лексическое окружение для функции getCounter будет выглядеть таким образом:

getCounterLexicalEnvironment = {
environmentRecord: {
counter: 0,
<anonymous function> : < reference to function>
}
outer: <globalLexicalEnvironment>
}

Эта функция возвращает анонимную функцию и назначает её на переменную count.

Когда функция count выполняется, её лексическое окружение будет выглядеть таким образом:

countLexicalEnvironment = {
environmentRecord: {

}
outer: <getCountLexicalEnvironment>
}

Когда функция count вызывается, JavaScript начнет поиск в лексическом окружении этой функции на наличие переменной counter. Снова, если ее запись окружения пуста, то движок пойдёт искать во внешнем лексическом окружении функции.

Движок находит переменную, выводит её в консоль и увеличивает переменную counter в лексическом окружении getCounter функции.

Таким образом, лексическое окружение для функции getCounter после первого вызова функции count будет выглядеть таким образом:

getCounterLexicalEnvironment = {
environmentRecord: {
counter: 1,
<anonymous function> : < reference to function>
}
outer: <globalLexicalEnvironment>
}

На каждом вызове функции count, JavaScript создаёт новое лексическое окружение для функции count, увеличивает переменную count и обновляет лексическое окружения функции getCounter, чтобы соответствовать изменениям.

Закрепляем понимание замыканий в JavaScript на примерах

Замыкания это такая вещь в JavaScript, что сколько бы вы про неё не читали, всё равно полностью не поймете. Как и с многим в программировании, вам нужно покрутить-повертеть какие-нибудь примеры, чтобы полностью принять и понять нужную концепцию.

Теперь давайте посмотрим на некоторые примеры того, как же используются замыкания и пройдёмся по процессу создания вашего приложения, где мы воспользуемся преимуществами вышеупомянутых замыканий.

Поехали!

Снова, что такое замыкание?

Мне очень нравится определение из Secrets of the JavaScript:

Замыкание это способ получения доступа и управления внешними переменными из функции.

Мне нравится рассматривать замыкания как функцию языка программирования, которая позволяет нам делать крутые вещи, такие как:

Объектно-ориентированное программирование в JavaScript:

И рекурсия:

Давайте попробуем кое-что на основании функции, которую мы создали в примере с рекурсией:

Пока что всё идёт хорошо…

Ох, что же произошло?

Смотрим и разбираемся:

incrementUntil может вызывать себя по принципу отсылки к себе же в замыканиях. Это собственно и делает рекурсии возможными в JavaScript.

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

Хоть incrementUntil выполняется внутри myFun2, он не изменяет num в области видимости myFun2, так как incrementUntil воспринимает только num, который находится внутри его родительской области видимости, там где функция была изначально создана. В общем, он не воспринимает num внутри myFun2, потому что он не получает доступ к области видимости вызывающей функции.

Вот, как мы можем пофиксить myFun2, чтобы он мог применить incrementUntil функцию, для изменения num, внутри своей же области видимости.

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

Отрефакторенная функция incrementUntil не читает и не изменяет ничего вне своей области видимости, так что это чистая функция, иллюстрирующая основной принцип функционального программирования. Можно сказать, что в функции происходит следующее: incrementUntil(max)(num), которая ”прибавляет до max, начиная с num”.

Реально удобно в частичном применении то, что мы можем частично выполнить последнее решение, давая нашей функции набор нужных аргументов. Затем мы можем сохранить промежуточное решение в переменную, что сохранит нам немного времени, устранив нужду в выполнении того же промежуточного решения каждый раз, как мы вызовем функцию, это избавит нас повторений.

Вот, что имелось ввиду:

В коде выше вы создаёте замыкание, чтобы сохранить значение переданное функции multThenAdd, даже после того, как внутренняя функция сработает. Внутренняя функция, которая возвращается, создана внутри внешней функции, создавая замыкание.

Следующий код равноценен коду выше, но в ES6:

var multThenAdd = num => mul => add => num * mul + add

Всё круто и элегантно, но синтаксис мог бы показаться реально странным для приверженцев императивного программирования.

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

Частичное применение это просто паттерн разработки, где ваша функция возвращает другую функцию, которая берет аргумент. Для примера, вы можете вызывать свою функцию так: myFun(arg1)(arg2), что было бы эквивалентно:

const myFun2 = myFun(arg1)
myFun2(arg2)

Частичное применение это мощный паттерн, потому что вы можете продолжать делать цепочки, такие как myFun(arg1)(arg2)(arg3)… То, что тут происходит похоже на сборочный конвейер завода: один аргумент за раз применяется к myFun, чтобы по аккуратно частям собрать все аргументы, с которыми и получится финальное решение.

Вот кусок кода, который написан для приложения на Node/Express, которое использует этот паттерн: