Skip to content

Latest commit

 

History

History
310 lines (171 loc) · 35.2 KB

File metadata and controls

310 lines (171 loc) · 35.2 KB

Вы не знаете JS: Область видимости и замыкания

Глава 1: Что такое область видимости?

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

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

Но включение переменных в нашу программу порождает самые интересные вопросы, которые мы теперь зададим: где эти переменные живут? Другими словами, где они хранятся? И, что более важно, как наша программа их находит, когда нуждается в них?

Эти вопросы говорят о необходимости хорошо определенного набора правил для хранения переменных в некоем месте и для обнаружения эти переменных позднее. Мы назовем этот набор правил — Область видимости.

Но где и как правила этих областей видимости устанавливаются?

Теория компиляторов

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

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

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

  1. Разбиение на лексемы (Tokenizing/Lexing): разбиение строки символов на имеющие смысл (для языка) части, называемые лексемами. Например, представьте программу: var a = 2;. Эта программа, вполне вероятно, будет разбита на следующие лексемы: var, a, =, 2 и ;. Пробел может быть сохранен или не сохранен как лексема в зависимости от того имеет он смысл или нет.

    Примечание: Разница между tokenizing и lexing — едва различима и теоретическая, но она сосредотачивается на том, идентифицируются ли эти лексемы как без состояния или с состоянием. Проще говоря, если токенизатор используется, чтобы вызывать правила парсинга с сохранением состояния для выяснения следует ли считать a отдельной лексемой или только частью другой лексемы, это будет lexing.

  2. Парсинг: берет поток (массив) лексем и превращает его в дерево вложенных элементов, которые сообща представляют грамматическую структуру программы. Это дерево называется "AST" (Abstract Syntax Tree, дерево абстрактного синтаксиса).

    Такое дерево для var a = 2; может начинаться с узла верхнего уровня с названием VariableDeclaration, с дочерним узлом Identifier (чье значение равно a) и еще одним дочерним узлом AssignmentExpression, у которого тоже есть дочерний узел NumericLiteral (чье значение равно 2).

  3. Генерация кода: процесс взятия AST и превращения его в исполняемый код. Эта часть сильно зависит от языка, платформы назначения и т.п..

    Итак, вместо того, чтобы увязать в деталях, мы просто опустим их и скажем, что есть способ взять наше вышеописанное AST для var a = 2; и превратить его в набор машинных инструкций, чтобы в действительности создать переменную с именем a (включая выделение памяти и т.д.), а затем сохранить значение в a.

    Примечание: подробности того, как движок управляет системными ресурсами, глубже, чем мы будем "копать", поэтому мы всего лишь примем на веру, что движок умеет создавать и сохранять переменные, когда необходимо.

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

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

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

Для JavaScript, компиляция во многих случаях происходит за всего лишь микросекунды (или меньше!) перед выполнением кода. Чтобы гарантировать высочайшее быстродействие, движки JS используют все виды уловок (такие как JIT, который компилирует лениво и даже перекомпилирует на ходу), которые вне "области" нашего обсуждения тут.

Давайте скажем, простоты ради, что любой код JavaScript должен быть скомпилирован до (обычно прямо перед!) его выполнения. Поэтому, компилятор JS возьмет программу var a = 2; и сперва скомпилирует ее, а потом будет готов выполнить ее, обычно сразу же.

Понимание области видимости

Путь, которым мы будем приближаться к обучению области видимости, это думать о процессе в терминах диалога. Но, кто ведет этот диалог?

Действующие лица

Встречайте действующих лиц, которые взаимодействуют, чтобы обработать программу var a = 2;. Чтобы мы могли понять о чем их диалоги мы немного подслушаем их:

  1. Движок: отвечает за компиляцию от начала до конца и выполнение нашей JavaScript программы.

  2. Компилятор: один из друзей Движка, выполняет всю грязную работу по парсингу и генерации кода (см. предыдущий раздел).

  3. Область видимости: еще один друг Движка; собирает и обслуживает список поиска всех объявленных идентификаторов (переменных), и следит за исполнением строгого набора правил о том, как эти идентификаторы доступны для кода, выполняемого в текущий момент.

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

Туда и обратно

Когда вы видите программу var a = 2;, вы вероятнее всего подумаете о ней как об одном операторе. Но наш новый друг Движок видит это не так. На самом деле, Движок видит два отдельных оператора, один, который Компилятор обработает во время компиляции, а другой, который Движок обработает во время выполнения.

Так давайте же разберем по полочкам как Движок и его друзья поступят с программой var a = 2;.

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

Обоснованным предположением будет то, что Компилятор породит код, который можно кратко представить следующим псевдо-кодом: "Выделить память для переменной, пометить ее как a, затем поместить значение 2 в эту переменную." К сожалению, это не совсем точно.

Компилятор вместо этого сделает следующее:

  1. Встретив var a, Компилятор просит Область видимости посмотреть существует ли уже переменная a в коллекции указанной области видимости. Если да, то Компилятор игнорирует это объявление переменной и двигается дальше. В противном случае, Компилятор просит Область видимости объявить новую переменную a в коллекции указанной области видимости.

  2. Затем Компилятор генерирует код для Движка для последующего выполнения, чтобы обработать присваивание a = 2. Код, который Движок запускает, сначала спрашивает Область видимости есть ли переменная с именем a, доступная в коллекции текущей области видимости. Если да, то Движок использует эту переменную. Если нет, то Движок ищет в другом месте (см. раздел Вложенная область видимости ниже).

Если Движок в итоге находит переменную, он присваивает ей значение 2. Если нет, то Движок вскинет руки и выкрикнет "ошибка!"!

Подводя итоги: для присваивания значения переменной выполняется два отдельных действия: первое, Компилятор объявляет переменную (если не была объявлена до этого в текущей области видимости), и второе, когда выполняет код, Движок ищет эту переменную в Области видимости и присваивает ей значение, если находит.

Компилятор расскажет

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

Когда Движок выполняет код, который Компилятор генерирует на шаге (2), он должен поискать переменную a, чтобы увидеть была ли она объявлена и этот поиск принимает во внимание Область видимости. Но тип поиска, который выполняет Движок, влияет на результат поиска.

В нашем случае, он говорит, что Движок выполнит "LHS"-поиск переменной a. Другой тип поиска называется "RHS".

Держу пари, что вы можете угадать что значат "L" и "R". Эти термины означают "Left-hand Side" (левая сторона) и "Right-hand Side" (правая сторона).

Сторона... чего? Операции присваивания.

Иными словами, LHS-поиск выполняется, когда переменная появляется с левой стороны операции присваивания, а RHS-поиск выполняется, когда переменная появляется с правой стороны операции присваивания.

На самом деле, давайте будем более точны. RHS-поиск неотличим, для наших целей, от простого поиска значения некоторой переменной, тогда как LHS-поиск пытается найти сам контейнер переменной, чтобы он мог присвоить значение. Таким образом, RHS не обязательно означает "правая сторона присваивания" по существу, он просто более точно означает "не левая сторона".

Прикинувшись немного поверхностным на минуту, вы можете подумать, что "RHS" вместо этого значит "retrieve his/her source (value)" (получить его/ее исходное значение), представляя, что RHS означает "иди и возьми значение из...".

Давайте копнем немного глубже в этом направлении.

Когда я говорю:

console.log( a );

Ссылка на a — это RHS-ссылка, потому что здесь ничего не присваивается в a. Напротив, мы выполняем поиск, чтобы извлечь значение a, для того, чтобы передать значение в console.log(..).

Для сравнения:

a = 2;

Ссылка на a здесь — это LHS-ссылка, так как мы не заботимся здесь о том, каково текущее значение, мы просто хотим найти эту переменную как цель для операции присваивания = 2.

Примечание: LHS и RHS, означающие "левая/правая сторона присваивания", не обязательно буквально означают "левая/правая сторона операции присваивания =". Есть еще несколько способов, которыми производится присваивание, и поэтому лучше концептуально думать о нем как: "кто является целью присваивания (LHS)" и "кто источник присваивания (RHS)".

Представьте такую программу, в которой есть обе ссылки LHS и RHS:

function foo(a) {
	console.log( a ); // 2
}

foo( 2 );

Последняя строка, которая активизирует foo(..) как вызов функции, требует RHS-ссылку на foo, что значит, "сходи и найди значение foo и дай его мне". Более того, (..) означает, что значение foo должно быть выполнено, поэтому это скорее всего функция!

Здесь есть едва уловимое, но важное присваивание. Вы обнаружили его?

Вы наверное упустили неявное a = 2 в этом коде. Это происходит, когда значение 2 передается как аргумент в функцию foo(..), в этом случае значение 2 присваивается параметру a. Чтобы (неявно) присвоить значение параметру a, выполняется LHS-поиск.

Также есть и RHS-ссылка на значение a и это результирующее значение передается в console.log(..). console.log(..) нужна ссылка для выполнения. Для объекта console это RHS-поиск, затем происходит разрешение имени свойства чтобы убедиться существует ли метод, называемый log.

Наконец, мы можем осмыслить, что есть LHS/RHS-обмен передаваемым значением 2 (путем RHS-поиска переменной a) в log(..). Внутри родной реализации log(..), мы можем предположить, что у нее есть параметры, у первого из которых (возможно называющегося arg1) есть поиск LHS-ссылки, до присваивания ему 2.

Примечание: У вас может появиться соблазн представлять объявление функции function foo(a) {... как обычное объявление переменной и присваивание, такое как var foo и foo = function(a){.... Делая так, будет соблазн думать об объявлении этой функции как подразумевающей LHS-поиск.

Однако, едва заметная, но важная разница есть в том, что Компилятор обрабатывает как объявление, так и определение значения во время генерации кода, благодаря чему, когда Движок выполняет код, не требуется никакой обработки чтобы "присвоить" значение функции в foo. Следовательно, неуместно думать об объявлении функции как о присваивании с помощью LHS-поиска тем способом, который мы здесь обсуждаем.

Беседа Движка и Области видимости

function foo(a) {
	console.log( a ); // 2
}

foo( 2 );

Давайте представим вышеуказанный обмен между ними (который обрабатывает этот код) как беседу. Беседа может пойти примерно так:

Движок: Эй, Область видимости, у меня есть RHS-ссылка на foo. Когда-нибудь слышала о такой?

Область видимости: Ну разумеется, слышала. Компилятор объявил ее всего секунду назад. Это функция. Пожалуйста!

Движок: Отлично, спасибо! Хорошо, я выполняю foo.

Движок: Эй, Область видимости, у меня есть LHS-ссылка на a, слышала что-нибудь о ней?

Область видимости: Ну разумеется, слышала. Компилятор объявил ее как формальный параметр в foo только что. Пожалуйста!

Движок: Отзывчива как всегда, Область видимости. Снова спасибо. А теперь присвоим 2 в a.

Движок: Эй, Область видимости, извини, что беспокою тебя снова. Мне нужен RHS-поиск console. Когда-нибудь слышала о таком имени?

Область видимости: Нет проблем, Движок, это то, чем я весь день и занимаюсь. Да, у меня есть console. Она встроенная. Пожалуйста!

Движок: Идеально. Ищу log(..). Превосходно, это функция.

Движок: Эй, Область видимости. Можешь помочь мне с RHS-ссылкой на a? Думаю, я ее помню, но просто хочу лишний раз проверить.

Область видимости: Ты прав, Движок. Та же ссылка, не изменилась. Пожалуйста!

Движок: Круто! Передаю значение a, которое равно 2, в log(..).

...

Тест

Проверьте ваше понимание на настоящий момент. Обязательно сыграйте роль Движка и поучаствуйте в "беседе" с Областью видимости:

function foo(a) {
	var b = a;
	return a + b;
}

var c = foo( 2 );
  1. Определите все LHS-поиски (их 3!).

  2. Определите все RHS-поиски (их 4!).

Примечание: См. обзор этой главы, чтобы узнать ответы на тест!

Вложенная область видимости

Мы говорили, что Область видимости — это набор правил поиска переменных по их идентификатору. Однако, обычно бывает более одной Области видимости.

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

Пример:

function foo(a) {
	console.log( a + b );
}

var b = 2;

foo( 2 ); // 4

RHS-ссылка на b не может быть разрешена внутри функции foo, но она может быть разрешена в Области видимости, окружающей ее (в этом случае, глобальной).

Поэтому, еще раз пересмотрев беседы между Движком и Областью видимости, мы возможно услышим:

Движок: "Эй, Область видимости foo, что-нибудь слышала о b? У меня есть RHS-ссылка на нее".

Область видимости: "Не-а, никогда не слышала о такой. Попробуй что-нибудь другое!"

Движок: "Эй, Область видимости снаружи foo! О, ты еще и глобальная Область видимости, круто. Когда-нибудь слышала о b? У меня есть RHS-ссылка на нее."

Область видимости: "Да-да, конечно есть. Пожалуйста!"

Простые правила просмотра вложенных Областей видимости: Движок начинает в текущей выполняемой Области видимости, ищет в ней переменную, затем если не находит, продолжает поиск уровнем выше и так далее. Если достигнута самая внешняя глобальная область видимости, поиск останавливается, независимо от того, нашел он переменную или нет.

Берем за основу метафоры

Для визуализации процесса разрешения во вложенных Областях видимости, я хочу, чтобы вы подумали об этом высоком здании.

Здание символизирует набор правил вложенных Областей видимости нашей программы. Первый этаж здания представляет вашу текущую выполняемую Область видимости, где бы вы ни были. Верхний уровень здания — это глобальная Область видимости.

Вы разрешаете LHS- и RHS-ссылки ища на вашем текущем этаже, а если вы не нашли что искали, поднимаетесь на лифте на следующий этаж, ища там, затем на следующий и так далее. Как только вы попадаете на верхний этаж(глобальная Область видимости), вы либо находите то, что искали, либо не находите. Но в любом случае вы должны остановиться.

Ошибки

Почему имеет значение называть поиск LHS или RHS?

Потому что эти два типа поиска ведут себя по-разному в обстановке, когда переменная еще не была объявлена (не была найдена ни в одной просмотренной Области видимости).

Представьте:

function foo(a) {
	console.log( a + b );
	b = a;
}

foo( 2 );

Когда происходит RHS-поиск b первый раз, она не будет найдена. Это как бы "необъявленная" переменная, так как она не была найдена в этой области видимости.

Если RHS-поиск не сможет когда-либо найти переменную, в любой из вложенных Областей видимости, это приведет к возврату Движком ошибки ReferenceError. Важно отметить, что эта ошибка имеет тип ReferenceError.

Напротив, если Движок выполняет LHS-поиск и достигает верхнего этажа (глобальной Области видимости) и не находит ничего, и если программа не запущена в "строгом режиме", то затем глобальная Область видимости создаст новую переменную с таким именем в глобальной области видимости и передаст ее обратно Движку.

"Нет, до этого не было ни одной и я любезно создала ее для тебя."

"Строгий режим", который был добавлен в ES5, имеет ряд разных отличий от обычного/нестрогого/ленивого режима. Одно такое отличие — это то, что он запрещает автоматическое/неявное создание глобальных переменных. В этом случае, не было бы никакой переменной в глобальной Области видимости, чтобы передать обратно от LHS-поиска, и Движок выбросит ReferenceError аналогично случаю с RHS.

Теперь, если переменная найдена в ходе RHS-поиска, но вы пытаетесь сделать что-то с ее значением, что невозможно, например, пытаетесь выполнить как функцию не-функциональное значение или ссылаетесь на свойство значения null или undefined, то Движок выдаст другой вид ошибки, называемый TypeError.

ReferenceError — это сбой разрешения имени, связанный с Областью видимости, тогда как TypeError подразумевает, что разрешение имени в Области видимости было успешным, но была попытка выполнения нелегального/невозможного действия с результатом.

Обзор

Область видимости — это набор правил, которые определяют где и как переменная (идентификатор) могут быть найдены. Этот поиск может осуществляться для целей присваивания значения переменной, которая является LHS (left-hand-side) ссылкой, или может осуществляться для целей извлечения ее значения, которое является RHS (right-hand-side) ссылкой.

LHS-ссылки являются результатом операции присваивания. Присваивания, связанные с Областью видимости, могут происходить либо с помощью операции =, либо передачей аргументов (присваиванием) параметрам функции.

JavaScript Движок перед выполнением сначала компилирует код, и пока он это делает, он разбивает операторы, подобные var a = 2; на два отдельных шага:

  1. Первый, var a, чтобы объявить ее в Область видимости. Это выполняется в самом начале, до исполнения кода.

  2. Позже, a = 2 ищет переменную (LHS-ссылку) и присваивает ей значение, если находит.

Оба поиска ссылок LHS и RHS начинаются в текущей выполняющейся Области видимости и если нужно (т.е. они не нашли что искали в ней), они работают с их более высокими вложенными Областями видимости, с одной областью (этажом) за раз, ища идентификатор, пока не доберутся до глобальной (верхний этаж) и не остановятся, вне зависимости от результата поиска.

Невыполненные RHS-ссылки приводят к выбросу ReferenceError. Невыполненные LHS-ссылки приводят к автоматической, неявносозданной переменной с таким именем (если не включен "Строгий режим"), либо к ReferenceError (если включен "Строгий режим").

Ответы к тесту

function foo(a) {
	var b = a;
	return a + b;
}

var c = foo( 2 );
  1. Определите все LHS-поиски (их 3!).

    c = .., a = 2 (неявное присваивание параметру) и b = ..

  2. Определите все RHS-поиски (их 4!).

    foo(2.., = a;, a + .. и .. + b

Про "Строгий режим" см. здесь