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
_Awaitou sem_Await? - Onde isso faz diferença — onde as produções para
Something_AwaiteSomething(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 oStringValuedeIdentifierfor"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!