Compreendendo a especificação do ECMAScript, parte 3
Neste episódio, aprofundaremos na definição da linguagem ECMAScript e sua sintaxe. Se você não está familiarizado com gramáticas livres de contexto, agora é um bom momento para revisar o básico, já que a especificação utiliza gramáticas livres de contexto para definir a linguagem. Consulte o capítulo sobre gramáticas livres de contexto em "Crafting Interpreters" para uma introdução acessível ou a página da Wikipédia para uma definição mais matemática.
Gramáticas ECMAScript
A especificação ECMAScript define quatro gramáticas:
A gramática lexical descreve como os pontos de código Unicode são traduzidos em uma sequência de elementos de entrada (tokens, terminadores de linha, comentários, espaços em branco).
A gramática sintática define como programas sintaticamente corretos são compostos de tokens.
A gramática de RegExp descreve como os pontos de código Unicode são traduzidos em expressões regulares.
A gramática de string numérica descreve como Strings são traduzidas em valores numéricos.
Cada gramática é definida como uma gramática livre de contexto, composta por um conjunto de produções.
As gramáticas usam notações ligeiramente diferentes: a gramática sintática usa LeftHandSideSymbol :
enquanto a gramática lexical e a de RegExp usam LeftHandSideSymbol ::
e a gramática de string numérica usa LeftHandSideSymbol :::
.
A seguir, examinaremos a gramática lexical e a gramática sintática com mais detalhes.
Gramática lexical
A especificação define o texto fonte do ECMAScript como uma sequência de pontos de código Unicode. Por exemplo, os nomes de variáveis não se limitam a caracteres ASCII, mas podem incluir outros caracteres Unicode. A especificação não trata sobre a codificação real (por exemplo, UTF-8 ou UTF-16). Ela assume que o código fonte já foi convertido em uma sequência de pontos de código Unicode de acordo com a codificação em que estava.
Não é possível tokenizar o código fonte ECMAScript com antecedência, o que torna a definição da gramática lexical um pouco mais complicada.
Por exemplo, não podemos determinar se /
é o operador de divisão ou o início de uma RegExp sem analisar o contexto maior em que ocorre:
const x = 10 / 5;
Aqui, /
é um DivPunctuator
.
const r = /foo/;
Aqui, o primeiro /
é o início de um RegularExpressionLiteral
.
Modelos introduzem uma ambiguidade semelhante — a interpretação de }`
depende do contexto em que ocorre:
const what1 = 'temp';
const what2 = 'late';
const t = `Eu sou um(a) ${ what1 + what2 }`;
Aqui `Eu sou um(a) ${
é um TemplateHead
e }`
é um TemplateTail
.
if (0 == 1) {
}`não muito útil`;
Aqui }
é um RightBracePunctuator
e `
é o início de um NoSubstitutionTemplate
.
Embora a interpretação de /
e }`
dependa de seu “contexto” — sua posição na estrutura sintática do código — as gramáticas que descreveremos a seguir ainda são livres de contexto.
A gramática lexical utiliza vários símbolos-alvo para distinguir entre os contextos onde alguns elementos de entrada são permitidos e outros não. Por exemplo, o símbolo-alvo InputElementDiv
é usado em contextos onde /
é uma divisão e /=
é uma divisão com atribuição. As produções de InputElementDiv
listam os possíveis tokens que podem ser produzidos neste contexto:
InputElementDiv ::
WhiteSpace
LineTerminator
Comment
CommonToken
DivPunctuator
RightBracePunctuator
Neste contexto, encontrar /
produz o elemento de entrada DivPunctuator
. Produzir um elemento de entrada RegularExpressionLiteral
não é uma opção aqui.
Por outro lado, InputElementRegExp
é o símbolo-alvo para os contextos onde /
é o início de uma RegExp:
InputElementRegExp ::
WhiteSpace
LineTerminator
Comment
CommonToken
RightBracePunctuator
RegularExpressionLiteral
Como vemos nas produções, é possível que isso produza o elemento de entrada RegularExpressionLiteral
, mas produzir DivPunctuator
não é possível.
Da mesma forma, há outro símbolo de objetivo, InputElementRegExpOrTemplateTail
, para contextos onde TemplateMiddle
e TemplateTail
são permitidos, além de RegularExpressionLiteral
. E, finalmente, InputElementTemplateTail
é o símbolo de objetivo para contextos onde apenas TemplateMiddle
e TemplateTail
são permitidos, mas RegularExpressionLiteral
não é permitido.
Em implementações, o analisador gramatical sintático (“parser”) pode chamar o analisador gramatical lexical (“tokenizador” ou “lexer”), passando o símbolo de objetivo como parâmetro e solicitando o próximo elemento de entrada adequado para esse símbolo de objetivo.
Gramática sintática
Examinamos a gramática lexical, que define como construímos tokens a partir de pontos de código Unicode. A gramática sintática constrói sobre isso: define como programas sintaticamente corretos são compostos de tokens.
Exemplo: Permitindo identificadores legados
Introduzir uma nova palavra-chave na gramática pode causar uma mudança potencialmente disruptiva — e se o código existente já usar a palavra-chave como identificador?
Por exemplo, antes de await
ser uma palavra-chave, alguém poderia ter escrito o seguinte código:
function old() {
var await;
}
A gramática do ECMAScript adicionou cuidadosamente a palavra-chave await
de forma que este código continue funcionando. Dentro de funções assíncronas, await
é uma palavra-chave, então isso não funciona:
async function modern() {
var await; // Erro de sintaxe
}
Permitir yield
como um identificador em não-geradores e proibí-lo em geradores funciona de forma similar.
Entender como await
é permitido como um identificador exige entender a notação gramatical específica do ECMAScript. Vamos mergulhar nisso!
Produções e abreviações
Vamos observar como as produções para VariableStatement
são definidas. À primeira vista, a gramática pode parecer um pouco assustadora:
VariableStatement[Yield, Await] :
var VariableDeclarationList[+In, ?Yield, ?Await] ;
O que os subscritos ([Yield, Await]
) e prefixos (+
em +In
e ?
em ?Async
) significam?
A notação é explicada na seção Notação Gramatical.
Os subscritos são uma abreviação para expressar um conjunto de produções, para um conjunto de símbolos no lado esquerdo, todos de uma vez. O símbolo no lado esquerdo tem dois parâmetros, o que se expande em quatro símbolos "reais" no lado esquerdo: VariableStatement
, VariableStatement_Yield
, VariableStatement_Await
e VariableStatement_Yield_Await
.
Note que aqui o simples VariableStatement
significa "VariableStatement
sem _Await
e _Yield
". Não deve ser confundido com VariableStatement[Yield, Await]
.
No lado direito da produção, vemos a abreviação +In
, significando "usar a versão com _In
", e ?Await
, significando "usar a versão com _Await
se e somente se o símbolo do lado esquerdo tiver _Await
" (de forma similar com ?Yield
).
A terceira abreviação, ~Foo
, significando "usar a versão sem _Foo
", não é usada nesta produção.
Com essas informações, podemos expandir as produções assim:
VariableStatement :
var VariableDeclarationList_In ;
VariableStatement_Yield :
var VariableDeclarationList_In_Yield ;
VariableStatement_Await :
var VariableDeclarationList_In_Await ;
VariableStatement_Yield_Await :
var VariableDeclarationList_In_Yield_Await ;
No final das contas, precisamos descobrir duas coisas:
- Onde é decidido se estamos no caso com
_Await
ou sem_Await
? - Onde isso faz diferença — onde as produções para
Something_Await
eSomething
(sem_Await
) divergem?
_Await
ou sem _Await
?
Vamos abordar a questão 1 primeiro. É relativamente fácil adivinhar que funções não-assíncronas e assíncronas diferem no uso do parâmetro _Await
para o corpo da função ou não. Ao ler as produções para declarações de funções assíncronas, encontramos isto:
AsyncFunctionBody :
FunctionBody[~Yield, +Await]
Note que AsyncFunctionBody
não possui parâmetros — eles são adicionados ao FunctionBody
no lado direito.
Se expandirmos esta produção, obtemos:
AsyncFunctionBody :
FunctionBody_Await
Em outras palavras, funções assíncronas têm FunctionBody_Await
, o que significa um corpo de função onde await
é tratado como uma palavra-chave.
Por outro lado, se estivermos dentro de uma função não-assíncrona, a produção relevante é:
FunctionDeclaration[Yield, Await, Default] :
function BindingIdentifier[?Yield, ?Await] ( FormalParameters[~Yield, ~Await] ) { FunctionBody[~Yield, ~Await] }
(O FunctionDeclaration
tem outra produção, mas não é relevante para nosso exemplo de código.)
Para evitar expansão combinatória, vamos ignorar o parâmetro Default
, que não é usado nesta produção específica.
A forma expandida da produção é:
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 }
Nesta produção, sempre obtemos FunctionBody
e FormalParameters
(sem _Yield
e sem _Await
), já que eles são parametrizados com [~Yield, ~Await]
na produção não expandida.
O nome da função é tratado de maneira diferente: ele recebe os parâmetros _Await
e _Yield
se o símbolo do lado esquerdo os tiver.
Para resumir: Funções assíncronas têm um FunctionBody_Await
e funções não assíncronas têm um FunctionBody
(sem _Await
). Como estamos falando de funções não geradoras, tanto nossa função assíncrona quanto nossa função não assíncrona de exemplo são parametrizadas sem _Yield
.
Talvez seja difícil lembrar qual é FunctionBody
e qual é FunctionBody_Await
. FunctionBody_Await
é para uma função onde await
é um identificador ou para uma função onde await
é uma palavra-chave?
Você pode pensar no parâmetro _Await
como significando "await
é uma palavra-chave". Esta abordagem também é preparada para o futuro. Imagine uma nova palavra-chave, blob
, sendo adicionada, mas apenas dentro de funções "blob". Funções não blob, não assíncronas e não geradoras ainda teriam FunctionBody
(sem _Await
, _Yield
ou _Blob
), exatamente como agora. Funções blob teriam um FunctionBody_Blob
, funções assíncronas blob teriam FunctionBody_Await_Blob
e assim por diante. Ainda precisaríamos adicionar o subscrito Blob
às produções, mas as formas expandidas de FunctionBody
para funções já existentes permanecem as mesmas.
Proibir await
como identificador
Em seguida, precisamos descobrir como await
é proibido como identificador se estivermos dentro de um FunctionBody_Await
.
Podemos seguir as produções mais adiante para ver que o parâmetro _Await
é levado inalterado de FunctionBody
até a produção VariableStatement
que estávamos olhando anteriormente.
Assim, dentro de uma função assíncrona, teremos um VariableStatement_Await
e dentro de uma função não assíncrona, teremos um VariableStatement
.
Podemos seguir as produções mais adiante e acompanhar os parâmetros. Já vimos as produções para VariableStatement
:
VariableStatement[Yield, Await] :
var VariableDeclarationList[+In, ?Yield, ?Await] ;
Todas as produções para VariableDeclarationList
apenas carregam os parâmetros como estão:
VariableDeclarationList[In, Yield, Await] :
VariableDeclaration[?In, ?Yield, ?Await]
(Aqui mostramos apenas a produção relevante para nosso exemplo.)
VariableDeclaration[In, Yield, Await] :
BindingIdentifier[?Yield, ?Await] Initializer[?In, ?Yield, ?Await] opt
O atalho opt
significa que o símbolo do lado direito é opcional; há, de fato, duas produções, uma com o símbolo opcional e outra sem.
No caso simples relevante ao nosso exemplo, VariableStatement
consiste na palavra-chave var
, seguida por um único BindingIdentifier
sem inicializador, e terminando com um ponto e vírgula.
Para proibir ou permitir await
como BindingIdentifier
, esperamos terminar com algo como isto:
BindingIdentifier_Await :
Identifier
yield
BindingIdentifier :
Identifier
yield
await
Isso proibiria await
como identificador dentro de funções assíncronas e permitiria como identificador dentro de funções não assíncronas.
Mas a especificação não o define assim, em vez disso encontramos esta produção:
BindingIdentifier[Yield, Await] :
Identifier
yield
await
Expandido, isso significa as seguintes produções:
BindingIdentifier_Await :
Identifier
yield
await
BindingIdentifier :
Identifier
yield
await
(Estamos omitindo as produções para BindingIdentifier_Yield
e BindingIdentifier_Yield_Await
que não são necessárias em nosso exemplo.)
Isso parece que await
e yield
seriam sempre permitidos como identificadores. O que está acontecendo? O blog inteiro é inútil?
Semântica estática ao resgate
Acontece que semântica estática são necessárias para proibir await
como identificador dentro de funções assíncronas.
Semântica estática descreve regras estáticas — ou seja, regras que são verificadas antes que o programa seja executado.
Neste caso, as semânticas estáticas para BindingIdentifier
definem a seguinte regra dirigida por sintaxe:
BindingIdentifier[Yield, Await] : await
É um erro de sintaxe se esta produção tiver um parâmetro
[Await]
.
Efetivamente, isso proíbe a produção BindingIdentifier_Await : await
.
A especificação explica que o motivo de ter essa produção mas defini-la como um Erro de Sintaxe pela semântica estática é devido à interferência com a inserção automática de ponto e vírgula (ASI).
Lembre-se de que o ASI entra em ação quando não conseguimos analisar uma linha de código de acordo com as produções gramaticais. O ASI tenta adicionar pontos e vírgulas para satisfazer o requisito de que declarações e declarações devem terminar com um ponto e vírgula. (Descreveremos o ASI com mais detalhes em um episódio posterior.)
Considere o seguinte código (exemplo da especificação):
async function poucas_virgulas() {
let
await 0;
}
Se a gramática não permitisse await
como um identificador, o ASI entraria em ação e transformaria o código no seguinte código gramaticalmente correto, que também usa let
como um identificador:
async function poucas_virgulas() {
let;
await 0;
}
Esse tipo de interferência com o ASI foi considerado muito confuso, então a semântica estática foi usada para impedir o uso de await
como um identificador.
StringValues
de identificadores não permitidos
Há também outra regra relacionada:
BindingIdentifier : Identifier
É um Erro de Sintaxe se esta produção tiver um parâmetro
[Await]
e oStringValue
deIdentifier
for"await"
.
Isso pode ser confuso no início. Identifier
é definido assim:
Identifier :
IdentifierName mas não ReservedWord
await
é uma ReservedWord
, então como um Identifier
pode ser await
?
Acontece que Identifier
não pode ser await
, mas pode ser algo cujo StringValue
seja "await"
— uma representação diferente da sequência de caracteres await
.
Semântica estática para nomes de identificadores define como o StringValue
de um nome de identificador é calculado. Por exemplo, a sequência de escape Unicode para a
é \u0061
, então \u0061wait
tem o StringValue
"await"
. \u0061wait
não será reconhecido como uma palavra-chave pela gramática lexical, em vez disso será um Identifier
. A semântica estática proíbe usá-lo como um nome de variável dentro de funções assíncronas.
Então, isso funciona:
function antigo() {
var \u0061wait;
}
E isso não funciona:
async function moderno() {
var \u0061wait; // Erro de sintaxe
}
Resumo
Neste episódio, familiarizamo-nos com a gramática lexical, a gramática sintática e os atalhos usados para definir a gramática sintática. Como exemplo, analisamos a proibição do uso de await
como um identificador dentro de funções assíncronas, mas permitindo-o dentro de funções não assíncronas.
Outros aspectos interessantes da gramática sintática, como a inserção automática de ponto e vírgula e as gramáticas de cobertura, serão abordados em um episódio posterior. Fique ligado!