Перейти к основному содержимому

Понимание спецификации ECMAScript, часть 2

· 10 мин. чтения
[Марья Хёльтта](https://twitter.com/marjakh), наблюдатель за спекулятивной спецификацией

Давайте ещё немного попрактикуемся в наших замечательных навыках чтения спецификации. Если вы ещё не посмотрели предыдущий эпизод, сейчас самое время это сделать!

Все эпизоды

Готовы ко второй части?

Весёлый способ узнать спецификацию — начать с функции JavaScript, о которой мы знаем, и узнать, как она описана.

Внимание! Этот эпизод содержит скопированные алгоритмы из спецификации ECMAScript состоянием на февраль 2020 года. Они со временем устареют.

Мы знаем, что свойства ищутся в цепочке прототипов: если объект не имеет свойства, которое мы пытаемся считать, мы поднимаемся по цепочке прототипов, пока не найдем его (или пока не найдем объект, у которого больше нет прототипа).

Например:

const o1 = { foo: 99 };
const o2 = {};
Object.setPrototypeOf(o2, o1);
o2.foo;
// → 99

Где определена проходка по прототипу?

Попробуем найти, где определено такое поведение. Хорошее место для начала — это список внутренних методов объекта.

Есть как [[GetOwnProperty]], так и [[Get]] — нас интересует версия, которая не ограничена только собственными свойствами, поэтому мы выбираем [[Get]].

К сожалению, тип спецификации описателя свойства также имеет поле [[Get]], поэтому при просмотре спецификации для [[Get]] нам нужно тщательно различать два независимых использования.

[[Get]] — это существенный внутренний метод. Обычные объекты реализуют поведение по умолчанию для существенных внутренних методов. Экзотические объекты могут определять свои собственные внутренние методы [[Get]], которые отклоняются от поведения по умолчанию. В этом посте мы сосредоточимся на обычных объектах.

Реализация по умолчанию для [[Get]] делегируется к OrdinaryGet:

[[Get]] ( P, Receiver )

Когда внутренний метод [[Get]] объекта O вызывается с ключом свойства P и значением языка ECMAScript Receiver, выполняются следующие шаги:

  1. Вернуть ? OrdinaryGet(O, P, Receiver).

Мы вскоре увидим, что Receiver — это значение, которое используется как значение this при вызове функции-геттера свойства доступа.

OrdinaryGet определён следующим образом:

OrdinaryGet ( O, P, Receiver )

Когда абстрактная операция OrdinaryGet вызывается с объектом O, ключом свойства P и значением языка ECMAScript Receiver, выполняются следующие шаги:

  1. Утвердить: IsPropertyKey(P) равно true.
  2. Пусть desc будет ? O.[[GetOwnProperty]](P).
  3. Если desc равно undefined, то
    1. Пусть parent будет ? O.[[GetPrototypeOf]]().
    2. Если parent равно null, вернуть undefined.
    3. Вернуть ? parent.[[Get]](P, Receiver).
  4. Если IsDataDescriptor(desc) равно true, вернуть desc.[[Value]].
  5. Утвердить: IsAccessorDescriptor(desc) равно true.
  6. Пусть getter будет desc.[[Get]].
  7. Если getter равно undefined, вернуть undefined.
  8. Вернуть ? Call(getter, Receiver).

Проходка по цепочке прототипов находится в шаге 3: если мы не найдём свойство как собственное, мы вызываем метод [[Get]] прототипа, который снова делегируется к OrdinaryGet. Если мы всё ещё не найдём свойство, мы вызываем метод [[Get]] его прототипа, который снова делегируется к OrdinaryGet, и так далее, пока мы либо не найдём свойство, либо не достигнем объекта без прототипа.

Посмотрим, как работает этот алгоритм, когда мы обращаемся к o2.foo. Сначала мы вызываем OrdinaryGet, где O равно o2, а P равно "foo". O.[[GetOwnProperty]]("foo") возвращает undefined, так как o2 не имеет собственного свойства с именем "foo", поэтому мы переходим к ветке в шаге 3. В шаге 3.a мы задаём parent как прототип o2, который является o1. parent не равен null, поэтому мы не возвращаем в шаге 3.b. В шаге 3.c мы вызываем метод [[Get]] прототипа со ключом свойства "foo" и возвращаем то, что он возвращает.

Родитель (o1) является обычным объектом, поэтому его метод [[Get]] вызывает OrdinaryGet снова, на этот раз с O, равным o1, и P, равным "foo". o1 имеет собственное свойство с именем "foo", поэтому в шаге 2 O.[[GetOwnProperty]]("foo") возвращает связанный описатель свойства, и мы сохраняем его в desc.

Дескриптор свойства является типом спецификации. Дескрипторы свойства данных хранят значение свойства непосредственно в поле [[Value]]. Дескрипторы свойств аксессоров хранят функции доступа в полях [[Get]] и/или [[Set]]. В этом случае дескриптор свойства, связанный с "foo", является дескриптором свойства данных.

Дескриптор свойства данных, который мы сохранили в desc на этапе 2, не равен undefined, поэтому мы не переходим в ветку if на этапе 3. Далее мы выполняем шаг 4. Дескриптор свойства является дескриптором свойства данных, поэтому мы возвращаем его поле [[Value]], то есть 99, на этапе 4, и на этом заканчиваем.

Что такое Receiver и откуда он берётся?

Параметр Receiver используется только в случае свойств аксессоров на шаге 8. Он передаётся как значение this при вызове функции геттера свойства аксессора.

OrdinaryGet передаёт исходный Receiver через всю рекурсию без изменений (шаг 3.c). Давайте выясним, откуда изначально берётся Receiver!

Ищем места, где вызывается [[Get]]. Мы находим абстрактную операцию GetValue, которая работает с ссылками. Ссылка — это тип спецификации, состоящий из базового значения, имени ссылки и флага строгой ссылки. В случае o2.foo базовое значение — это объект o2, имя ссылки — строка "foo", а флаг строгой ссылки равен false, так как пример кода является нестрогим.

Лирическое отступление: Почему ссылка не является записью?

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

Возвращаемся к GetValue

Посмотрим, как определён GetValue:

GetValue ( V )

  1. ReturnIfAbrupt(V).
  2. Если Type(V) не равно Reference, вернуть V.
  3. Пусть base будет GetBase(V).
  4. Если IsUnresolvableReference(V) равно true, выбросить исключение ReferenceError.
  5. Если IsPropertyReference(V) равно true, тогда
    1. Если HasPrimitiveBase(V) равно true, тогда
      1. Утверждать: В этом случае base никогда не будет undefined или null.
      2. Установить base как ! ToObject(base).
    2. Вернуть ? base.[[Get]](GetReferencedName(V), GetThisValue(V)).
  6. Иначе,
    1. Утверждать: base является записью окружения.
    2. Вернуть ? base.GetBindingValue(GetReferencedName(V), IsStrictReference(V))

Ссылка в нашем примере — это o2.foo, которая является ссылкой на свойство. Так что мы выбираем ветку 5. Мы не выбираем ветку 5.a, так как базовое значение (o2) не является примитивным значением (Number, String, Symbol, BigInt, Boolean, Undefined или Null).

Затем мы вызываем [[Get]] на шаге 5.b. Receiver, который мы передаём, — это GetThisValue(V). В данном случае это просто базовое значение ссылки:

GetThisValue( V )

  1. Утверждать: IsPropertyReference(V) равно true.
  2. Если IsSuperReference(V) равно true, тогда
    1. Вернуть значение компонента thisValue ссылки V.
  3. Вернуть GetBase(V).

Для o2.foo мы не выбираем ветку на шаге 2, поскольку это не ссылка супертипа (например, super.foo), но выбираем шаг 3 и возвращаем базовое значение ссылки, которое равно o2.

Объединяя всё вместе, мы выясняем, что устанавливаем Receiver как базовое значение исходной ссылки и затем сохраняем его без изменений на протяжении обхода цепочки прототипов. В итоге, если найденное свойство является аксессором, мы используем Receiver как значение this при его вызове.

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

Давайте попробуем!

const o1 = { x: 10, get foo() { return this.x; } };
const o2 = { x: 50 };
Object.setPrototypeOf(o2, o1);
o2.foo;
// → 50

В этом примере у нас есть аксессор foo, и мы определяем для него геттер. Геттер возвращает this.x.

Затем мы обращаемся к o2.foo — что возвращает геттер?

Мы выяснили, что при вызове геттера значение this — это объект, откуда мы изначально пытались получить свойство, а не объект, где мы его нашли. В данном случае значение this это o2, а не o1. Мы можем подтвердить это, проверив, возвращает ли геттер o2.x или o1.x, и действительно, он возвращает o2.x.

Все работает! Мы смогли предсказать поведение этого примера кода, основываясь на прочитанном в спецификации.

Обращение к свойствам — почему оно вызывает [[Get]]?

Где в спецификации указано, что внутренний метод объекта [[Get]] будет вызван при обращении к свойству, как o2.foo? Несомненно, это должно быть где-то определено. Не верьте мне на слово!

Мы выяснили, что внутренний метод объекта [[Get]] вызывается из абстрактной операции GetValue, которая работает с ссылками. Но откуда вызывается GetValue?

Семантика выполнения для MemberExpression

Правила грамматики спецификации определяют синтаксис языка. Семантика выполнения определяет, что означают синтаксические конструкции (как их оценивать во время выполнения).

Если вы не знакомы с контекстно-свободными грамматиками, это хорошая идея ознакомиться с ними сейчас!

Мы углубимся в правила грамматики в дальнейшем, пока давайте оставим их простыми! В частности, мы можем игнорировать нижние индексы (Yield, Await и так далее) в производствах на этот раз.

Следующие производства описывают, как выглядит MemberExpression:

MemberExpression :
PrimaryExpression
MemberExpression [ Expression ]
MemberExpression . IdentifierName
MemberExpression TemplateLiteral
SuperProperty
MetaProperty
new MemberExpression Arguments

У нас есть 7 производств для MemberExpression. MemberExpression может быть просто PrimaryExpression. Альтернативно, MemberExpression можно построить из другого MemberExpression и Expression, объединив их: например, MemberExpression [ Expression ], например o2['foo']. Или это может быть MemberExpression . IdentifierName, например o2.foo — это производство связано с нашим примером.

Семантика выполнения для производства MemberExpression : MemberExpression . IdentifierName определяет набор шагов при его оценке:

Семантика выполнения: Оценка для MemberExpression : MemberExpression . IdentifierName

  1. Пусть baseReference будет результатом оценки MemberExpression.
  2. Пусть baseValue будет ? GetValue(baseReference).
  3. Если код, соответствующий этому MemberExpression, является кодом строгого режима, пусть strict будет true; иначе пусть strict будет false.
  4. Вернуть ? EvaluatePropertyAccessWithIdentifierKey(baseValue, IdentifierName, strict).

Алгоритм делегирует выполнение абстрактной операции EvaluatePropertyAccessWithIdentifierKey, поэтому нам также нужно её прочитать:

EvaluatePropertyAccessWithIdentifierKey( baseValue, identifierName, strict )

Абстрактная операция EvaluatePropertyAccessWithIdentifierKey принимает значения baseValue, узел анализа identifierName и логический аргумент strict. Она выполняет следующие шаги:

  1. Утверждение: identifierName является IdentifierName
  2. Пусть bv будет ? RequireObjectCoercible(baseValue).
  3. Пусть propertyNameString будет StringValue от identifierName.
  4. Вернуть значение типа Reference, компонентом базового значения которого является bv, компонентом имени ссылки которого является propertyNameString, и флагом строгой ссылки является strict.

Это значит, что: EvaluatePropertyAccessWithIdentifierKey создаёт Reference, который использует предоставленное baseValue как базовое значение, строковое значение identifierName как имя свойства, и strict как флаг режима строгого выполнения.

В конечном итоге этот Reference передаётся в GetValue. Это определения расположены в нескольких местах спецификации, в зависимости от того, как Reference используется.

MemberExpression как параметр

В нашем примере мы используем доступ к свойству в качестве параметра:

console.log(o2.foo);

В этом случае поведение определяется семантикой выполнения производства ArgumentList, которое вызывает GetValue для аргумента:

Семантика выполнения: ArgumentListEvaluation

ArgumentList : AssignmentExpression

  1. Пусть ref будет результатом оценки AssignmentExpression.
  2. Пусть arg будет ? GetValue(ref).
  3. Вернуть список, единственным элементом которого является arg.

o2.foo не выглядит как AssignmentExpression, но он им является, поэтому это производство применимо. Чтобы узнать, почему, вы можете изучить этот дополнительный материал, хотя это не строго необходимо на данном этапе.

Выражение AssignmentExpression на шаге 1 – это o2.foo. ref, результат оценки o2.foo, – это вышеупомянутый Reference. На шаге 2 мы вызываем GetValue для него. Таким образом, мы знаем, что будет вызван метод объекта [[Get]], и произойдет проход по цепочке прототипов.

Резюме

В этом эпизоде мы рассмотрели, как спецификация определяет языковую функцию, в данном случае поиск по прототипу, на всех слоях: синтаксические конструкции, которые запускают функцию, и алгоритмы, её определяющие.