Skip to content

Latest commit

 

History

History
223 lines (135 loc) · 30.1 KB

File metadata and controls

223 lines (135 loc) · 30.1 KB

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

Глава 2: Лексическая область видимости

В главе 1, мы определили "область видимости" как набор правил, которые регулируют как Движок может искать переменную по ее имени идентификатора и найти ее либо в текущей Области видимости, либо в любой из вложенных Областей видимости, в которой она содержится.

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

Динамическая область видимости рассматривается в приложении A. Я упоминаю ее здесь только чтобы показать контраст с лексической областью действия, которая является моделью области видимости, используемой в JavaScript.

Время разбора на лексемы

Как мы уже обсудили в главе 1, первая традиционная фаза работы стандартного компилятора языка называется разбиение на лексемы (lexing или tokenizing). Если вы припомните, то процесс разбиения на лексемы анализирует символы строки исходного кода и дает семантическое значение лексемам как результат некоторого парсинга с состоянием.

Это и есть та концепция, которая предоставляет основу понимания что такое лексическая область видимости и откуда происходит ее название.

Определяя ее отчасти через саму себя, лексическая область видимости — это область видимости, которая определена во время разбора на лексемы. Иными словами, лексическая область видимости основана на том, где переменные и блоки области видимости были созданы вами во время написания и таким образом (в основном) навечно зафиксированы на момент, когда лексический анализатор обрабатывал ваш код.

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

Давайте рассмотрим этот код:

function foo(a) {

	var b = a * 2;

	function bar(c) {
		console.log( a, b, c );
	}

	bar(b * 3);
}

foo( 2 ); // 2 4 12

В этом примере кода есть три вложенных области видимости. Удобно представлять эти области видимости как зоны одна внутри другой.

Зона 1 заключает в себе глобальную область видимости, у которой есть всего один идентификатор: foo.

Зона 2 заключает в себе область видимости foo, которая включает в себя три идентификатора: a, bar и b.

Зона 3 заключает в себе область видимости bar и она включает в себя всего один идентификатор: c.

Зоны областей видимости определяются тем, где написаны блоки области видимости, которые последовательно вложены друг в друга. В следующей главе, мы обсудим различные единицы области видимости, а сейчас давайте просто допустим, что каждая функция создает новую зону области видимости.

Зона для bar полностью содержится в зоне для foo, потому что (и только поэтому) это то место, которое мы выбрали для создания функции bar.

Заметьте, что эти вложенные зоны вложены однозначно и четко. Мы не говорим сейчас о круговых диаграммах Эйлера—Венна, где зоны могут пересекать границы. Иными словами, ни одна зона для функции не может одновременно существовать (частично) внутри двух других зон внешних областей видимости, как и ни одна функция не может частично быть внутри каждой из двух родительских функций.

Поиски

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

В вышеприведенном коде, Движок выполняет оператор console.log(..) и идет искать три переменных a, b, и c, на которые есть ссылки. Сначала он начинает с самой внутренней зоны области видимости, области видимости функции bar(..). Он не найдет там a, поэтому пойдет на уровень выше, наружу к следующей ближайшей зоне области видимости, области видимости foo(..). Там он наконец найдет a, и поэтому использует эту a. То же самое и для b. А вот c он найдет внутри bar(..).

Если бы c была и внутри bar(..), и внутри foo(..), то оператор console.log(..) нашел и использовал ту, что в bar(..), никогда не трогая такую же из foo(..).

Поиск в области видимости прекращается как только он находит первое совпадение. Одно и то же имя идентификатора может быть указано в нескольких слоях вложенных областей видимости, что называется "затенение (shadowing)" (внутренний идентификатор "затеняет (shadows)" внешний). Независимо от затенения, поиск в области видимости всегда начинается с самой внутренней области видимости, исполняющейся в данный момент и работает таким путем по направлению наружу/вверх пока не найдется первое совпадение и тогда останавливается.

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

window.a

Эта техника дает доступ к глобальной переменной, которая в противном случае была бы недоступна из-за затенения. Однако, неглобальные затененные переменные не могут быть доступны.

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

Процесс поиска в лексической области видимости применяется только к идентификаторам первого класса, таким как a, b и c. Если у вас есть ссылка на foo.bar.baz в строке кода, поиск в лексической области будет применен чтобы найти идентификатор foo, но как только он находит эту переменную, ему на смену приходят правила доступа к свойствам объекта, чтобы разрешить имена свойств bar и baz, соответственно.

Обманываем лексическую область видимости

Если лексическая область видимости определяется только тем, где объявлена функция, что целиком во власти момента написания кода, какой может быть возможный путь "изменить" (т.е. обмануть) лексическую область во время выполнения?

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

Перед тем как я объясню проблему с производительностью, всё же, давайте взглянем на то, как работают эти два механизма.

eval

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

При вычислении eval(..) в таком свете, должно быть ясно как eval(..) позволяет модифицировать окружение лексической области видимости, обманывая и притворяясь, что этот код был тут всё время.

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

Представьте следующий код:

function foo(str, a) {
	eval( str ); // обман!
	console.log( a, b );
}

var b = 2;

foo( "var b = 3;", 1 ); // 1, 3

Строка "var b = 3;" интерпретируется в точке вызова eval(..), как будто этот код был тут всегда. Поскольку этот код объявляет новую переменную b, он изменяет существующую лексическую область foo(..). Фактически, как было указано выше, этот код на самом деле создает переменную b внутри foo(..), которая затеняет b, которая была объявлена во внешней (глобальной) области видимости.

Когда происходит вызов console.log(..), он находит и a, и b в области видимости foo(..), но никогда не найдет внешнюю b. По этой причине, мы напечатаем "1, 3" вместо "1, 2" как это было бы в обычном случае.

Примечание: В этом примере для простоты строка "кода", которую мы передали, была фиксированным литералом. Но она легко может быть создана программно соединением символов вместе на основе логики вашей программы. eval(..) обычно используется для динамически созданного кода, поскольку динамическое вычисление по существу статического кода из строкового литерала не дает никакого реального преимущества перед простым написанием этого кода напрямую.

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

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

function foo(str) {
   "use strict";
   eval( str );
   console.log( a ); // ReferenceError: a is not defined
}

foo( "var a = 2" );

Есть другие средства в JavaScript, которые почти равносильны по эффекту вызовам eval(..). setTimeout(..) и setInterval(..), которые могут принимать строку как свой первый аргумент, содержимое которой вычисляется как код динамически сгенерированной функции. Это старая, унаследованная функциональность и давным-давно устаревшая. Не делайте так!

Конструктор функции new Function(..) аналогично принимает строку кода в своем последнем аргументе, чтобы превратить ее в динамически сгенерированную функцию (первые аргументы, если указаны, являются именованными параметрами для новой функции). Такой синтаксис конструктора функции немного безопаснее, чем eval(..), но его также следует избегать в вашем коде.

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

with

Еще одна возможность в JavaScript, к которой неодобрительно относятся (и которая сейчас устарела!), которая обманывает лексическую область видимости, это ключевое слово with. Есть много подходящих путей, чтобы объяснить что такое with, но я выберу объяснение с точки зрения того, как оно взаимодействует и влияет на лексическую область видимости.

with обычно описывают как сокращение для выполнения множественных ссылок на свойства объекта без повторения каждый раз ссылки на сам объект.

Например:

var obj = {
	a: 1,
	b: 2,
	c: 3
};

// более "скучно" повторять "obj"
obj.a = 2;
obj.b = 3;
obj.c = 4;

// "легкое" сокращение
with (obj) {
	a = 3;
	b = 4;
	c = 5;
}

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

function foo(obj) {
	with (obj) {
		a = 2;
	}
}

var o1 = {
	a: 3
};

var o2 = {
	b: 3
};

foo( o1 );
console.log( o1.a ); // 2

foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2 — Упс, утекшая глобальная переменная!

В этом примере кода, создаются два объекта o1 и o2. У одного есть свойство a, а у другого — нет. Функция foo(..) берет ссылку на объект obj как аргумент, а затем вызывает with (obj) { .. } с этой ссылкой. Внутри блока with мы делаем, как нам представляется, обычную лексическую ссылку на a, на самом деле LHS-ссылку (см. главу 1), чтобы присвоить ей значение 2.

Когда мы передаем o1, присвоение a = 2 находит свойство o1.a и присваивает ему значение 2, что нашло свое отражение в последующем операторе console.log(o1.a). Однако, когда мы передаем o2, поскольку у него нет свойства a, это свойство не создается, а o2.a остается undefined.

Но затем мы замечаем своеобразный побочный эффект, факт того, что присвоение a = 2 создало глобальную переменную a. Как такое может быть?

Оператор with берет объект, у которого есть ноль или более свойств, и трактует этот объект как если бы он являлся целиком отдельной лексической областью видимости, и таким образом свойства объекта воспринимаются как лексически определенные идентификаторы в этой "области видимости".

Примечание: Даже если блок with трактует объект как лексическую область видимости, обычное объявление var внутри этого блока with не будет входить в область этого блока with, а вместо этого будет в области видимости функции, содержащей этот блок.

В то время как функция eval(..) может менять существующую лексическую область видимости, если она принимает строку кода с одним или более объявлениями в ней, то оператор with на самом деле создает полностью новую лексическую область действия на ровном месте из объекта, который вы ему передаете.

Таким образом мы поняли, что "область видимости", объявленная оператором with когда мы передали o1, была o1 и что у этой "области видимости" есть "идентификатор", который соответствует свойству o1.a. Но когда мы использовали o2 как "область видимости", у нее не было такого "идентификатора" a и поэтому сработали обычные правила поиска LHS-идентификатора (см. главу 1).

Ни "область видимости" o2, ни область видимости foo(..), ни даже глобальная область видимости не нашли у себя идентификатор a, поэтому когда выполняется a = 2, это приводит к созданию автоматической глобальной переменной (поскольку мы в нестрогом режиме).

Это незнакомая и отчасти ошеломляющая мысль увидеть как with превращает, во время выполнения, объект и его свойства в "область видимости" с "идентификаторами". Но это — самое понятное объяснение, которое я могу дать результатам, которые мы видели.

Примечание: В дополнение к тому, что является плохой идеей их использовать, как eval(..), так и with подвергаются воздействию (ограничиваются) строгим режимом. with полностью запрещено, в то время как различные формы скрытых или небезопасных eval(..) запрещены при сохранении базовой функциональности.

Быстродействие

Оба eval(..) и with обманывают в той или иной форме лексическую область видимости, определенную на этапе кодирования, изменением или созданием новой лексической области видимости во время выполнения.

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

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

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

Иными словами, с точки зрения пессимистического здравого смысла, большинство таких оптимизаций, которые он мог бы сделать, бессмысленны, если есть eval(..) или with, поэтому он просто не выполняет никаких оптимизаций.

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

Обзор

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

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

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