[Перевод] Как работают замыкания (под капотом) в JavaScript
Привет, Хабр!
Мы в Хекслете используем JavaScript не только для очевидных задач во фронтэнде, но и, например, для реализации браузерной среды разработки (наш опен-сорсный hexlet-ide) на React’е. У нас есть практический курс по JavaScript, и один из уроков там посвящен замыканиям. Это важная тема не столько в рамках JS, сколько в программировании вообще. Мы освещаем ее и в других курсах.
В целом, статей и туториалов про использование замыканий в JS полно, но объяснений как это все работает внутри — мало. Сегодняшний перевод посвящен именно этой теме. Как и почему работают замыкания в JS, когда они создаются и уничтожаются и почему каждая функция в JS — это замыкание.
Я использую замыкания уже достаточно давно. Я научился их использовать, но не до конца понимал как они на самом деле работают, что происходит «под капотом». Что это вообще такое? Википедия не очень помогает. Когда замыкание создается и уничтожается? Как выглядит реализация?
"use strict";
var myClosure = (function outerFunction() {
var hidden = 1;
return {
inc: function innerFunction() {
return hidden++;
}
};
}());
myClosure.inc(); // возвращает 1
myClosure.inc(); // возвращает 2
myClosure.inc(); // возвращает 3
// Ага, круто. А как это реализовано?
// И что происходит под капотом?
Когда я наконец все выяснил, мне захотелось поделиться со всеми. Как минимум, так я сам не забуду. Ведь
Скажи мне — и я забуду, покажи мне — и я запомню, дай мне сделать — и я пойму.
— Конфуций и Бенджамин Франклин
В процессе изучения я пытался визуализировать взаимодействия сущностей: как объекты обращаются друг к другу, как один наследуется от другого и так далее. Найти иллюстрации не удалось, поэтому я нарисовал свои.
Я допускаю, что читатель знаком с JavaScript, знает о глобальном объекте, знает о том, что функции в JS являются функциями высшего порядка, и т.д.
Цепочка областей видимости
Когда JS-код работает, ему нужно пространство для хранения локальных переменных. Давайте назовем это пространство объектом области видимости (он же LexicalEnvironment — лексическое окружение) или просто scope-объектом. Например, когда вы вызываете какую-то функцию, а она задает локальную переменную, эта переменная сохраняется в объект области видимости. Можно считать его обычным JavaScript-объектом, с одним важным отличием: к нему нельзя обратиться напрямую. Можно изменять его свойства, но нельзя обратиться к самому объекту.
Концепция такого объекта для области видимости сильно отличается от, скажем, C или C++, где локальные переменные хранятся в стеке. В JavaScript такие объекты хранятся в heap, и они могут оставаться в памяти даже после того, как функция вернула значение. Об этом поговорим позже.
Как можно было ожидать, у объекта области видимости может быть родитель. Когда код пытается обратиться к переменной, интерпретатор ищет свойство у текущего объекта области видимости. Если свойства не существует, интерпретатор двигается вверх по цепочке scope-объектов и продолжает поиски. И так далее, пока свойство не найдено или пока не кончились родители. Давайте назовем этот порядок scope-объектов «цепочкой областей видимости» или «цепочкой scope-объектов».
Этот механизм очень похож на прототипное наследование, но, опять же, есть одно важное отличие: если вы попытаетесь обратиться к несуществующему свойству обычного объекта, и в цепочке прототипов нигде нет такого свойства, то это не ошибка — просто будет возвращен undefined. Но если обратиться к несуществующему свойству в scopе-цепочке (то есть обратиться к переменной, которой не существует), то будет ошибка ReferenceError.
Последний элемент в scope-цепочке это всегда глобальный объект (Global Object). В JavaScript-коде самого высокого уровня цепочка объектов областей видимости состоит из всего одного элемента: глобального объекта. Так что когда вы создаете переменные в верхнем уровне кода, они задаются в глобальном объекте. Когда происходит вызов функции, в scope-цепочке больше одного объекта. Можно подумать, что если функция вызвана из верхнего уровня, то в scope-цепочке есть ровно два объекта области видимости, но это не всегда так. Там может быть 2 или больше объектов, это зависит от функции. Об этом тоже позже.
Верхний уровень
Хватит теории, вот пример:
my_script.js
"use strict";
var foo = 1;
var bar = 2;
Мы просто создали две переменные на верхнем уровне. Как я объяснил выше, в этом случае объект области видимости это глобальный объект:
Тут у нас есть область запуска (это мой код верхнего уровня из my_script.js), и соответствующий scope-объект. Конечно, в реальности глобальный объект содержит еще кучу стандартных и специфических для хоста штук, но мы их тут не будем показывать.
Не-вложенные функции
Взгляните на такой скрипт:
my_script.js
"use strict";
var foo = 1;
var bar = 2;
function myFunc() {
//-- определяем переменные, локальные для функции
var a = 1;
var b = 2;
var foo = 3;
console.log("inside myFunc");
}
console.log("outside");
//-- и вызываем функцию
myFunc();
Когда функция myFunc определена, идентификатор myFunc добавляется в текущий scope-объект (в данном случае — в глобальный объект), и этот идентификатор относится к функции. Как известно, функция это объект, поэтому далее когда мы говорим «функция-объект», то имеем ввиду объект, которым является функция.
Функция-объект содержит код функции и другие свойства. Одно из интересных нам свойств это внутреннее свойство [[scope]]; оно ссылается на текущий scope-объект, то есть scope-объект, который активен в момент определения функции (опять же — в данном случае это глобальный объект).
Когда мы вызываем console.log («outside»), получается такая схема:
Функция-объект, на которую ссылается переменная myFunc, хранит код функции и ссылается на scope-объект, который был актуален в момент определения функции. Это очень важно.
Когда функция вызывается, создается новый scope-объект, который хранит локальные переменные для myFunc (и значения его аргументов), и этот новый scope-объект наследует от scope-объекта, на который ссылается вызываемая функция.
Так что, при вызове myFunc схема выглядит так:
Это — цепочка scope-объектов. Если обратиться к какой-нибудь переменной внутри myFunc, JavaScript попробует найти ее в первом объекте цепочки — области видимости функции myFunc (). Если там такой переменной нет, то нужно идти выше (в данном случае выше находится глобальный объект). Если найти ничего не получается во всей цепочке — будет ошибка ReferenceError.
Например, если обратиться к a внутри myFunc, то получим 1 из первого объекта — scope-объекта myFunc (). Если обратиться к foo, мы получим 3 из того же объекта: можно сказать, он скрывает свойство foo глобального объекта. Если обратиться к bar, то получим 2 из глобального объекта. Это работает почти как прототипное наследование.
Важно помнить, что эти scope-объекты продолжают существовать пока на них есть ссылки. Когда последняя ссылка на такой объект исчезает, объект будет обработан сборщиком мусора.
После того, как myFunc () возвращает значение, ссылок на область видимости myFunc () больше нет, сборщик мусора делает свое дело и получается:
Далее я не буду включать функции-объекты в диаграммы чтобы не перегружать иллюстрации. Как вы уже знаете, цепочка выглядит так: функция → функция-объект → scope-объект.
Не забывайте об этом.
Вложенные функции
С момента, когда функция возвращает значение, больше никто не обращается к его scope-объекту, поэтому его собирает сборщик мусора. Но что если определить вложенную функцию, вызвать ее и дождаться возвращения? Вы уже знаете ответ: функция-объект всегда ссылается на scope-объект, в котором она была создана. Так что когда мы задаем вложенную функцию, она получает ссылку к текущей области видимости внешней функции. И если мы сохраним вложенную функцию в другом месте, то scope-объект не будет обработан сборщиком мусора даже когда внешняя функция вернет значение: ведь на этот scope-объект все еще есть ссылка! Взгляните на этот код:
my_script.js
"use strict";
function createCounter(initial) {
//-- переменная, локальная для функции
var counter = initial;
//-- Вложенные функции. У каждой есть
// ссылка на текущий scope-объект (объект области видимости)
/**
* Увеличивает внутренний счетчит на переданное значение.
* Если число не конечное или меньше 1 — использует 1.
*/
function increment(value) {
if (!isFinite(value) || value < 1){
value = 1;
}
counter += value;
}
/**
* Возвращает текущее значение счетчика.
*/
function get() {
return counter;
}
//-- возвращает объект, содержащий ссылки
// на вложенные функции
return {
increment: increment,
get: get
};
}
//-- создаем объект Счетчик
var myCounter = createCounter(100);
console.log(myCounter.get()); //-- выводит "100"
myCounter.increment(5);
console.log(myCounter.get()); //-- выводит "105"
При вызове createCounter (100); получается такая схема:
Заметьте, что на область видимости createCounter (100) существуют ссылки из вложенных функций increment и get. Если createCounter () не вернет ничего, то, конечно, эти внутренние ссылки на себя не будут считаться, и scope-объект будет собран сборщиком мусора. Но так как createCounter () возвращает объект, в котором есть ссылки на эти функции, получается так:
Итак, функция createCounter (100) уже вернула значение, но ее область видимости еще существует, она доступна из внутренних функций и только из них. Нет никакой возможности обратиться к области видимости createCounter (100) напрямую, можно только вызывать myCounter.increment () или myCounter.get (). У этих функций есть уникальный, частный доступ к области createCounter.
Давайте попробуем вызвать myCounter.get (). Помните — при вызове функции создается новая область видимости, и в цепочку областей видимости, которая используется для этой новой функции, добавляется новый объект. Получается так:
Первый scope-объект в цепочке функции get () это пустой scope-объект самой функции. Когда внутри get () происходит обращение к счетчику, JavaScript не может ничего найти в первом объекте цепочки, двигается к следующему объекту и использует счетчик в области видимости createCounter (100). И функция get () просто возвращает его.
Можно заметить, что объект myCounter также доступен функции myCounter.get () как 'this' (красная стрелка в диаграмме). this не является частью цепочки scope-объектов, но о нем нужно помнить. Об этом тоже позже.
Вызов increment (5) чуть более интересный, потому что здесь присутствует аргумент:
Значение аргумента хранится в scope-объекте, созданном для этого вызова. Когда функция обращается к значению переменной, JavaScript сразу находит его в первом объекте цепочки. Однако, когда функция обращается к счетчику, JavaScript не может найти его в первом объекте цепочки scope-объектов, двигается выше и находит его там. Так что increment () изменяет значение счетчика в области видимости createCounter (100). И практически ничто другое не может изменить это значение. Поэтому замыкания так важны: объект myCounter невозможно вскрыть. Замыкания хорошо подходят для хранения секретной информации.
Важно понимать, что области видимости — «живые». При вызове функции текущая цепочка не копируется для функции, а на самом деле дополняется новым объектом. И когда любой объект цепочки изменяется, это изменение сразу становится доступным всем функциям, в цепочках которых этот объект состоит. После того, как increment () изменит значение счетчика, следующий вызов get () вернет обновленное значение.
Поэтому этот известный пример на работает:
"use strict";
var elems = document.getElementsByClassName("myClass"), i;
for (i = 0; i < elems.length; i++) {
elems[i].addEventListener("click", function () {
this.innerHTML = i;
});
}
Несколько функция создаются в цикле, и все они содержат ссылку на один и тот же scope-объект. Поэтому они используют одну и ту же переменную i, а не личную копию. Подробнее об этом примере можно почитать по ссылке Don’t make functions within a loop.
Похожие функции-объекты, разные scope-объекты
А теперь давайте немного расширим наш пример и развлечемся (да, мне весело — прим. пер.). Что если создать несколько объектов счетчиков?
my_script.js
"use strict";
function createCounter(initial) {
/* ... см. код из предыдущего примера ... */
}
//-- создаем счетчики
var myCounter1 = createCounter(100);
var myCounter2 = createCounter(200);
После созданию myCounter1 и myCounter2 получается такая схема:
Не забывайте: каждая функция-объект содержит ссылку на scope-объект. В этом примере myCounter1.increment и myCounter2.increment ссылаются на функцию-объекты, которые содержат один и тот же код и одни и те же значения свойств (name, length и другие), но их [[scope]] ссылается на разные scope-объекты.
В диаграмме нет отдельных функция-объектов (для упрощения визуализации), но они все еще существуют.
Примеры:
var a, b;
a = myCounter1.get(); // a == 100
b = myCounter2.get(); // b == 200
myCounter1.increment(1);
myCounter1.increment(2);
myCounter2.increment(5);
a = myCounter1.get(); // a == 103
b = myCounter2.get(); // b == 205
Вот так это и работает. Концепция замыканий это мощь.
Цепочка scope-объектов и this
Нравится вам или нет, this не является частью цепочки scope-объектов. Значение this зависит от паттерна вызова функции. То есть можно вызывать одну и ту же функцию, но иметь разные значения this внутри.Паттерны вызовов
На эту тему стоит написать отдельную статью, так что сейчас я просто пройдусь по теме поверхностно. Есть четыре паттерна. Вот:
Method invocation pattern (вызов метода)
"use strict";
var myObj = {
myProp: 100,
myFunc: function myFunc() {
return this.myProp;
}
};
myObj.myFunc(); //-- возвращает 100
Если вызов содержит точку или [subscript], то функция вызывается как метод. В примере выше this ссылается на myObj.
Function invocation pattern (вызов функции)
"use strict";
function myFunc() {
return this;
}
myFunc(); //-- возвращает undefined
В этом случае значение this зависит от того, запущен ли код в strict mode.
- В strict mode this равен undefined
- В non-strict mode this ссылается на глобальный объект (Global Object)
В примере выше — strict mode, так что myFunc () вернет undefined.
Constructor invocation pattern (вызов конструктора)
"use strict";
function MyObj() {
this.a = 'a';
this.b = 'b';
}
var myObj = new MyObj();
Когда функция вызывается с префиксом new, JavaScript задает новый объект, который наследуется от свойства prototype указанной функции. И этот новосозданный объект передается в функцию как this.
Apply invocation pattern
"use strict";
function myFunc(myArg) {
return this.myProp + " " + myArg;
}
var result = myFunc.apply(
{ myProp: "prop" },
[ "arg" ]
);
//-- результат — "prop arg"
Можно передать любое значение как this. В этом примере для этого используется Function.prototype.apply (). Другие варианты:
- Function.prototype.call ()
- Function.prototype.bind ()
В следующих примерах в основном используется Method invocation pattern.
Использование this во вложенных функциях
"use strict";
var myObj = {
myProp: "outer-value",
createInnerObj: function createInnerObj() {
var hidden = "value-in-closure";
return {
myProp: "inner-value",
innerFunc: function innerFunc() {
return "hidden: '" + hidden + "', myProp: '" + this.myProp + "'";
}
};
}
};
var myInnerObj = myObj.createInnerObj();
console.log( myInnerObj.innerFunc() );
Вывод: hidden: 'value-in-closure', myProp: 'inner-value'
К моменту вызова myObj.createInnerObj () получается такая структура:
А к моменту вызова myInnerObj.innerFunc () — такая:
Видно, что this в myObj.createInnerObj () ссылается на myObj, но this в myInnerObj.innerFunc () ссылается на myInnerObj: обе функции вызваны как методы. Поэтому this.myProp внутри innerFunc () возвращает внутреннее значение, а не внешнее.
Можно обмануть innerFunc (), чтобы тот использовать myProp таким образом:
var myInnerObj = myObj.createInnerObj();
var fakeObject = {
myProp: "fake-inner-value",
innerFunc: myInnerObj.innerFunc
};
console.log( fakeObject.innerFunc() );
Вывод: hidden: 'value-in-closure', myProp: 'fake-inner-value'
Или с apply () или call ():
var myInnerObj = myObj.createInnerObj();
console.log(
myInnerObj.innerFunc.call(
{
myProp: "fake-inner-value-2",
}
)
);
Вывод: hidden: 'value-in-closure', myProp: 'fake-inner-value-2'
Однако, иногда внутренней функции на самом деле нужен доступ к this, который доступен во внешней функции, вне зависимости от того, как вызвана внутренняя функция. Для этого нужно специально сохранить нужное значение в замыкании (то есть в текущем scope-объекте) вот так: var self = this; и использовать self во внутренней функции вместо this.
"use strict";
var myObj = {
myProp: "outer-value",
createInnerObj: function createInnerObj() {
var self = this;
var hidden = "value-in-closure";
return {
myProp: "inner-value",
innerFunc: function innerFunc() {
return "hidden: '" + hidden + "', myProp: '" + self.myProp + "'";
}
};
}
};
var myInnerObj = myObj.createInnerObj();
console.log( myInnerObj.innerFunc() );
Вывод: hidden: 'value-in-closure', myProp: 'outer-value'
Получается так:
Теперь видно, что у innerFunc () есть доступ к значению this внешней функции, через self, который лежит в замыкании.
Заключение
Теперь мы можем ответить на те вопросы из первого абзаца.
Что такое замыкание? Это объект, связанный и с функцией-объектом и scope-объектом. На самом деле, все функции в JavaScript это замыкания: невозможно иметь ссылку на функцию-объект без scope-объекта.
Когда замыкание создается? Так как все функции в JavaScript это замыкания, ответ очевиден: когда задается функция — задается замыкание. Так что замыкание создается при определении функции. Но нужно понимать разницу между созданием замыкания и созданием нового scope-объекта: замыкание (функция + ссылка на текущую цепочку scope-объектов) создается при определении функции, но новый scope-объект создается (и используется для модификации цепочки scope-объектов замыкания) при вызове функции.
Когда замыкание уничтожается? Как любой другой объект в JavaScript, сборщик мусора обрабатывает замыкание когда на него больше нет ссылок.