Saltar al contenido principal

Entendiendo la especificación ECMAScript, parte 3

· 13 min de lectura
[Marja Hölttä](https://twitter.com/marjakh), espectadora especulativa de especificaciones

Todos los episodios

En este episodio, profundizaremos en la definición del lenguaje ECMAScript y su sintaxis. Si no estás familiarizado con las gramáticas libres de contexto, ahora es un buen momento para revisar los conceptos básicos, ya que la especificación utiliza gramáticas libres de contexto para definir el lenguaje. Consulta el capítulo sobre gramáticas libres de contexto en "Crafting Interpreters" para una introducción accesible o la página de Wikipedia para una definición más matemática.

Gramáticas de ECMAScript

La especificación ECMAScript define cuatro gramáticas:

La gramática léxica describe cómo los puntos de código Unicode se traducen en una secuencia de elementos de entrada (tokens, terminadores de línea, comentarios, espacios en blanco).

La gramática sintáctica define cómo los programas sintácticamente correctos se componen de tokens.

La gramática RegExp describe cómo los puntos de código Unicode se traducen en expresiones regulares.

La gramática de cadenas numéricas describe cómo las cadenas se traducen en valores numéricos.

Cada gramática se define como una gramática libre de contexto, que consiste en un conjunto de producciones.

Las gramáticas utilizan una notación ligeramente diferente: la gramática sintáctica usa LeftHandSideSymbol : mientras que la gramática léxica y la gramática RegExp usan LeftHandSideSymbol :: y la gramática de cadenas numéricas usa LeftHandSideSymbol :::.

A continuación, veremos en más detalle la gramática léxica y la gramática sintáctica.

Gramática léxica

La especificación define el texto fuente de ECMAScript como una secuencia de puntos de código Unicode. Por ejemplo, los nombres de las variables no están limitados a caracteres ASCII sino que también pueden incluir otros caracteres Unicode. La especificación no menciona la codificación real (por ejemplo, UTF-8 o UTF-16). Asume que el código fuente ya se ha convertido en una secuencia de puntos de código Unicode según la codificación que tenía.

No es posible tokenizar el código fuente de ECMAScript de antemano, lo que hace que definir la gramática léxica sea un poco más complicado.

Por ejemplo, no podemos determinar si / es el operador de división o el inicio de una expresión regular sin mirar el contexto más amplio en el que ocurre:

const x = 10 / 5;

Aquí / es un DivPunctuator.

const r = /foo/;

Aquí el primer / es el inicio de un RegularExpressionLiteral.

Las plantillas introducen una ambigüedad similar: la interpretación de }` depende del contexto en el que ocurre:

const what1 = 'temp';
const what2 = 'late';
const t = `Soy un ${ what1 + what2 }`;

Aquí Soy un ${ es TemplateHead y }` es un TemplateTail.

if (0 == 1) {
}`no muy útil`;

Aquí } es un RightBracePunctuator y ` es el inicio de un NoSubstitutionTemplate.

Aunque la interpretación de / y }` depende de su “contexto” — su posición en la estructura sintáctica del código — las gramáticas que describiremos a continuación siguen siendo libres de contexto.

La gramática léxica utiliza varios símbolos meta para distinguir entre los contextos donde algunos elementos de entrada están permitidos y otros no. Por ejemplo, el símbolo meta InputElementDiv se utiliza en contextos donde / es una división y /= es una asignación de división. Las producciones de InputElementDiv enumeran los posibles tokens que pueden ser producidos en este contexto:

InputElementDiv ::
WhiteSpace
LineTerminator
Comment
CommonToken
DivPunctuator
RightBracePunctuator

En este contexto, encontrar / produce el elemento de entrada DivPunctuator. Producir un RegularExpressionLiteral no es una opción aquí.

Por otro lado, InputElementRegExp es el símbolo meta para los contextos donde / es el inicio de una expresión regular:

InputElementRegExp ::
WhiteSpace
LineTerminator
Comment
CommonToken
RightBracePunctuator
RegularExpressionLiteral

Como vemos en las producciones, es posible que esto produzca el elemento de entrada RegularExpressionLiteral, pero producir DivPunctuator no es posible.

Del mismo modo, existe otro símbolo objetivo, InputElementRegExpOrTemplateTail, para contextos donde se permiten TemplateMiddle y TemplateTail, además de RegularExpressionLiteral. Y finalmente, InputElementTemplateTail es el símbolo objetivo para contextos donde solo se permiten TemplateMiddle y TemplateTail, pero no se permite RegularExpressionLiteral.

En las implementaciones, el analizador de gramática sintáctica ("parser") puede llamar al analizador de gramática léxica ("tokenizador" o "lexer"), pasando el símbolo objetivo como parámetro y solicitando el siguiente elemento de entrada adecuado para ese símbolo objetivo.

Gramática sintáctica

Hemos analizado la gramática léxica, que define cómo construimos tokens a partir de puntos de código Unicode. La gramática sintáctica se basa en esto: define cómo los programas sintácticamente correctos están compuestos por tokens.

Ejemplo: Permitir identificadores heredados

Introducir una nueva palabra clave en la gramática es un cambio potencialmente perjudicial: ¿qué pasa si el código existente ya utiliza la palabra clave como un identificador?

Por ejemplo, antes de que await fuera una palabra clave, alguien podría haber escrito el siguiente código:

function old() {
var await;
}

La gramática de ECMAScript agregó cuidadosamente la palabra clave await de tal manera que este código sigue funcionando. Dentro de funciones asincrónicas, await es una palabra clave, por lo que esto no funciona:

async function modern() {
var await; // Error de sintaxis
}

Permitir yield como un identificador en funciones no generadoras y desactivarlo en generadoras funciona de manera similar.

Entender cómo se permite await como identificador requiere comprender la notación de gramática sintáctica específica de ECMAScript. ¡Vamos a profundizar en ello!

Producciones y abreviaturas

Veamos cómo se definen las producciones para VariableStatement. A primera vista, la gramática puede parecer un poco intimidante:

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

¿Qué significan los subíndices ([Yield, Await]) y prefijos (+ en +In y ? en ?Async)?

La notación se explica en la sección Notación de gramática.

Los subíndices son una abreviatura para expresar un conjunto de producciones, para un conjunto de símbolos del lado izquierdo, todo a la vez. El símbolo del lado izquierdo tiene dos parámetros, que se expanden en cuatro símbolos "reales" del lado izquierdo: VariableStatement, VariableStatement_Yield, VariableStatement_Await y VariableStatement_Yield_Await.

Nota que aquí el VariableStatement simple significa "VariableStatement sin _Await y _Yield". No debe confundirse con VariableStatement[Yield, Await].

En el lado derecho de la producción, vemos la abreviatura +In, que significa "usar la versión con _In", y ?Await, que significa “usar la versión con _Await si y solo si el símbolo del lado izquierdo tiene _Await” (similar con ?Yield).

La tercera abreviatura, ~Foo, que significa "usar la versión sin _Foo", no se utiliza en esta producción.

Con esta información, podemos expandir las producciones así:

VariableStatement :
var VariableDeclarationList_In ;

VariableStatement_Yield :
var VariableDeclarationList_In_Yield ;

VariableStatement_Await :
var VariableDeclarationList_In_Await ;

VariableStatement_Yield_Await :
var VariableDeclarationList_In_Yield_Await ;

En última instancia, necesitamos averiguar dos cosas:

  1. ¿Dónde se decide si estamos en el caso con _Await o sin _Await?
  2. ¿Dónde hace una diferencia, dónde divergen las producciones para Something_Await y Something (sin _Await)?

_Await o no _Await?

Abordemos primero la pregunta 1. Es algo fácil adivinar que las funciones no asincrónicas y las funciones asincrónicas difieren en si elegimos el parámetro _Await para el cuerpo de la función o no. Al leer las producciones para declaraciones de funciones asincrónicas, encontramos esto:

AsyncFunctionBody :
FunctionBody[~Yield, +Await]

Cabe señalar que AsyncFunctionBody no tiene parámetros: se agregan al FunctionBody en el lado derecho.

Si expandimos esta producción, obtenemos:

AsyncFunctionBody :
FunctionBody_Await

En otras palabras, las funciones asincrónicas tienen FunctionBody_Await, lo que significa un cuerpo de función donde await se trata como una palabra clave.

Por otro lado, si estamos dentro de una función no asincrónica, la producción relevante es:

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

(La FunctionDeclaration tiene otra producción, pero no es relevante para nuestro ejemplo de código).

Para evitar una expansión combinatoria, ignoremos el parámetro Default, que no se utiliza en esta producción en particular.

La forma expandida de la producción es:

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 }

En esta producción siempre obtenemos FunctionBody y FormalParameters (sin _Yield y sin _Await), ya que están parametrizados con [~Yield, ~Await] en la producción no expandida.

El nombre de la función se trata de manera diferente: obtiene los parámetros _Await y _Yield si el símbolo del lado izquierdo los tiene.

En resumen: Las funciones async tienen un FunctionBody_Await y las funciones no-async tienen un FunctionBody (sin _Await). Dado que estamos hablando de funciones no generadoras, tanto nuestra función async de ejemplo como nuestra función no-async de ejemplo están parametrizadas sin _Yield.

Quizás sea difícil recordar cuál es FunctionBody y cuál FunctionBody_Await. ¿FunctionBody_Await es para una función donde await es un identificador o para una función donde await es una palabra clave?

Puedes pensar en el parámetro _Await como "await es una palabra clave". Este enfoque también es a prueba de futuro. Imagina una nueva palabra clave, blob siendo agregada, pero solo dentro de funciones "blobby". Las funciones no-blobby no-async no generadoras aún tendrían FunctionBody (sin _Await, _Yield o _Blob), exactamente igual que ahora. Las funciones blobby tendrían un FunctionBody_Blob, las funciones async blobby tendrían FunctionBody_Await_Blob y así sucesivamente. Aún necesitaríamos agregar el subíndice Blob a las producciones, pero las formas expandidas de FunctionBody para funciones ya existentes permanecen igual.

Prohibir await como identificador

A continuación, necesitamos averiguar cómo await se prohíbe como identificador si estamos dentro de un FunctionBody_Await.

Podemos seguir las producciones más adelante para ver que el parámetro _Await se transmite sin cambios desde FunctionBody hasta la producción de VariableStatement que estábamos mirando previamente.

Por lo tanto, dentro de una función async, tendremos un VariableStatement_Await y dentro de una función no-async, tendremos un VariableStatement.

Podemos seguir las producciones más adelante y mantener un registro de los parámetros. Ya hemos visto las producciones para VariableStatement:

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

Todas las producciones para VariableDeclarationList simplemente llevan los parámetros tal como están:

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

(Aquí mostramos solo la producción relevante para nuestro ejemplo.)

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

El shorthand opt significa que el símbolo del lado derecho es opcional; de hecho hay dos producciones, una con el símbolo opcional y otra sin él.

En el caso simple relevante para nuestro ejemplo, VariableStatement consiste en la palabra clave var, seguida de un único BindingIdentifier sin un inicializador, y finalizando con un punto y coma.

Para prohibir o permitir await como un BindingIdentifier, esperamos terminar con algo como esto:

BindingIdentifier_Await :
Identifier
yield

BindingIdentifier :
Identifier
yield
await

Esto prohibiría await como un identificador dentro de funciones async y lo permitiría como un identificador dentro de funciones no-async.

Pero la especificación no lo define de esta manera, en cambio encontramos esta producción:

BindingIdentifier[Yield, Await] :
Identifier
yield
await

Expandido, esto significa las siguientes producciones:

BindingIdentifier_Await :
Identifier
yield
await

BindingIdentifier :
Identifier
yield
await

(Estamos omitiendo las producciones para BindingIdentifier_Yield y BindingIdentifier_Yield_Await que no son necesarias en nuestro ejemplo.)

Esto parece como si await y yield siempre se permitieran como identificadores. ¿Qué pasa con eso? ¿Todo el artículo del blog no sirve de nada?

La semántica estática al rescate

Resulta que se necesitan semánticas estáticas para prohibir await como identificador dentro de funciones async.

Las semánticas estáticas describen reglas estáticas — es decir, reglas que se verifican antes de que el programa se ejecute.

En este caso, las semánticas estáticas para BindingIdentifier definen la siguiente regla dirigida por la sintaxis:

BindingIdentifier[Yield, Await] : await

Es un error de sintaxis si esta producción tiene un [Await] parámetro.

Efectivamente, esto prohíbe la producción BindingIdentifier_Await : await.

La especificación explica que la razón de tener esta producción pero definirla como un Error de Sintaxis mediante la semántica estática es debido a la interferencia con la inserción automática de punto y coma (ASI).

Recuerda que la ASI entra en acción cuando no podemos analizar una línea de código de acuerdo con las producciones de la gramática. La ASI intenta agregar puntos y comas para satisfacer el requisito de que las declaraciones y sentencias deben terminar con un punto y coma. (Describiremos la ASI en más detalle en un episodio posterior).

Considera el siguiente código (ejemplo de la especificación):

async function too_few_semicolons() {
let
await 0;
}

Si la gramática no permitiera await como identificador, la ASI actuaría y transformaría el código en el siguiente código gramaticalmente correcto, que también usa let como identificador:

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

Este tipo de interferencia con la ASI fue considerada demasiado confusa, por lo que se utilizaron semánticas estáticas para deshabilitar await como identificador.

Valores de cadena de identificadores no permitidos (StringValues)

También hay otra regla relacionada:

BindingIdentifier : Identifier

Es un Error de Sintaxis si esta producción tiene un parámetro [Await] y el StringValue del Identifier es "await".

Esto podría ser confuso al principio. Identifier se define así:

Identifier :
IdentifierName but not ReservedWord

await es una ReservedWord, entonces ¿cómo puede un Identifier ser alguna vez await?

Resulta que el Identifier no puede ser await, pero puede ser otra cosa cuyo StringValue sea "await" — una representación diferente de la secuencia de caracteres await.

Las semánticas estáticas para nombres de identificadores definen cómo se calcula el StringValue de un nombre de identificador. Por ejemplo, la secuencia de escape Unicode para a es \u0061, por lo que \u0061wait tiene el StringValue "await". \u0061wait no será reconocido como una palabra clave por la gramática léxica, en cambio será un Identifier. Las semánticas estáticas prohíben usarlo como nombre de variable dentro de funciones asincrónicas.

Por lo tanto, esto funciona:

function old() {
var \u0061wait;
}

Y esto no:

async function modern() {
var \u0061wait; // Error de sintaxis
}

Resumen

En este episodio, nos familiarizamos con la gramática léxica, la gramática sintáctica y las abreviaturas utilizadas para definir la gramática sintáctica. Como ejemplo, analizamos cómo prohibir el uso de await como identificador dentro de funciones asincrónicas pero permitirlo dentro de funciones no asincrónicas.

Otras partes interesantes de la gramática sintáctica, como la inserción automática de punto y coma y las gramáticas de cobertura serán tratadas en un episodio posterior. ¡Mantente atento!