in ECMAScript

Тонкости ECMA-262-3. Часть 4. Цепь областей видимости.

Read this article in: English, Chinese (version 1, version 2), Korean.

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

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

Данная заметка будет посвящена ещё одной сущности, напрямую связанной с контекстами исполнения; на сей раз, мы затронем тему цепи областей видимости.

Если представить кратко, выделив основную суть, цепь областей видимости (scope chain) в большей мере связана со вложенными функциями.

Как мы знаем, в ECMAScript можно описывать вложенные функции и даже возвращать эти функции из внешних.

var x = 10;

function foo() {
  
  var y = 20;
  
  function bar() {
    alert(x + y);
  }
  
  return bar;

}

foo()(); // 30

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

Цепь областей видимости и является списком этих (родительских) объектов переменных для вложенных контекстов. Данная цепь используется для поиска переменных. Т.е. из примера выше, scope chain контекста “bar” будет включать AO(bar), AO(foo) и VO(global).

Но, давайте разбирать эту тему подробней.

Начнём с определения, далее будем рассматривать его нюансы на примерах.

Цепь областей видимости (Scope, Scope chain, сокращённо SC) – это связанная с контекстом исполнения цепь объектов переменных, в которой происходит поиск переменных при разрешении имён идентификаторов.

Scope chain функции создаётся при её выполнении, и состоит из объекта активации и внутреннего свойства функции [[Scope]]. Мы будем обсуждать свойство [[Scope]] чуть ниже.

Схематично в контексте:

activeExecutionContext = {
    VO: {...}, // или AO
    this: thisValue,
    Scope: [ // Scope chain
      // список всех объектов переменных
      // для поиска идентификаторов
    ] 
};

где Scope по определению:

Scope = AO + [[Scope]]

Для примеров будем представлять Scope и [[Scope]] в виде обычных массивов JavaScript — так будет наглядней.

var Scope = [VO1, VO2, ..., VOn]; // scope chain

Альтернативной формой записи может быть иерархическая цепь объектов с ссылкой на родительскую область видимости (на родительский объект переменных) в каждом звене цепи. Этому представлению соответствует концепция __parent__ некоторых реализаций, которую мы обсуждали во второй части, посвящённой объекту переменных:

var VO1 = {__parent__: null, ... other data}; -->
var VO2 = {__parent__: VO1, ... other data}; -->
// etc.

Однако представлять scope chain в виде массива — удобней, поэтому в дальнейшем обсуждении мы будем использовать именно этот подход. К тому же, спецификация сама абстрактно утверждает (см. 10.1.4), что “a scope chain is a list of objects”, независимо от того, что на уровне реализаций может быть использован подход с __parent__. Массив же в ECMAScript является хорошим кандидатом на роль списка (list).

Комбинация AO + [[Scope]], а также процесс разрешения имён идентификаторов (identifier resolution), о котором также будет сказано ниже, связаны с жизненным циклом функций.

Жизненный цикл функции подразделяется на этап создания и этап активации (вызова). Разберём их подробней.

Как известно, декларации функций попадают в объект переменных (VO) / объект активации (AO) на этапе входа в контекст. Рассмотрим пример объявления переменной и функции в глобальном контексте (где объектом переменных является сам глобальный объект, помним, да?):

var x = 10;

function foo() {
  var y = 20;
  alert(x + y);
}

foo(); // 30

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

До сих пор мы говорили лишь об объекте переменных в рамках одного контекста. Здесь мы видим, что переменная “y” определена в функции “foo” (а значит, она находится в AO контекста функции “foo”), но переменная “x”, при входе в контекст, нигде не определялась (и, соответственно, в AO не добавлялась; “с виду”, переменной “x” вообще не существует для функции “foo”, но, как мы увидим ниже, – лишь “с виду”). Объект активации контекста функции “foo” содержит только одно свойство – свойство “y”:

fooContext.AO = {
  y: undefined // undefined - при входе, 20 - при вызове
};

Каким же образом функция “foo” видит переменную “x”? Логично предположить, что функция должна иметь доступ к объекту переменных вышестоящего контекста. В сущности, так оно и есть, и, физически, данный функционал реализуется за счёт внутреннего свойства функции [[Scope]].

[[Scope]] – это иерархическая цепь объектов переменных (VO), стоящих выше контекста функции; цепь записывается свойством в функцию при её создании.

Обратите внимание на ключевой момент – [[Scope]] записывается в функцию при её создании – статически (неизменно), раз, и навсегда (до уничтожения функции). Т.е. функция может быть ни разу не вызвана, но свойство [[Scope]] в неё уже записано.

Ещё один момент, который следует учесть, это то, что [[Scope]], в отличии от Scope (Scope chain), является свойством функции, а не контекста. Рассматривая вышеприведённый пример, [[Scope]] функции “foo” будет иметь следующий вид:

foo.[[Scope]] = [
  globalContext.VO // === Global
];

А далее, при вызове функции, как нам известно, происходит вход в контекст функции, где создаётся объект активации, определяется this и Scope (Scope chain). Рассмотрим подробней этот момент.

Как было сказано в определении, при входе в контекст, после создания AO/VO, свойство контекста Scope определяется следующим образом:

Scope = AO|VO + [[Scope]]

Основным моментом здесь является то, что объект активации является первым элементом массива Scope:

Scope = [AO].concat([[Scope]]);

Эта особенность очень важна при разрешении имён идентификаторов.

Разрешение имён идентификаторов (Identifier resolution) – это процесс определения, к какому из объектов переменных в Scope chain принадлежит переменная (или декларация функции) по её имени.

На выходе данного алгоритма будет всегда значение типа Reference, база которого (base) является соответствующим объектом переменных (либо null, если переменная не найдена), а имя свойства (property name) — имя определяемого (разрешаемого) идентификатора. Подробно тип Reference разбирался в третьей части, посвящённой ключевому слову this.

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

Немного усложним пример, описанный выше; добавим дополнительный уровень вложенности:

var x = 10;

function foo() {

  var y = 20;

  function bar() {
    var z = 30;
    alert(x +  y + z);
  }

  bar();
}

foo(); // 60

Имеем:

globalContext.VO:

globalContext.VO === Global = {
  x: 10
  foo: <ссылка на функцию>
};

При создании “foo”, [[Scope]] “foo”:

foo.[[Scope]] = [
  globalContext.VO
];

При запуске “foo”:

fooContext.AO:

fooContext.AO = {
  y: 20,
  bar: <ссылка на функцию>
};

При запуске “foo”, fooContext.Scope = fooContext.AO + foo.[[Scope]]:

fooContext.Scope = [
  fooContext.AO,
  globalContext.VO
];

При создании внутрененней функции “bar”, [[Scope]] “bar”:

bar.[[Scope]] = [
  fooContext.AO,
  globalContext.VO
];

При запуске “bar”, barContext.AO:

barContext.AO = {
  z: 30
};

При запуске “bar”, barContext.Scope = barContext.AO + bar.[[Scope]]:

barContext.Scope = [
  barContext.AO,
  fooContext.AO,
  globalContext.VO
];

Разрешение имён идентификаторов “x”, “y” и “z” при alert-e:

- "x"
-- barContext.AO // не найдено
-- fooContext.AO // не найдено
-- globalContext.VO // найдено - 10
- "y"
-- barContext.AO // не найдено
-- fooContext.AO // найдено - 20
- "z"
-- barContext.AO // найдено - 30

Рассмотрим несколько важных особенностей, связанных со Scope chain и свойством функций [[Scope]].

Замыкания (Closures) в ECMAScript напрямую связаны со свойством функций [[Scope]]. Как уже было отмечено, [[Scope]] запоминается при создании функции и существует до тех пор, пока жива сама функция. Собственно, замыкание — это и есть комбинация кода функции и её свойства [[Scope]]. При этом, [[Scope]] одним из объектов цепи содержит то лексическое окружение (родительский объект переменных), в котором функция порождается. Переменные из вышестоящих контекстов при дальнейшей активации функции будут искаться именно в этой лексической (статически запомненной при создании) цепи объектов переменных.

Примеры:

var x = 10;

function foo() {
  alert(x);
}

(function () {
  var x = 20;
  foo(); // 10, а не 20
})();

Видим, что переменная “x” найдена в [[Scope]] функции “foo”, т.е. для поиска переменных используется лексическая (замкнутая) цепь, определяемая на момент создания функции, но не динамическая цепь вызова, при которой значение переменной “x” определилось бы как 20.

Ещё (классический) пример замыкания:

function foo() {

  var x = 10;
  var y = 20;

  return function () {
    alert([x, y]);
  };

}

var x = 30;

var bar = foo(); // возвратилась анонимная функция

bar(); // [10, 20]

Вновь видим, что для разрешения идентификаторов используется лексическая цепь создания функции — переменная “x” определена как 10, а не 30. Более того, данный пример наглядно демонстрирует, что [[Scope]] функции (в данном случае анонимной функции, возвращённой из функции “foo”) продолжает существовать даже после того, как порождающий её контекст уже завершился.

Более подробно о теории замыканий и об организации данной сущности в ECMAScript читайте в шестой части данного цикла статей.

В примерах выше наглядно видно, что в функцию при создании записывается свойство [[Scope]], благодаря которому, она получает доступ к переменным всех вышестоящий контекстов. Однако, в этом правиле есть одно важное исключение, и касается оно функций определённых с помощью конструктора Function.

var x = 10;

function foo() {

  var y = 20;

  function barFD() { // FunctionDeclaration
    alert(x); 
    alert(y);
  }

  var barFE = function () { // FunctionExpression
    alert(x);
    alert(y);
  };

  var barFn = Function('alert(x); alert(y);');

  barFD(); // 10, 20
  barFE(); // 10, 20
  barFn(); // 10, "y" is not defined

}

foo();

Как видим, функции “barFn”, созданной при помощи конструктора Function не доступна переменная “y”. Но это не значит, что функция “barFn” не имеет внутреннего свойства [[Scope]] (иначе бы, она не видела и переменную “x”). А дело в том, что, являясь исключением, функции, порождённые конструктором Function в качестве [[Scope]] всегда имеют лишь глобальный объект. Учитывайте это, т.к., например, создать замыкание внешних контекстов, кроме глобального, при помощи такой функции уже не получится.

Также, особо важным моментом при опросе Scope chain, является то, что, могут опрашиваться и прототипы (если они есть) объектов переменных — ввиду прототипной природы ECMAScript: если свойство не найдено в самом объекте, его поиск продолжается в цепи прототипов (Prototype chain) объекта. Т.е. своего рода двумерный просмотр цепи: (1) по звеньям scope chain, (2) и на каждом из звеньев scope chain — вглубь по звеньям prototype chain. Данный эффект можно наблюдать, если определить свойство в Object.prototype:

function foo() {
  alert(x);
}

Object.prototype.x = 10;

foo(); // 10

Объекты активации прототипа не имеют, что можно видеть в примере ниже:

function foo() {

  var x = 20;

  function bar() {
    alert(x);
  }

  bar();
}

Object.prototype.x = 10;

foo(); // 20

Если бы AO функции “bar” имел прототип, то свойство “x” должно было быть найдено в Object.prototype (поскольку не найдено в самом AO). В первом же примере, обходя scope chain, мы доходим до глобального объекта, который (в некоторых реализациях, но не во всех) наследуется от Object.prototype, и, соответственно, “x” определяется, как 10.

Аналогичную ситуацию можно наблюдать в некоторых версиях SpiderMonkey при работе с именованными функциями-выражениями (named function expression, NFE), где спец. объект, хранящий имя функции-выражения наследуется от Object.prototype, а также, в некоторых версиях реализации Blackberry, где сам объект активации наследуется от Object.prototype. Но подробней об этом — в главе, посвященной функциям.

Здесь особо разбирать нечего, но, отметить стоит. Цепь областей видимости глобального контекста содержит лишь глобальный объект. Контекст с типом кода “eval” наследует Scope из вызывающего контекста.

globalContext.Scope = [
  Global
];

evalContext.Scope === callingContext.Scope;

В ECMAScript существуют две инструкции, модифицирующие Scope на этапе исполнения кода контекста. Это инструкции with и часть catch инструкции try {…} catch {…}. Обе они добавляют в начало Scope chain объект, необходимый для поиска значений переменных, фигурирующих в рамках данных инструкций. Т.е., если имеет место один из этих случаев, Scope схематически модифицируется следующим образом:

Scope = Объект[with|catch] + AO|VO + [[Scope]]

Инструкция with в данном случае добавляет объект, являющийся её параметром (и, таким образом, нам становятся доступны свойства этого объекта без использования префикса):

var foo = {x: 10, y: 20};

with (foo) {
  alert(x);
  alert(y);
}

Модификация Scope:

Scope = foo + AO|VO + [[Scope]]

Покажем ещё раз, что имя переменной будет найдено при разрешении в объекте, добавленном инструкцией with в начало scope chain:

var x = 10, y = 10;
 
with ({x: 20}) {

  var x = 30, y = 30;

  alert(x); // 30
  alert(y); // 30
}
 
alert(x); // 10
alert(y); // 30

Что здесь произошло? Ещё на этапе входа в контекст, в объект переменных были добавлены имена “x” и “y”. Далее, уже на этапе выполнения кода, были произведены следующие модификации:

  • x = 10, y = 10;
  • добавился объект {x: 20} в начало scope chain;
  • встретившаяся инструкция var внутри with, естественно, ничего не создавала, поскольку все var-ы были разобраны и добавлены ещё при ходе в контекст;
  • произошла лишь модификация значения “x”, и именно той “x”, которая будет найдена теперь в объекте, добавленном в scope chain во втором пункте; значение этой “x” было 20, и стало 30;
  • также произошло изменение переменной “y”, которая будет найдена в объекте переменных выше; соответственно, было 10, стало 30;
  • далее, после отработки with, её спецобъект был удалён из scope chain (и вместе с ним, изменённое значение “x” – 30);
  • что и можно видеть в последних двух alert-ах: значение “x” текущего объекта переменных осталось неизменным, значение же “y” теперь равно 30 и было изменено ещё при работе инструкции with.

Также, часть catch инструкции try {…} catch {…} для того, чтобы был доступен параметр-исключение catch, создаёт новый объект и добавляет в него единственное свойство – параметр-исключение. Схематично можно представить так:

try {
  ...
} catch (ex) {
  alert(ex);
}

Модификация Scope:

var __catchObject = {
  ex: <объект-исключение>
};

Scope = __catchObject + AO|VO + [[Scope]]

После отработки данных инструкций, Scope восстанавливается к состоянию, которое цепь имела до их воздействия.

На данном этапе, мы разобрали практически все ключевые моменты, касающиеся контекстов исполнения и связанных с ними сущностей. Далее, по плану, – подробный разбор объектов-функций: типы функций (FunctionDeclaration, FunctionExpression) и замыкания (кстати, замыкания напрямую связаны с разобранным в данной статье свойством [[Scope]], но об этом – в очередной заметке). Буду рад ответить на ваши вопросы в комментариях.

Автор: Dmitry A. Soshnikov.
Дата публикации: 01.07.2009

Write a Comment

Comment

  1. Дмитрий, спасибо за Ваши статьи.
    По мере прочтения у меня, человека далёкого от JS, возник вопрос.
    Свойство [[Scope]] создаётся для всех типов функций? То есть для FE функции оно тоже будет создано?
    И ещё не совсем понимаю, что вклаывает в себя понятие “создание функции”. Вы пишите, что [[Scope]] назначается при создании ф-ии.
    Что на более низком уровне означает создание FE функции?

  2. @Konstantin

    Да, согласно алгоритму создания функций (см. ES5 13.2, пункт 9), свойство [[Scope]] создается для любой функции.

    Другой вопрос, что хранится в [[Scope]] конкретного типа функции (подробней здесь) — так для функций созданных с помощью конструктора Function, свойство [[Scope]] содержит только глобальный объект:

    var x = 10;
    
    (function () {
      var x = 100;
      var y = 200;
    
      var FE = function() {
        console.log(x, y);
      };
    
      var F = new Function('console.log(x); console.log(y);');
    
      FE(); // 100, 200
      F(); // 10, "y" is not defined
    
    })();

    Что же касается внутреннего представления, то это уже зависит от конкретного движка, здесь уже свои оптимизации могут быть. Абстрактно же — это “native ECMAScript object”, у которого [[Class]] задан как "Function".

  3. Дмитрий, скажите, пожалуйста, если для функции foo (первый пример, “Двумерный анализ Score”) создается AO, а объекты активации прототипа не имеют, то откуда берется 10?
    Или же если это создается VO для переменной x (которое имеет прототип), то почему тогда во втором примере (если убрать var, то x станет такой же глобальной переменной как и в первом примере) присвоение Object.prototype.x = 10; не меняет ее значения при вызове из bar? То есть уточните, пожалуйста, что для чего создается: VO для х, АО для foo и bar и как эти 2 примера работают (часть 2 цикла статей не помогла мне в этом вопросе)?

    Спасибо

  4. @Eli

    объекты активации прототипа не имеют, то откуда берется 10

    Это “фича” SpiderMonkey (Firefox). Там глобальный объект наследует (в итоге) от Object.prototype.

    Когда идет обращение к x из foo, осуществляется поиск x в цепи областей видимости, начиная с foo и вверх по иерархии.

    Проверяется каждый AO в этой цепи. Здесь только два таких объекта: сам AO функции foo (тут x не найден), и глобальный объект (и здесь x не найден).

    Однако, помимо самого объекта в цепи областей видимости, проверяются еще и прототипы (если они есть!). У AO нет прототипов, а у глобального объекта — есть, и это Object.prototype (в одном из звеньев); там-то x и найден.

    если убрать var, то x станет такой же глобальной переменной как и в первом примере

    Не совсем “такой же.” Если убрать var, создается свойство глобального объекта. Сооветственно, оно затеняет свойство из Object.prototype, т.к. найдено раньше:

    x = 10;
    Object.prototype.x = 20;
    
    console.log(x); // 10, из глобального объекта
    
    delete x;
    
    console.log(x); // 20, из Object.prototype
  5. Lists are distinguished from arrays in that lists only allow sequential access, while arrays allow random access.

  6. Спасибо большое за статьи. Вот функция

    function f() 
    {
       if (arguments.length > 0)
          f.F1 = function() {return a;} 
       else
       {
          eval('var a = 0');
          f.F2 = function() {return a;}
          f(0);
       }  
    }
    f();
    f.F1();
    f.F2();
    

    Почему F1 не видит “а”, а F2 видит?

  7. @Oleg

    Почему F1 не видит “а”, а F2 видит?

    Потому что при рекурсивном вызове — f(0) переменная a не создается. Когда запускается функция (рекурсивно или нет — не важно) — всегда создается свежий AO. И AO первого вызова (где создается a, и которая видна для F2) никакого отношения ко второму вызову не имеет (где создается F1, но не создается a).

  8. Отличная серия статей. Спасибо.

    Возник вопрос. Какой порядок поиска переменной? AO->[[Prototype]]->[[Scope]] или AO->[[Scope]]->[[Prototype]]

  9. @MrZorg, по спецификации AO не имеют прототипа. Поэтому поиск всегда осуществляется только в AO и дальше в [[Scope]].

    Некоторые старые реализации могли задавать прототип AO, и поиск был бы AO.[[Prototype]] -> [[Scope]], но в данный момент такого поведения не наблюдается.