Замыкания вызывают у программистов сложности из-за того, что это — «невидимая» конструкция.
Когда вы используете объект, переменную или функцию, вы делаете это намеренно. Вы думаете: «Тут мне понадобится переменная» — и добавляете её в свой код.
А вот замыкания — это уже нечто иное. В то время как большинство программистов начинает осваивать замыкания, эти люди уже, сами о том не зная, пользуются замыканиями. Вероятно, с вами происходит то же самое. Поэтому изучение замыканий — это не столько освоение новой идеи, сколько изучение того, как распознать то, с чем вы уже много раз сталкивались.
Если в двух словах, то замыкание — это когда функция обращается к переменным, объявленным за её пределами. Например, замыкание содержится в этом фрагменте кода:
let users = ['Alice', 'Dan', 'Jessica'];
let query = 'A';
let user = users.filter(user => user.startsWith(query));
Обратите внимание на то, что user => user.startsWith(query)
— это функция. Она использует переменную query
. А эта переменная объявлена за пределами функции. Это и есть замыкание.
Вы, если хотите, можете дальше не читать. Оставшаяся часть этого материала рассматривает замыкания в другом свете. Вместо того чтобы говорить о том, что такое замыкания, эта часть статьи посвятит вас в подробности методики обнаружения замыканий. Это похоже на то, как, в 1960-х, работали первые программисты.
Шаг 1: функции могут получать доступ к переменным, объявленным за их пределами
Для того чтобы разобраться с замыканиями, нужно достаточно хорошо познакомиться с переменными и функциями. В этом примере мы объявляем переменную food
внутри функции eat
:
function eat() {
let food = 'cheese';
console.log(food + ' is good');
}
eat(); // Выводит в консоль 'cheese is good'
А что если надо иметь возможность позже изменить значение переменной food
, сделав это за пределами функции eat
? Для того чтобы это сделать, мы можем изъять из функции саму переменную food
и перенести её на более высокий уровень:
let food = 'cheese'; // Мы вынесли переменную из функции
function eat() {
console.log(food + ' is good');
}
Это позволяет менять переменную food
«извне» тогда, когда это нужно:
eat(); // Выводит 'cheese is good'
food = 'pizza';
eat(); // Выводит 'pizza is good'
food = 'sushi';
eat(); // Выводит 'sushi is good'
Другими словами, переменная food
больше не является для функции eat
локальной. Но у функции eat
, несмотря на это, нет никаких проблем при работе с этой переменной. Функции могут обращаться к переменным, объявленным за их пределами. Остановитесь ненадолго и проверьте себя, убедитесь в том, что с этой идеей у вас не возникает никаких проблем. После того, как эта мысль надёжно обосновалась в вашем разуме, переходите ко второму шагу.
Шаг 2: размещение кода в вызове функции
Предположим, у нас есть какой-то код:
/* Фрагмент кода */
То, какой именно это код, неважно. Но представим, что нам надо выполнить его дважды.
Первый способ это сделать заключается в том, чтобы просто сделать копию кода:
/* Фрагмент кода */
/* Фрагмент кода */
Другой способ — размещение кода в цикле:
for (let i = 0; i < 2; i++) {
/* Фрагмент кода */
}
А третий способ, который нам сегодня особенно интересен, заключается в том, чтобы поместить этот код в функцию:
function doTheThing() {
/* Фрагмент кода */
}
doTheThing();
doTheThing();
Использование функции даёт нам максимальную гибкость, так как это позволяет вызывать данный код любое количество раз, в любое время и из любого места программы.
На самом деле, мы, если надо, можем ограничиться лишь однократным вызовом новой функции:
function doTheThing() {
/* Фрагмент кода */
}
doTheThing();
Обратите внимание на то, что вышеприведённый код эквивалентен исходному фрагменту кода:
/* Фрагмент кода */
Другими словами, если мы возьмём какой-то фрагмент кода и «обернём» его в функцию, а потом вызовем эту функцию в точности один раз, то мы никак не повлияем на то, что именно делает этот код. У этого правила есть некоторые исключения, на которые мы внимания обращать не будем, но, в целом, можно считать, что это правило верно. Подумайте над этим некоторое время, привыкните к этой идее.
Шаг 3: обнаружение замыканий
Мы разобрались с двумя идеями:
- Функции могут работать с переменными, объявленными за их пределами.
- Если разместить код в функции и однократно вызвать эту функцию, на результаты работы кода это не повлияет.
Теперь поговорим о том, что будет, если эти две идеи объединить.
Возьмём пример кода, который мы рассматривали на первом шаге:
let food = 'cheese';
function eat() {
console.log(food + ' is good');
}
eat();
Теперь поместим весь этот пример в функцию, которую планируем вызвать лишь один раз:
function liveADay() {
let food = 'cheese';
function eat() {
console.log(food + ' is good');
}
eat();
}
liveADay();
Прочтите оба предыдущих примера кода и убедитесь в том, что они эквивалентны.
Второй пример работает! Но давайте присмотримся к нему. Обратите внимание на то, что функция eat
находится внутри функции liveADay
. Позволено ли подобное в JavaScript? Действительно ли можно поместить одну функцию в другую?
Есть языки, в которых код, структурированный подобным образом, окажется некорректным. Например, в языке C подобный код будет неправильным (в этом языке нет замыканий). Это означает, что при использовании C наш второй вывод будет неверным — тут нельзя просто взять произвольный фрагмент кода и «обернуть» его в функцию. Но в JavaScript такого ограничения нет.
Ещё раз хорошо поразмыслим над этим кодом, обратив особое внимание на то, где объявлена и где используется переменная food
:
function liveADay() {
let food = 'cheese'; // Объявим `food`
function eat() {
console.log(food + ' is good'); // Прочитаем значение `food`
}
eat();
}
liveADay();
Давайте вместе пошагово разберём этот код. Во-первых, мы объявляем, на верхнем уровне, функцию liveADay
. Мы её немедленно вызываем. У этой функции есть локальная переменная food
. В ней объявлена и функция eat
. Затем внутри liveADay
вызывается функция eat
. Так как функция eat
находится внутри функции liveADay
, eat
«видит» все переменные, объявленные в liveADay
. Именно поэтому функция eat
может прочитать значение переменной food
.
Это и называется замыканием.
Мы говорим о существовании замыкания тогда, когда функция (такая, как eat
) считывает или записывает значение переменной (такой, как food
), которая объявлена за её пределами (например, в функции liveADay
).
Подумайте над этими словами, перечитайте их. Проверьте себя, найдя в нашем примере кода то, о чём идёт речь.
Вот пример, который был приведён в самом начале материала:
let users = ['Alice', 'Dan', 'Jessica'];
let query = 'A';
let user = users.filter(user => user.startsWith(query));
Возможно, замыкание будет проще заметить, переписав этот пример с использованием функционального выражения:
let users = ['Alice', 'Dan', 'Jessica'];
// 1. Переменная query объявлена за пределами функции
let query = 'A';
let user = users.filter(function(user) {
// 2. Мы находимся во вложенной функции
// 3. И мы считываем значение переменной query (которая объявлена за пределами функции!)
return user.startsWith(query);
});
Когда функция обращается к переменной, объявленной за её пределами, мы называем это замыканием. Сам этот термин используется достаточно свободно. Некоторые люди назовут саму вложенную функцию, показанную в примере, «замыканием». Другие могут иметь в виду метод доступа к внешней переменной, называя «замыканием» его. На практике значения это не имеет.
Призрак вызова функции
Сейчас замыкания могут показаться вам обманчиво простой концепцией. Но это не означает, что у них нет некоторых неочевидных особенностей. Если хорошо обдумать тот факт, что функция может считывать и записывать значения переменных, находящихся за её пределами, то окажется, что это имеет довольно серьёзные последствия.
Например, это означает, что такие переменные будут «жить» до тех пор, пока может быть вызвана функция, вложенная в другую функцию.
function liveADay() {
let food = 'cheese';
function eat() {
console.log(food + ' is good');
}
// Вызовем eat через пять секунд
setTimeout(eat, 5000);
}
liveADay();
В этом примере food
— локальная переменная, находящаяся внутри вызова функции liveADay()
. Так и хочется решить, что эта переменная «исчезнет» после выхода из функции, и уже не вернётся, чтобы, как призрак, нас преследовать.
Но в функции liveADay
мы просим браузер вызвать функцию eat
через пять секунд. А эта функция считывает значение переменной food
. В результате оказывается, что JavaScript-движку нужно поддерживать «жизнь» в переменной food
, относящейся к данному вызову liveADay()
, до тех пор, пока не будет вызвана функция eat
.
В этом смысле замыкания можно рассматривать как «призраки» прошлых вызовов функций, или как «воспоминания» о таких вызовах. Даже хотя выполнение функции liveADay()
давно завершилось, переменные, объявленные в ней, должны продолжать существовать до тех пор, пока вложенная функция eat
может быть вызвана. К счастью, JavaScript берёт на себя заботу об этих механизмах, поэтому нам в таких ситуациях не нужно делать что-то особенное.
Почему «замыкания» называются именно так?
У вас может возникнуть вопрос о том, почему «замыкания» называются именно так. Причина этого, в основном, является исторической. Тот, кто знаком с компьютерным жаргоном, может сказать, что у выражения наподобие user => user.startsWith(query)
имеется «открытая привязка». Другими словами, из этого выражения ясно, что представляет собой user
(параметр), но, если рассматривать это выражение в изоляции, неясно — что такое query
. Когда мы говорим, что, на самом деле, query
— это переменная, объявленная за пределами функции, мы «закрываем» (closing) эту открытую привязку. Другими словами — мы получаем замыкание (closure).
Замыкания реализованы не во всех языках программирования. Например, в некоторых языках, вроде C, вообще нельзя пользоваться вложенными функциями. В результате функция может работать только со своими локальными переменными или с глобальными переменными. Но при этом никогда не возникает ситуации, в которой она может получать доступ к локальным переменным родительской функции. Это, на самом деле, очень неприятное ограничение.
Есть и языки, вроде Rust, в которых замыкания реализованы. Но в них для описания замыканий и обычных функций используется разный синтаксис. В результате, если вам необходимо прочитать значение переменной, находящейся за пределами функции, то вам, используя Rust, нужно использовать особую конструкцию. Причина этого в том, что использование замыканий может потребовать от внутренних механизмов языка хранить внешние переменные (называемые «окружением») даже после завершения вызова функции. Эта дополнительная нагрузка на систему приемлема в JavaScript, но она может, при использовании достаточно низкоуровневых языков, вызвать проблемы с производительностью.
Теперь, надеюсь, вы разобрались с концепцией замыканий в JavaScript.
Испытываете ли вы сложности с пониманием каких-то концепций JavaScript?
Автор: ru_vds