Пора понять замыкания в JavaScript! Часть 1. Готовим фундамент
Замыкания... Про них многие слышали, некоторые читали, все их использовали, но никто не понимает. Пришла пора исправить это досадное недоразумение.
Понимание замыканий в JavaScript стало, в некотором роде, мерилом класса разработчика. О них написаны десятки статей. По сообществу ходят ужасные легенды о лексических окружениях и областях видимости, в которых сгинул не один отважный джуниор. На самом деле, все не так страшно, но не стоит сразу лезть в пекло. Простой путь к замыканиям лежит через базовые понятия языка.
Три кита js-замыканий
Замыкания в JavaScript основываются на трех глобальных концепциях:
- контексты выполнения кода;
- области видимости переменных;
- возможность сделать одну функцию аргументом другой.
Глобальный контекст и стек выполнения
Когда js-программа только начинает свою работу, в ней еще нет ни переменных, ни функций, ни одного замыкания – ничего. Один лишь чистый глобальный контекст выполнения. На этом не паханом поле программист обладает полной свободой действий.
Чтобы отслеживать, куда забредет разработчик при отсутствии ограничений, существует стек выполнения. Это специальная структура, в которую складываются все активные контексты. Изначально он всего один, поэтому принципиальная схема выглядит так:
Итак, эти две структуры присутствуют всегда. Глобальный контекст исчезает только тогда, когда завершается сама программа.
В контексте можно объявлять и вызывать функции, инициализировать переменные, выполнять выражения. Все, что создано в рамках контекста, относится к нему. Например, объявленные в глобальной области переменные будут называться глобальными.
Все это звучит так, как будто программист сам может создавать контексты и добавлять их в стек. Так оно и есть, разработчики постоянно делают это, даже не задумываясь. Оказывается, новая область выполнения создается при каждом вызове функции.
Контексты функций
Когда интерпретатор JavaScript встречает вызов функции, происходит много интересного. Прежде всего, создается абсолютно новый контекст выполнения кода, который сразу же становится активным и перемещается на верх стека. Этот контекст называется локальным.
У любой области выполнения функции есть родитель – тот контекст, из которого она была вызвана, например, глобальный.
В локальной области еще до начала работы уже могут быть свои переменные – это входящие аргументы.
В качестве сигнала о завершении функции выступает закрывающая фигурная скобка }
или ключевое слово return
. Наткнувшись на них, движок готовится свернуть локальный контекст и вернуться к его родителю. Но прежде он должен получить возвращаемое значение.
Любая функция что-то возвращает, необязательно явно. Даже если в ней отсутствует команда return
, родителю будет возвращено значение undefined
. Что с ним делать – решит уже сам контекст вызова. Текущая локальная область будет уничтожена вместе со всеми своими переменными и функциями.
Чтобы досконально разобраться в концепции контекстов выполнения кода, нужно шаг за шагом пройти весь путь js-интерпретатора от начала до конца программы. Для примера подойдет очень простой код, в котором тем не менее происходит создание и вызов функции.
let a = 3; function addTwo(x) { let result = x + 2; return result; } let b = addTwo(a); console.log("пример по контекстам выполнения:", b);
Все события кода разбираются детально, но с некоторыми упрощениями. Например, не учитывается хойстинг функций.
Итак, программа запущена, создан и помещен в стек глобальный контекст выполнения, переменных пока нет.
По следам локальных областей
1. Строка 1. Инструкция let a
инициализирует первую глобальную переменную a
, которая изначально равна undefined
.
2. Присваивание переменной a
значения 3.
3. Строка 2. Объявление функции addTwo
в глобальном контексте.
4. Строки со 2 по 5 относятся к функции. В данный момент они просто пропускаются интерпретатором без анализа и выполнения.
5. Строка 6. Инициализация еще одной глобальной переменной b
. На данный момент b = undefined
.
6. Затем обработчик видит инструкцию присваивания. Чтобы выполнить ее, ему необходимо рассчитать правую часть выражения. Там находится вызов функции addTwo
с входящим аргументом a
.
7. Интерпретатор осматривает текущий глобальный контекст. Нужная функция находится на строке 2, а аргумент обнаруживается в самом начале программы (a = 3
). Все готово, можно запускать функцию.
8. Запуск сопровождается созданием нового контекста выполнения и переходом в него. Все дальнейшие действия осуществляются уже внутри локальной области addTwo
. Не следует забывать, что эта функция в итоге должна сформировать некоторое значение и вернуть его в глобальный контекст.
8.1. Строка 2. Прежде всего, происходит инициализация параметра x
и присвоение ему значения 3. Это первая локальная переменная области addTwo
.
8.2. Строка 3. Создание в текущем контексте переменной result
и занесение в нее числа 5 (результат сложения x + 2). Схема работы программы сейчас выглядит так:
8.3. Строка 4. Встретив завершающую команду, интерпретатор ищет переменную result
. Она обнаруживается в текущем контексте на предыдущей строке. Таким образом, в глобальную область выполнения будет возвращено значение 5.
9. Сделав все, что полагается, обработчик с чистой совестью разрушает и удаляет из стека ненужный больше контекст функции addTwo
вместе с переменными x
и result
.
10. Действие вновь возвращается в глобальную область на строку 6, где переменной b
присваивается полученное из функции значение 5.
11. Строка 7. Здесь просто выводится на консоль для проверки результат работы программы.
Казалось бы, такая маленькая программа в 7 строк, а сколько событий!
Теперь понятно, что контекст выполнения – это просто актуальное на данный момент окружение кода. К нему относятся, например, входящие параметры и локальные переменные функции.
Область видимости
Прежде чем вызвать функцию или произвести какую-либо операцию с переменной, JavaScript должен их найти. Поиск начинается с текущего контекста выполнения. Если после тщательной проверки всех углов требуемое не найдено, интерпретатор не сдается. В этом случае он обратится к родительской области и поищет в ней.
При необходимости упорный обработчик дойдет по цепочке до самого глобального контекста. Здесь его полномочия заканчиваются. Если переменная не будет найдена в глобальной области, по месту требования вернется обидное значение undefined
.
Из этого следует, что вложенные контексты выполнения имеют доступ к своим предкам любого уровня. Они могут смело пользоваться родительскими функциями и переменными, то есть видят их. Вот здесь и появляется понятие области видимости. Обратно это, кстати, не работает – дети от родителей свои игрушки прячут. Все как в жизни.
Замыкания активно эксплуатируют области видимости, поэтому следует уделить этой концепции особо пристальное внимание. Для полного осознания пройдем еще раз следом за интерпретатором по простой программе.
let factor = 2; function multiplyThis(n) { let result = n * factor; return result; } let multiplied = multiplyThis(6); console.log('пример по областям видимости:', multiplied)
В программе есть глобальные переменные и переменные, которые будут созданы в локальном контексте multiplyThis
. При этом функция во время работы попытается обратиться к переменной factor
, которая лежит вне ее области выполнения. Получится ли у нее получить желаемое? Проследим за всеми перипетиями этой интриги.
Сказ о том, как JavaScript переменную искал
Начало не предвещает особенных потрясений: программа запускается, создает глобальный контекст выполнения и кладет его в стек. Затем в текущей области создается и получает значение переменная factor
. Следом идет multiplyThis
, содержащая определение функции, которое интерпретатор не трогает.
На шестой строчке события начинают, наконец, развиваться.
1. Новая переменная multiplied
получает стартовое значение undefined
.
2. Обработчик видит операцию присваивания и вызов multiplyThis
с правой стороны от знака =
. Он отправляется на поиски этой функции, находит ее в текущем глобальном контексте и вызывает с аргументом 6.
3. Создается контекст multiplyThis
, в котором сразу же инициализируется входящий параметр n = 6
.
4. Строка 3. Создается еще одна локальная переменная result
со значением undefined
.
5. Чтобы осуществить присваивание, интерпретатору нужно найти значения n и factor. С первым проблем не возникает: оно лежит в текущем контексте выполнения и равно 6.
6. Но factor по-прежнему не найден. Без него ничего не выйдет, неужели все было напрасно?
Обработчик кода обращается к родительскому контексту с просьбой выдать переменную и получает то, что хотел. Теперь можно умножить два значения и записать результат.
7. Функция завершается инструкцией return
, возвращаемое значение равно 12, локальная область multiplyThis
разрушается вместе с переменными n
и result
. А вот factor
остается, так как он лежит в родительском контексте.
Дальше все просто: в multiplied
записывается то, что вернула функция, и переменная выводится на консоль.
Этот простой пример экспериментально подтвердил, что функция не ограничена лишь своим контекстом выполнения. Она может заглядывать к родителям и пользоваться их переменными. Более того, она даже может их менять, что не всегда хорошо. Таким образом, глобальный контекст всегда виден для всех прочих областей, созданных в программе.
Функция в функции
Замыкания уже очень-очень близко, осталось лишь рассмотреть функциональную матрешку JavaScript, на которой все и основано.
Функции из примеров выше возвращали простое число. Это не очень интересно. Почему бы не вернуть из функции другую функцию? Тем более, язык позволяет и даже одобряет подобные начинания.
let firstValue = 7; function createAdder() { function addNumbers(a, b) { let result = a + b; return result; } return addNumbers; } let adder = createAdder(); let sum = adder(firstValue, 8); console.log('пример функциональной матрешки: ', sum)
Начало программы весьма прозаическое и отличается от предыдущих только именами глобальных переменных.
Сюжет закручивается на 9 строке при вызове функции createAdder
.
1. Обработчик создает новую локальную область createAdder
. Входящих параметров, требующих инициализации, у функции нет.
2. В текущем контексте объявляется переменная addNumbers
.
3. Контекст завершает свою работу, возвращая описание функции.
Здесь следует сделать акцент на том, что возвращается только функциональное описание. Сама addNumbers
будет разрушена вместе с локальной областью выполнения.
4. Теперь в adder
записана функция, следовательно, ее можно вызвать. Что и делает программа на 10 строке.
5. Ожидаемо создается новая область adder
, внутри которой инициализируются входящие аргументы a = 7
и b = 8
.
6. В новую локальную переменную result
записывается результат их сложения, который и возвращается, когда функция отработает.
Замыкания уже рядом
Три разобранных выше программы – достаточный фундамент для понимания замыканий в JavaScript. Они утверждают и объясняют 3 важные концепции языка, касающиеся функций:
- Описание любой функции может быть записано в переменную. До тех пор, пока интерпретатор не встретит прямое указание запустить функцию, ее описание не будет обработано.
- Для каждой запущенной функции создается собственный контекст с актуальными данными.
- Функция может обращаться к переменным, определенным в родительской области.
- Функция в JavaScript вполне может эксплуатироваться как обычный объект, в том числе возвращаться из другой функции.
Сами замыкания подробно разобраны во второй части статьи.
Источник: статья Olivier De Meulder: I never understood JavaScript closures