Как стать автором
Обновить

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

Уровень сложностиСложный
Время на прочтение10 мин
Количество просмотров1.3K
Автор оригинала: Marja Hölttä

Привет, Хабр! Представляю вашему вниманию перевод третьей статьи автора Marja Hölttä из цикла Understanding ECMAScript. Материал статьи актуальный для версии EcmaScript2025. Перевод первой статьи. Перевод второй статьи.

В этой части мы углубимся в дефиниции языка спецификации ECMAScript и её синтаксис. Если вы не знакомы с безконтекстной (context-free) грамматикой, сейчас самое время ознакомиться с основами, поскольку спецификация использует context-free грамматику для определения языка. В качестве ликбеза можете посмотреть главу о context-free грамматике в разделе "Crafting Interpreters", для изучения такой грамматики с точки зрения математического аппарата можно почитать страницу в Википедии.

Виды грамматик в ECMAScript

Спецификация ECMAScript выделяет четыре вида грамматики (grammar):

  • Lexical grammar - описывает как кодпоинты Unicode преобразуются в последовательность входящих элементов (input elements) - токенов, символов конца строки, комментариев, пробельных символов.

  • Syntactic grammar - описывает как последовательности токенов формируют синтаксически верные независимые части программы.

  • RegExp grammar - описывает как кодпоинты Unicode преобразуются в регулярные выражения (RegExp).

  • Numeric string grammar - описывает как строки (Strings) преобразуются в числовые значения.

Каждая из указанных грамматик является context-free, т.е. состоящей из набора абстрактных символов - нетерминалов (nonterminal или production).

Для обозначения вида грамматики используются символы:

  • для обозначения syntactic grammar используется символ :, например как в LeftHandSideSymbol :;

  • для обозначения lexical grammar и RegExp grammar используется символ ::, например как в LeftHandSideSymbol ::;

  • для обозначения numeric string grammar используется символ :::, например как LeftHandSideSymbol :::.

Рассмотрим более детально lexical grammar и syntactic grammar.

Lexical grammar

Спецификация определяет исходный текст ECMAScript как последовательность кодпоинтов Unicode. Например, для именования переменных можно не ограничиваться символами ASCII и можно использовать другие символы Unicode. В спецификации ничего не говорится о фактической кодировке (например, UTF-8 или UTF-16). Предполагается, что исходный код уже был преобразован в последовательность кодпоинтов Unicode в соответствии с кодировкой, в которой он был предоставлен.

Невозможно заранее разделить на токены исходный ECMAScript код, что несколько усложняет работу lexical grammar. Например, мы не можем определить, является ли / оператором деления или началом RegExp, не рассмотрев контекст, в котором этот символ используется:

const x = 10 / 5;

Здесь / - DIVPunctuator.

const r = /foo/;

Здесь первый символ / распознаётся как начало RegularExpressionLiteral.

Шаблонные строки также вносят похожую двусмысленность. Интерпретация символов }` зависит от контекста, в котором они приведены:

const what1 = 'temp';
const what2 = 'late';
const t = `I am a ${ what1 + what2 }`;

Здесь `I am a ${ это TemplateHead, а }` - TemplateTail.

if (0 == 1) {
}`not very useful`;

Здесь токен }` уже распознаётся как RightBracePunctuator и токен ` распознаётся как начало NoSubstitutionTemplate.

Несмотря на то, что интерпретация / и }` зависит от контекста их использования, т.е. от их положения в синтаксической структуре кода, виды грамматики, которые описаны далее, по-прежнему являются context-free.

Lexical grammar использует несколько целевых (goal) символов, чтобы различать контексты, в которых некоторые input elements разрешены, а некоторые - нет. Например, goal символ InputElementDiv используется в контексте, где / - символ деления, а /= - символ присваивания с делением. В нетерминале InputElementDiv перечислены возможные токены, которые могут быть созданы в этом контексте:

InputElementDiv ::
  WhiteSpace
  LineTerminator
  Comment
  CommonToken
  DivPunctuator
  RightBracePunctuator

Появление в этом контексте символа / приведет к созданию input element DivPunctuator. Создание RegularExpressionLiteral исключено.

В другом случае, InputElementRegExp - goal символ для контекста, где токен / распознаётся как начало RegExp:

InputElementRegExp ::
  WhiteSpace
  LineTerminator
  Comment
  CommonToken
  RightBracePunctuator
  RegularExpressionLiteral

Как видно из списка возможных токенов, тут возможно создать RegularExpressionLiteral input element, но создание DivPunctuator исключено.

Аналогично, существует еще один goal символ, InputElementRegExpOrTemplateTail, для контекстов, где допускаются TemplateMiddle и TemplateTail, в дополнение к RegularExpressionLiteral. И, наконец, InputElementTemplateTail - это goal символ для контекстов, где допускаются только TemplateMiddle и TemplateTail, а RegularExpressionLiteral не допускается.

При имплементации, синтаксический грамматический анализатор (parser) может применять лексический анализатор (tokenizer или lexer), передавая goal символ как параметр и запрашивая следующий input element, который подходит для переданного goal символа.

Syntactic grammar

Мы рассмотрели lexical grammar, которая определяет, как мы создаем токены из кодпоинтов Unicode. На ней основывается syntactic grammar, которая определяет, как синтаксически корректные программы составляются из токенов.

Пример: разрешенное использования устаревших (legacy) идентификаторов

Введение нового ключевого слова в грамматику, может нарушить обратную совместимость (breaking change). А что, если существующий код уже использует это ключевое слово в качестве идентификатора?

Например, до того, как await стало ключевым словом, кто-то мог написать следующий код:

function old() {
  var await;
}

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

async function modern() {
  var await; // Syntax error
}

Аналогично разрешается использовать yield в качестве идентификатора в функциях, но запрещается в функциях генераторах.

Понимание того, когда применение await в качестве идентификатора разрешено, требует понимания нотации syntactic grammar, специфичной для ECMAScript. Давайте разбираться!

Нетерминалы и их сокращенная запись (shorthands)

Давайте рассмотрим как определяются нетерминалы VariableStatement. На первый взгляд грамматика может показаться немного пугающей:

VariableStatement[Yield, Await] :
  var VariableDeclarationList[+In, ?Yield, ?Await] ;

Что значат нижние индексы [Yield, Await] и префиксы (+ в +In и ? в ?Async)?

Обозначения описаны в главе Grammar Notation.

Индексы - это shorthand для перечисления возможных перестановок набора нетерминалов, используемых как символы левой стороны (left-hand side). В примере кода выше, left-hand side символ имеет два параметра, которые расширяются до четырех "реальных" left-hand side символов: VariableStatement, VariableStatement_Yield, VariableStatement_Await и VariableStatement_Yield_Await.

Обратите внимание, что здесь выражение VariableStatement означает “VariableStatement без _Await и _Yield”. Его не следует путать с записью VariableStatement[Yield, Await].

С правой стороны (right-hand side) нетерминала мы видим shorthand +In, которое означает "Добавить к названию _In", и ?Await, что означает “Добавить к названию _Await тогда и только тогда, когда в left-hand side символе есть _Await” (аналогично с ?Yield).

Третий shorthand - ~Foo, означает “использовать версию без _Foo” (в нетерминале выше не используется).

Обладая этой информацией, мы можем развернуть нетерминал следующим образом:

VariableStatement :
  var VariableDeclarationList_In ;

VariableStatement_Yield :
  var VariableDeclarationList_In_Yield ;

VariableStatement_Await :
  var VariableDeclarationList_In_Await ;

VariableStatement_Yield_Await :
  var VariableDeclarationList_In_Yield_Await ;

В конечном счете, нам нужно выяснить две вещи:

  • Как определить, рассматриваем ли мы нетерминал с _Await или без _Await?

  • Где это имеет значение — где расходятся нетерминалы для Something_Await и Something (без _Await) будут различаться?

С _Await или без _Await?

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

AsyncFunctionBody :
  FunctionBody[~Yield, +Await]

Обратите внимание, что AsyncFunctionBody не имеет параметров — они добавляются в FunctionBody как right-hand side символ.

Если мы развернём запись этого нетерминала, мы получим:

AsyncFunctionBody :
  FunctionBody_Await

Другими словами, асинхронные функции имеют FunctionBody_Await, что означает тело функции, где await рассматривается как ключевое слово.

С другой стороны, если мы находимся внутри синхронной функции, то соответствующий нетерминал будет:

FunctionDeclaration[Yield, Await, Default] :
  function BindingIdentifier[?Yield, ?Await] ( FormalParameters[~Yield, ~Await] ) { FunctionBody[~Yield, ~Await] }

FunctionDeclaration есть другой нетерминал, но он не имеет отношения к нашему примеру кода.)

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

Развернутая форма нетерминала выглядит так:

FunctionDeclaration :
  function BindingIdentifier ( FormalParameters ) { FunctionBody }

FunctionDeclaration_Yield :
  function BindingIdentifier_Yield ( FormalParameters ) { FunctionBody }

FunctionDeclaration_Await :
  function BindingIdentifier_Await ( FormalParameters ) { FunctionBody }

FunctionDeclaration_Yield_Await :
  function BindingIdentifier_Yield_Await ( FormalParameters ) { FunctionBody }

В этом нетерминале мы всегда получаем FunctionBody и FormalParameters (без _Yield и без _Await), поскольку в сокращенной записи нетерминала они параметризуются с помощью "[~Yield, ~Await]".

Имя функции обрабатывается по-другому: оно получает параметры _Await и _Yield, если они есть в left-hand side символе.

Подводя итог: асинхронные функции имеют FunctionBody_Await, а синхронные функции имеют FunctionBody (без _Await). Поскольку мы говорим о функциях, не являющихся генераторами, как наша асинхронная функция-пример, так и наша синхронная функция-пример параметризуются без _Yield.

Бывает трудно запомнить, какой из них FunctionBody, а какой FunctionBody_Await. FunctionBody_Await для функции, где await - идентификатор, или для функции, где await - ключевое слово?

Вы можете запомнить, что использование параметра _Await означает "await - это ключевое слово". Этот подход также удобен для будущих модификаций. Представьте, что добавляется новое ключевое слово blob, но только внутри функций blobby. Не-blobby функции, синхронные функции, функции не-генераторы по-прежнему будут иметь FunctionBody (без _Await, без _Yield или без _Blob), точно так же, как и сейчас. Blobby функции будут иметь Function Body_Blob, асинхронные blobby функции будут иметь FunctionBody_Await_Blob и так далее. Нам все равно нужно было бы добавить индекс Blob в нетерминалы, но развернутые формы FunctionBody для уже существующих функций остаются прежними.

Запрет использования слова await как идентификатора

Далее, нам нужно выяснить, как накладывается запрет на использование слова await в качестве идентификатора, если мы находимся внутри FunctionBody_Await.

Если мы рассмотрим нетерминалы дальше, то увидим, что параметр _Await переносится без изменений из FunctionBody вплоть до нетерминала VariableStatement, который мы рассматривали ранее.

Таким образом, внутри асинхронной функции у нас будет VariableStatement_Await, а внутри синхронной функции у нас будет VariableStatement.

Мы можем проследить за нетерминалами дальше, обращая внимание на их параметры. Мы уже видели нетерминал для VariableStatement:

VariableStatement[Yield, Await] :
  var VariableDeclarationList[+In, ?Yield, ?Await] ;

Все нетерминалы из списка VariableDeclarationList просто пробрасывают параметры в неизменном виде:

VariableDeclarationList[In, Yield, Await] :
  VariableDeclaration[?In, ?Yield, ?Await]

(Здесь приведены только нетерминалы релевантные нашему примеру)

VariableDeclaration[In, Yield, Await] :
  BindingIdentifier[?Yield, ?Await] Initializer[?In, ?Yield, ?Await] opt

Shorthand opt означает, что right-hand side символ является необязательным (optional). На самом деле здесь указаны два варианта нетерминала, один с optional символом, а другой без него.

В простом случае, релевантному нашему примеру, инструкция VariableStatement состоит из ключевого слова var, за которым следует один BindingIdentifier без инициализатора и заканчивается точкой с запятой.

Чтобы запретить или разрешить await в качестве BindingIdentifier, можно ожидать увидеть что-то вроде этого:

BindingIdentifier_Await :
  Identifier
  yield

BindingIdentifier :
  Identifier
  yield
  await

Это позволило бы запретить await в качестве идентификатора внутри асинхронных функций и разрешить его в качестве идентификатора внутри синхронных функций.

Но в спецификации это определяется по-другому, мы находим такой нетерминал:

BindingIdentifier[Yield, Await] :
  Identifier
  yield
  await

В расширенном виде это означает следующие нетерминалы:

BindingIdentifier_Await :
  Identifier
  yield
  await

BindingIdentifier :
  Identifier
  yield
  await

(Мы не рассматриваем нетерминалы для BindingIdentifier_Yield и BindingIdentifier_Yield_Await, т.к. не используем их в нашем примере)

Это выглядит будто await и yield всегда будут разрешены в качестве идентификаторов. Что с этим не так? Неужели весь этот пост написан впустую?

Statics semantics спешит на помощь

Оказывается, для запрета await в качестве идентификатора внутри асинхронных функций приходится использовать static semantics.

Static semantics описывает статические правила, то есть правила, которые проверяются перед запуском программы.

В этом случае static semantics для BindingIdentifier определяет следующее синтаксическое правило:

BindingIdentifier[Yield, Await] : await

Это правило вызывает синтаксическую ошибку (Syntax Error), если в этом нетерминале есть параметр [Await].

Фактически, это правило запрещает нетерминал BindingIdentifier_Await : await.

Спецификация объясняет, зачем нужно определять такой нетерминал, но объявлять его Syntax Error через static semantics. Это нужно для корректной работы системы расстановки точек с запятой (automatic semicolon insertion) - ASI.

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

Рассмотрим следующий код (пример из спецификации):

async function too_few_semicolons() {
  let
  await 0;
}

Если грамматика запрещает использовать await в качестве идентификатора, ASI включится и преобразует код в следующий грамматически правильный код, который также использует let в качестве идентификатора:

async function too_few_semicolons() {
  let;
  await 0;
}

Такое поведение с использованием ASI было сочтено слишком запутанным, поэтому для запрета использования await в качестве идентификатора была использована static semantics.

Запрещенные строковые значения (StringValues) идентификаторов

Есть также еще одно связанное правило:

BindingIdentifier : Identifier

Это вызывает Syntax Error, если в этом нетерминале есть параметр [Await] и StringValue идентификатора равно await.

Поначалу это может привести к путанице. Identifier определяется следующим образом:

Identifier :
  IdentifierName but not ReservedWord

await является ReservedWord, так как же Identifier может быть await?

Как оказалось, Identifier не может быть await, но это может быть чем-то, чье StringValue является await - другое представление последовательности символов await.

Static semantics определяет способ вычисления имен идентификаторов вычисляя StringValue имени идентификатора. Например, в Unicode символу a соответствует последовательность \u0061, поэтому \u0061wait имеет значение StringValue = await. \u0061wait не будет распознан lexical grammar как ключевое слово, вместо этого такая последовательность символов станет Identifier. Static semantics запрещает использовать его в качестве имени переменной внутри асинхронных функций.

Этот код будет работать:

function old() {
  var \u0061wait;
}

А этот код вызовет Syntax error:

async function modern() {
  var \u0061wait; // Syntax error
}

Выводы

В этом выпуске мы ознакомились с lexical grammar, syntactic grammar и shorthands, используемыми в syntactic grammar. В качестве примера мы рассмотрели вопрос о том как запретить использования await в качестве идентификатора внутри асинхронных функций, но разрешить его внутри синхронных функций.

Другие интересные аспекты работы syntactic grammar, например, система автоматической постановки точки с запятой, будут рассмотрены в других статьях. Оставайтесь с нами!

Теги:
Хабы:
+10
Комментарии0

Публикации

Истории

Работа

Ближайшие события