Это перевод статьи Притти Кассириди »Let’s Learn JavaScript Closures».
Замыкания являются ключевой особенностью в JavaScript, которую каждый уважающий себя программист должен знать.
В интернете есть множество объяснений того, что из себя представляют замыкания, но мало кто рассказывает, почему они такие.
Разработчикам понимание принципов работы тех или иных особенностей языка, даст больше осознанности в их применении, поэтому этот пост посвящён внутреннему устройству замыканий: как они работают и почему.
Надеюсь, после этой статьи вы будете лучше подготовлены к использованию замыканий в своей повседневной разработке. Давайте начнём.
Что такое замыкания?
Замыкания являются мощным инструментом в JavaScript и других языках программирования. Вот определение с MDN:
Замыкания — это функции, ссылающиеся на независимые (свободные) переменные. Другими словами, функция, определённая в замыкании, «запоминает» окружение, в котором она была создана.
Заметка: свободные переменные — это переменные, которые не объявлены локально и не передаются в качестве параметра.
Давайте посмотрим на несколько примеров:
Пример 1
function numberGenerator() {
// Локальная «свободная» переменная, которая доступна только в замыкании
var num = 1;
function checkNumber() {
console.log(num);
}
num++;
return checkNumber;
}
var number = numberGenerator();
number(); // 2
В примере функция numberGenerator
создаёт локальную «свободную» переменную num (число) и checkNumber (функция, которая выводит число в консоль). Функция checkNumber не содержит собственной локальной переменной, но благодаря замыканию она имеет доступ к переменным внутри внешней функции, numberGenerator. Поэтому объявленная в numberGenerator переменная num будет успешно выведена в консоль, даже после того, как numberGenerator вернёт результат выполнения.
Пример 2
В этом примере видно, что замыкания содержат в себе все локальные переменные, которые были объявлены внутри внешней замкнутой функции — enclosing function.
function sayHello() {
var say = function() { console.log(hello); }
// Локальная переменная, которая доступна только в замыкании
var hello = 'Hello, world!';
return say;
}
var sayHelloClosure = sayHello();
sayHelloClosure(); // ‘Hello, world!’
Обратите внимание, как переменная hello определяется после анонимной функции, но эта функция всё равно может получить доступ к этой переменной hello. Это происходит из-за того, что переменная hello во время создания уже была определена в области видимости (scope), тем самым сделав её доступной на тот момент, когда анонимная функция будет выполнена. Не беспокойтесь, позже я объясню, что такое «область видимости». А пока просто смиритесь с этим.
Понимаем высокий уровень
Наши примеры показали нам, почему замыкания находятся на высоком уровне. Главная мысль такая: мы имеем доступ к переменным, которые были определены в замкнутых функциях и описывают их переменные как возвращённые. Также что-то происходит в фоновом режиме, что делает эти переменные доступными после замкнутых функций, которые определяют и возвращают их.
Чтобы понять, как это работает, давайте рассмотрим несколько связанных между собой идей. Мы зайдём издалека и постепенно вернёмся к замыканиям. Начнём наш путь с общего контекста, в котором выполняется функция, и известного как контекст выполнения — execution context.
Контекст выполнения
Контекст выполнения — это абстрактное понятие, которое используется в спецификации ECMAScript для оценки времени выполнения кода. Это может быть глобальный контекст — global context, в котором ваш код выполнится первым, или когда поток выполнения переходит в тело функции.
В любой момент времени выполняется только один контекст функции (тело функции). Вот почему JavaScript является однопотоковым, так как единовременно может выполняться только одна команда. Обычно браузеры поддерживают этот контекст с помощью стека — stack. Стек — структура данных, выполняемая в обратном порядке: LIFO — «последним пришёл — первым вышел». Последнее, что вы добавили в стек, будет удалено первым из него. Это происходит из-за того, что мы можем только добавить или удалить элементы из верхушки стека. Текущий или «выполняющийся» контекст исполнения — всегда верхний элемент стека. Он выскакивает из стека, когда код в текущем контексте полностью разобран, позволяя следующему верхнему элементу стека взять на себя контекст выполнения.
Кроме того, если контекст уже выполняется, это не означает, что ему нужно завершить своё выполнение, прежде чем другой контекст выполнения сможет начать работу. Бывают случаи, когда контекст приостанавливается и другой контекст начинает работу. Прерванный контекст может быть позже забран обратно наверх в том месте, где он был приостановлен. В любое время один контекст может быть заменён другим, и этот новый контекст поместится в стек, став текущим контекстом выполнения.
Для наглядности запустите в консоли код, который вы видите ниже:
var x = 10;
function foo(a) {
var b = 20;
function bar(c) {
var d = 30;
return boop(x + a + b + c + d);
}
function boop(e) {
return e * -1;
}
return bar;
}
var moar = foo(5); // Замыкание
/*
Функция ниже выполняет функцию bar, которая была возвращена в тот момент,
когда мы выполнили функцию foo в строке выше. Функция bar
вызывает boop, в этот момент bar получает приостановку и boop перемещается
на вершину стека вызовов(call stack) (см. скриншот ниже)
*/
moar(15);
Затем, когда boop возвратится, он удалится из стека, и bar продолжит работу:
Когда у нас есть целая куча контекстов исполнения, выполняющиеся один за другим и останавливающиеся в середине выполнения и снова запускающиеся, то нужно как-то отслеживать их состояние, чтобы мы могли управлять последовательностью выполнения этих контекстов. Согласно спецификации ECMAScript, каждый контекст выполнения имеет различные компоненты, которые используются для отслеживания прогресса исполнения кода. К ним относятся:
- Оценка состояния кода — любое состояние необходимо выполнить, приостановить и возобновить определение кода, связанного с этим контекстом выполнения.
- Функция — объект функции, который оценивает контекст выполнения или null, если контекст был определён как script или модуль.
- Область — набор внутренних объектов, глобальное окружение ECMAScript, весь код ECMAScript, который находится в пределах этого глобального окружения и другие связанные с ним состояния и ресурсы.
- Лексическое окружение — используется для разрешения ссылок идентификатора кода в этом контексте исполнения.
- Переменное окружение — лексическое окружение, чья запись окружения — EnvironmentRecord имеет связи, созданные заявленными переменными — VariableStatements в этом контексте выполнения.
Не волнуйтесь, если это звучит слишком сложно. Из всех переменных, переменные лексического окружения наиболее интересны для нас, ведь они явно указывают, что принимают идентификатор ссылки кода в этом контексте выполнения. Вы можете думать о «идентификаторах» как о переменных. Так как наша первоначальная цель состояла в том, чтобы выяснить, как это возможно, что мы получаем доступ к переменным даже после того, как функция или «контекст» была возвращена, то мы должны копнуть ещё глубже в лексическую область видимости.
Заметка: c технической точки зрения, окружение переменных и лексическая область видимости используются для реализации замыканий. Но для простоты мы заменим его на «окружение». Для детального объяснения разницы между лексическим и переменным окружением читайте статью Акселя Раушмайера.
Лексическая область видимости
Дадим определение: лексическое окружение — специфичный тип, используемый для связи идентификаторов с определёнными переменными и функциями на основе лексической структуры вложенности кода ECMAScript. Лексическое окружение состоит из записи окружения и, возможно, нулевой ссылки на внешнее лексическое окружение. Обычно лексическое окружение связано с определённой синтаксической структурой, например: FunctionDeclaration — объявление функции, BlockStatement — оператор блока, Catch clause — условный оператор, TryStatement — перехват ошибок и новым лексическим окружением, которое создавалось каждый раз при разборе кода. — ECMAScript-262/6.0
Давайте разберём это.
- **«используемый для связи идентификаторов»: целью лексического окружения является управление данными, то есть идентификаторами в коде. Говоря иначе, это придаёт им смысл. Например, у нас есть такая строка в консоли: console.log (x / 10), x** здесь бессмысленная переменная или идентификатор без чего-либо, что придавало бы ей смысл. Лексическое окружение обеспечивает смысл или «ассоциацию» через запись окружения. Смотрите ниже.
- Лексическое окружение состоит из записи окружения: запись окружения — причудливый способ сказать, что она хранит записи всех идентификаторов и связей, которые существуют в лексической области видимости. Каждая лексическая область видимости имеет собственную запись окружения.
- Лексическая структура вложенности: самый интересный момент, который говорит, что внутреннее окружение ссылается на внешнее окружение, и это внешнее окружение может иметь собственное внешнее окружение. В результате окружение может быть внешним окружением для более чем одного внутреннего окружения. Глобальное окружение является единственным лексическим окружением, которое не имеет внешнего окружения. Это сложно описать словами, поэтому давайте использовать метафоры и представим лексическое окружение как слои лука: глобальная среда — внешний слой луковицы, где каждый последующий слой находится ниже.
Так выглядит окружение в псевдокоде:
LexicalEnvironment = {
EnvironmentRecord: {
// Здесь идёт привязка идентификатора
},
// Ссылка на внешнее окружение
outer: < >
};
- Новое лексическое окружение, которое создавалось каждый раз при разборе кода — каждый раз, когда вызываются внешние вложенные функции, создаётся новое лексическое окружение. Это важно, и мы вернёмся к этому моменту в конце. Примечание: функции — не единственный способ создать лексическое окружение. Другие типы содержат в себе оператор блока — block statement или условный оператор — catch clause__. Для простоты, я сосредоточусь на окружении созданной нами функции на протяжении всего поста.
Каждый контекст исполнения имеет собственное лексическое окружение. Это лексическое окружение содержит переменные и связанные с ними значения, а также имеет ссылку на него во внешнем окружении. Лексическое окружение может быть глобальным окружением, модульным окружением, которое содержит привязку для объявлений модуля на высшем уровне, или окружением функций, где окружение создаётся за счёт вызова функции.
Цепочки областей видимости
Исходя из приведённого выше определения, мы знаем, что окружение имеет доступ к окружению своего родителя, его родительское окружение имеет доступ к своему родительскому окружению и так далее. Этот набор идентификаторов, к которому каждое окружение имеет доступ, называется область видимости — scope. Мы можем вложить их в иерархические цепочки окружения, известные как цепочки областей видимости.
Давайте рассмотрим эту структуру вложенности:
var x = 10;
function foo() {
var y = 20; // свободная переменная
function bar() {
var z = 15; // свободная переменная
return x + y + z;
}
return bar;
}
Как вы можете видеть, bar вложен в foo. Чтобы всё это представить посмотрите на диаграмму ниже:
Мы вернёмся позже к этому примеру.
Эта цепочка области видимости или цепочка окружения связанная с функцией сохраняется в объекте функции в момент создания. Другими словами, она определяется статично по местоположению в исходном коде. Это называют лексической областью видимости.
Давайте быстренько разберём разницу между динамической областью видимости и статической областью видимости. Это поможет нам разобраться, почему статическая область видимости или лексическая область видимости необходима для использования замыкания.
Идём в обход: динамическая область видимости против статической области видимости
У динамических языков программирования существует стековая архитектура — stack-based implementations, локальные переменные и функции хранятся в стеке. Поэтому, во время выполнения стека, программа определяет какую переменную вы имеете в виду. С другой стороны, статическая область видимости — это когда переменные ссылаются на контекст и фиксируются на момент создания. Другими словами, структура исходного кода программы определяет к каким переменным вы обращаетесь.
Вы могли задаваться вопросом, как различаются динамическая и статическая области видимости. Вот два примера, которые помогут это продемонстрировать:
Пример 1
var x = 10;
function foo() {
var y = x + 5;
return y;
}
function bar() {
var x = 2;
return foo();
}
function main() {
foo(); // Статическая область: 15; Динамическая область: 15
bar(); // Статическая область: 15; Динамическая область: 7
return 0;
}
Как мы видим из примера выше, статическая и динамическая область видимости возвращают разные значения при вызове функции bar.
В статической области видимости возврат значения bar зависит от значения x. Это происходит из-за того, что статическая и лексическая структура исходного кода приводит x и к 10, и к 15.
Динамическая область видимости даёт нам стек определённых переменных, которые отслеживаются во время выполнения. Поэтому x, которую мы используем, зависит от того, что находится в её области видимости и как она была динамично определена во время выполнения. Выполнение функции bar выталкивает x = 2
на верхушку стека, заставляя foo вернуть 7.
Пример 2
var myVar = 100;
function foo() {
console.log(myVar);
}
foo(); // Статическая область: 100; Динамическая область: 100
(function () {
var myVar = 50;
foo(); // Статическая область: 100; Динамическая область: 50
})();
// Функция высшего порядка
(function (arg) {
var myVar = 1500;
arg(); // Статическая область: 100; Динамическая область: 1500
})(foo);
Аналогично и в динамической области видимости. Переменная myVar решает, какое использовать значение myVar в зависимости от того, где была вызвана функция. Статическая область видимости приводит myVar к переменной, которая была сохранена в рамках двух немедленно вызываемых функций при их создании.
Как вы можете заметить, динамическая область видимости часто создаёт некоторую двусмысленность. Она не даёт точно понять, какая свободная переменная будет передана.
Замыкания
Вы можете подумать, что всё, о чём мы говорим, совершенно не касается нашей темы, но на самом деле мы разобрали всё, что поможет нам понять замыкания:
Каждая функция имеет контекст выполнения, который состоит из окружения и передаёт смысл в переменные этой функции и ссылку на окружение своего родителя. Эта ссылка делает все переменные в родительской области доступными для всех внутренних функций, независимо от того, вызывается ли внутренняя функция (или функции) вне или внутри области видимости, в которой они были созданы.
Кажется, как будто функция «запоминает» это окружение, поскольку функция буквально имеет ссылку к области видимости и переменным, определённым в этой среде.
Возвратимся к примеру вложенной структуры:
var x = 10;
function foo() {
var y = 20;
function bar() {
var z = 15;
return x + y + z;
}
return bar;
}
var test = foo();
test(); // 45
Основываясь на нашем понимании того, как работает окружение, мы можем сказать, что определение окружения для вышеупомянутого примера выглядит примерно так в псевдокоде:
GlobalEnvironment = {
EnvironmentRecord: {
// встроенные идентификаторы
Array: '<func>',
Object: '<func>',
// и т.д.
// пользовательские идентификаторы
x: 10
},
outer: null
};
fooEnvironment = {
EnvironmentRecord: {
y: 20,
bar: '<func>'
}
outer: GlobalEnvironment
};
barEnvironment = {
EnvironmentRecord: {
z: 15
}
outer: fooEnvironment
};
Когда мы вызываем функцию test, мы получаем 45, и она возвращает значение из вызова функции bar (потому что foo возвращает bar). bar имеет доступ к свободной переменной y даже после того, как функция foo вернётся, так как bar имеет ссылку на y через его внешнее окружение, которое является окружением foo! bar так же имеет доступ к глобальной переменной x потому, что у окружения foo есть доступ к глобальному окружению. Это называют «поиск цепочки области видимости».
Подведём итог обсуждения динамической области видимости против статической: для замыканий, которые будут выполняться, нельзя использовать динамическую область с помощью динамического стека. Это сохранит наши переменные. Причина такого поведения кроется в том, что когда функция возвращается, переменные будут удалены из стека и больше не будут доступны, а это противоречит нашему определению замыкания. Это происходит из-за того, что замкнутость данных в родительском контексте сохраняется в так называемой «куче», и это позволяет сохранять данные после вызова функции, делая их возможными для возврата, то есть даже после того, как контекст выполнения извлекается из стека выполнения вызова.
Теперь, когда мы понимаем внутренности на абстрактном уровне, давайте рассмотрим ещё пару примеров:
Пример 1
Вот типичное заблуждение: в цикле for мы пробуем связать переменную счётчика с какой-либо функцией.
var result = [];
for (var i = 0; i < 5; i++) {
result[i] = function () {
console.log(i);
};
}
result[0](); // 5, ожидалось 0
result[1](); // 5, ожидалось 1
result[2](); // 5, ожидалось 2
result[3](); // 5, ожидалось 3
result[4](); // 5, ожидалось 4
На основе всего что было ранее, мы можем с лёгкостью найти ошибку здесь. Абстрактно говоря, вот так выглядит окружение, во время выхода из цикла for:
environment: {
EnvironmentRecord: {
result: [...],
i: 5
},
outer: null,
}
Было бы неверно предполагать, что область видимости отличается для всех пяти функций в результирующем массиве. Вместо того, что происходит по факту, окружение или контекст/область видимости является тем же самым для всех пяти функций в пределах результирующего массива. Поэтому каждый раз, когда переменная i увеличивается, обновляется область видимости, а она является общей для всех функций. Из-за этого любая из пяти функций, пытающихся получить доступ к i, возвращает 5, i равна 5, когда цикл завершается.
У нас есть только один способ исправить это — создать дополнительный вызываемый контекст для каждой функции, чтобы у них появился собственный контекст/область видимости.
var result = [];
for (var i = 0; i < 5; i++) {
result[i] = (function inner(x) {
// дополнительный вызываемый контекст
return function() {
console.log(x);
}
})(i);
}
result[0](); // 0, ожидалось 0
result[1](); // 1, ожидалось 1
result[2](); // 2, ожидалось 2
result[3](); // 3, ожидалось 3
result[4](); // 4, ожидалось 4
Ура, мы исправили это.
Есть ещё одно решение, в котором мы используем let вместо var, let находится в операторе блока и поэтому новая привязка идентификатора замыкания создаётся для каждой итерации в цикле for.
var result = [];
for (let i = 0; i < 5; i++) {
result[i] = function () {
console.log(i);
};
}
result[0](); // 0, ожидалось 0
result[1](); // 1, ожидалось 1
result[2](); // 2, ожидалось 2
result[3](); // 3, ожидалось 3
result[4](); // 4, ожидалось 4
Та-дам!
Пример 2
В этом примере мы покажем как каждый вызов в функции создаёт новое отдельное замыкание:
function iCantThinkOfAName(num, obj) {
// Это массив переменных, вместе с 2 параметрами, передаваемых
// "захваченными" в замкнутую функцию 'doSomething'
var array = [1, 2, 3];
function doSomething(i) {
num += i;
array.push(num);
console.log('num: ' + num);
console.log('array: ' + array);
console.log('obj.value: ' + obj.value);
}
return doSomething;
}
var referenceObject = { value: 10 };
var foo = iCantThinkOfAName(2, referenceObject); // замыкание #1
var bar = iCantThinkOfAName(6, referenceObject); // замыкание #2
foo(2);
/*
num: 4
array: 1,2,3,4
obj.value: 10
*/
bar(2);
/*
num: 8
array: 1,2,3,8
obj.value: 10
*/
referenceObject.value++;
foo(4);
/*
num: 8
array: 1,2,3,4,8
obj.value: 11
*/
bar(4);
/*
num: 12
array: 1,2,3,8,12
obj.value: 11
*/
Мы видим что каждый вызов в функции iCantThinkOfAName создаёт новое замыкание, а именно foo и bar. Последующие вызовы каждой замкнутой функции обновляют замкнутые переменные в пределах самого замыкания, демонстрируя, что переменные в каждом замыкании используются функции iCantThinkOfAName’s doSomething после того, как вернулась iCantThinkOfAName.
Пример 3
function mysteriousCalculator(a, b) {
var mysteriousVariable = 3;
return {
add: function() {
var result = a + b + mysteriousVariable;
return toFixedTwoPlaces(result);
},
subtract: function() {
var result = a - b - mysteriousVariable;
return toFixedTwoPlaces(result);
}
}
}
function toFixedTwoPlaces(value) {
return value.toFixed(2);
}
var myCalculator = mysteriousCalculator(10.01, 2.01);
myCalculator.add() // 15.02
myCalculator.subtract() // 5.00
Обратите внимание, что mysteriousCalculator находится в глобальной области и возвращает две функции. Говоря иначе, окружение для кода выше будет выглядеть так:
GlobalEnvironment = {
EnvironmentRecord: {
// встроенные идентификаторы
Array: '<func>',
Object: '<func>',
// и т.д.
// пользовательские идентификаторы
mysteriousCalculator: '<func>',
toFixedTwoPlaces: '<func>',
},
outer: null,
};
mysteriousCalculatorEnvironment = {
EnvironmentRecord: {
a: 10.01,
b: 2.01,
mysteriousVariable: 3,
}
outer: GlobalEnvironment,
};
addEnvironment = {
EnvironmentRecord: {
result: 15.02
}
outer: mysteriousCalculatorEnvironment,
};
subtractEnvironment = {
EnvironmentRecord: {
result: 5.00
}
outer: mysteriousCalculatorEnvironment,
};
Это происходит из-за того, что наши функции add и substract ссылаются на среду mysteriousCalculator, и они в состоянии использовать переменные этой среды для расчёта результата.
Пример 4
Последний пример продемонстрирует важность использования замыканий: для поддержания собственной ссылки на переменную во внешней области видимости.
function secretPassword() {
var password = 'xh38sk';
return {
guessPassword: function(guess) {
if (guess === password) {
return true;
} else {
return false;
}
}
}
}
var passwordGame = secretPassword();
passwordGame.guessPassword('heyisthisit?'); // false
passwordGame.guessPassword('xh38sk'); // true
Это очень мощная техника — она даёт замыкающей функции guessPassword исключительный доступ к переменной password, делая невозможным доступ к password снаружи.
Tl; dr
- Контекст выполнения — это абстрактный контекст, использовавшийся в спецификации ECMAScript для отслеживания времени выполнения кода. В любое время может быть только один контекст выполнения, который выполняет код.
- Каждый контекст исполнения имеет лексическое окружение. Оно содержит связи идентификаторов, то есть переменные и их значения и имеет ссылку на него во внешнем окружении.
- Набор идентификаторов, к которым у каждого окружения есть доступ, называют «область видимости». Мы можем вложить эти области в иерархическую цепь окружения, известной как «цепочки области видимости».
- Каждая функция имеет контекст выполнения, который включает в себя лексическое окружение. Это придаёт смысл переменным в пределах этой функции и ссылку на родительское окружение. И это означает, что функции «запоминают» окружение или область видимости, так как они буквально ссылаются на это окружение. Это и есть замыкание.
- Замыкания создаются каждый раз при вызове внешней функции. Другими словами, внутренняя функция не будет возвращена для замыкания, в котором была создана.
- Область видимости замыканий в JavaScript лексическая, её смысл определяется статично в зависимости от нахождения в исходном коде.
- Есть множество практических случаев использования замыканий. Один из важных случаев использования — это сохранение приватных ссылок к переменным во внешней среде.
Замыкающая ремарка
Я надеюсь этот пост был полезным и дал представление о том, как замыкания реализованы в JavaScript. Как вы видите, понимание внутреннего устройства замыканий и особенностей их работы делают их поиск проще. Теперь у вас будет меньше головной боли при отладке.
Дополнительная литература
Для краткости я опустила несколько тем, которые могут быть интересны для некоторых читателей. Вот несколько ссылок, которыми я хотела бы поделиться:
- Какие среды переменных бывают в контексте выполнения? Аксель Раушмайер сделал феноменальную работу, объясняющую эту тему, и я просто оставлю ссылку на его пост.
- Что является различными типами записи окружения? Читайте спецификацию здесь.
- Отличная статья на MDN про замыкания здесь.
Есть что-то ещё? Предлагайте.