Привет! Представляю вашему вниманию перевод третьей статьи автора 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, например, система автоматической постановки точки с запятой, будут рассмотрены в других статьях. Оставайтесь с нами!
Автор: Mirved64