Entendiendo la especificación de ECMAScript, parte 2
Practiquemos un poco más nuestras increíbles habilidades para leer especificaciones. ¡Si todavía no has visto el episodio anterior, ahora es un buen momento para hacerlo!
¿Listo para la parte 2?
Una forma divertida de conocer la especificación es comenzar con una característica de JavaScript que sabemos que existe y averiguar cómo está especificada.
¡Advertencia! Este episodio contiene algoritmos copiados de la especificación ECMAScript de febrero de 2020. Eventualmente estarán desactualizados.
Sabemos que las propiedades se buscan en la cadena de prototipos: si un objeto no tiene la propiedad que intentamos leer, subimos por la cadena de prototipos hasta encontrarla (o encontramos un objeto que ya no tiene prototipo).
Por ejemplo:
const o1 = { foo: 99 };
const o2 = {};
Object.setPrototypeOf(o2, o1);
o2.foo;
// → 99
¿Dónde está definida la cadena de prototipos?
Intentemos averiguar dónde se define este comportamiento. Un buen lugar para comenzar es una lista de Métodos Internos de Objetos.
Existen tanto [[GetOwnProperty]]
como [[Get]]
— estamos interesados en la versión que no está restringida a propiedades propias, así que iremos con [[Get]]
.
Desafortunadamente, el tipo de especificación Descriptor de Propiedades también tiene un campo llamado [[Get]]
, así que mientras navegamos por la especificación en busca de [[Get]]
, debemos distinguir cuidadosamente entre los dos usos independientes.
[[Get]]
es un método interno esencial. Los objetos ordinarios implementan el comportamiento predeterminado para los métodos internos esenciales. Los objetos exóticos pueden definir su propio método interno [[Get]]
que se desvía del comportamiento predeterminado. En este post, nos enfocamos en objetos ordinarios.
La implementación predeterminada de [[Get]]
delega en OrdinaryGet
:
Cuando el método interno
[[Get]]
deO
es llamado con la clave de propiedadP
y el valor del lenguaje ECMAScriptReceiver
, se siguen los pasos siguientes:
- Devuelve
? OrdinaryGet(O, P, Receiver)
.
Veremos en breve que Receiver
es el valor que se usa como el valor this al llamar a una función getter de una propiedad accesoria.
OrdinaryGet
se define así:
OrdinaryGet ( O, P, Receiver )
Cuando la operación abstracta
OrdinaryGet
es llamada con el objetoO
, la clave de propiedadP
y el valor del lenguaje ECMAScriptReceiver
, se siguen los pasos siguientes:
- Asegúrate de que
IsPropertyKey(P)
seatrue
.- Deja que
desc
sea? O.[[GetOwnProperty]](P)
.- Si
desc
esundefined
, entonces
- Deja que
parent
sea? O.[[GetPrototypeOf]]()
.- Si
parent
esnull
, devuelveundefined
.- Devuelve
? parent.[[Get]](P, Receiver)
.- Si
IsDataDescriptor(desc)
estrue
, devuelvedesc.[[Value]]
.- Asegúrate de que
IsAccessorDescriptor(desc)
seatrue
.- Deja que
getter
seadesc.[[Get]]
.- Si
getter
esundefined
, devuelveundefined
.- Devuelve
? Call(getter, Receiver)
.
La cadena de prototipos está dentro del paso 3: si no encontramos la propiedad como una propiedad propia, llamamos al método [[Get]]
del prototipo, que delega nuevamente en OrdinaryGet
. Si aún no encontramos la propiedad, llamamos al método [[Get]]
del prototipo de este último, que delega nuevamente en OrdinaryGet
, y así sucesivamente, hasta que encontremos la propiedad o lleguemos a un objeto sin prototipo.
Veamos cómo funciona este algoritmo cuando accedemos a o2.foo
. Primero invocamos OrdinaryGet
con O
siendo o2
y P
siendo "foo"
. O.[[GetOwnProperty]]("foo")
devuelve undefined
, dado que o2
no tiene una propiedad propia llamada "foo"
, así que tomamos la rama del if
en el paso 3. En el paso 3.a, establecemos parent
como el prototipo de o2
, que es o1
. parent
no es null
, así que no devolvemos en el paso 3.b. En el paso 3.c, llamamos al método [[Get]]
del prototipo con la clave de propiedad "foo"
y devolvemos lo que este devuelve.
El padre (o1
) es un objeto ordinario, por lo que su método [[Get]]
invoca OrdinaryGet
nuevamente, esta vez con O
siendo o1
y P
siendo "foo"
. o1
tiene una propiedad propia llamada "foo"
, así que en el paso 2, O.[[GetOwnProperty]]("foo")
devuelve el Descriptor de Propiedad asociado y lo almacenamos en desc
.
Property Descriptor es un tipo en la especificación. Los descriptors de propiedades de datos almacenan el valor de la propiedad directamente en el campo [[Value]]
. Los descriptors de propiedades de acceso almacenan las funciones de acceso en los campos [[Get]]
y/o [[Set]]
. En este caso, el descriptor de propiedad asociado con "foo"
es un descriptor de propiedad de datos.
El descriptor de propiedad de datos que almacenamos en desc
en el paso 2 no es undefined
, por lo que no seguimos la rama del if
en el paso 3. Luego ejecutamos el paso 4. El descriptor de propiedad es un descriptor de propiedad de datos, por lo que devolvemos el campo [[Value]]
, 99
, en el paso 4, y listo.
¿Qué es Receiver
y de dónde proviene?
El parámetro Receiver
solo se utiliza en el caso de propiedades de acceso en el paso 8. Se pasa como el valor this al llamar a la función getter de una propiedad de acceso.
OrdinaryGet
pasa el Receiver
original a lo largo de la recursión, sin cambios (paso 3.c). ¡Veamos de dónde proviene originalmente el Receiver
!
Buscando lugares donde se llama a [[Get]]
, encontramos una operación abstracta GetValue
que opera sobre Referencias. La Referencia es un tipo en la especificación que consta de un valor base, el nombre referenciado y una marca de referencia estricta. En el caso de o2.foo
, el valor base es el objeto o2
, el nombre referenciado es el string "foo"
, y la marca de referencia estricta es false
porque el código del ejemplo no es estricto.
Nota sobre el costado: ¿Por qué la Referencia no es un Registro?
Nota sobre el costado: La Referencia no es un Registro, aunque suena como si pudiera serlo. Contiene tres componentes, que podrían expresarse igualmente como tres campos nombrados. La Referencia no es un Registro solo por razones históricas.
Volviendo a GetValue
Veamos cómo se define GetValue
:
ReturnIfAbrupt(V)
.- Si
Type(V)
no esReference
, devuelveV
.- Deja que
base
seaGetBase(V)
.- Si
IsUnresolvableReference(V)
estrue
, lanza una excepciónReferenceError
.- Si
IsPropertyReference(V)
estrue
, entonces
- Si
HasPrimitiveBase(V)
estrue
, entonces
- Asegúrate de que, en este caso,
base
nunca seráundefined
onull
.- Asigna
base
a! ToObject(base)
.- Devuelve
? base.[[Get]](GetReferencedName(V), GetThisValue(V))
.- De lo contrario,
- Asegúrate de que
base
es un registro de entorno.- Devuelve
? base.GetBindingValue(GetReferencedName(V), IsStrictReference(V))
La Referencia en nuestro ejemplo es o2.foo
, que es una referencia de propiedad. Así que seguimos la rama 5. No seguimos la rama en 5.a, ya que la base (o2
) no es un valor primitivo (un Number, String, Symbol, BigInt, Boolean, Undefined o Null).
Luego llamamos a [[Get]]
en el paso 5.b. El Receiver
que pasamos es GetThisValue(V)
. En este caso, es simplemente el valor base de la Referencia:
- Asegúrate de que
IsPropertyReference(V)
estrue
.- Si
IsSuperReference(V)
estrue
, entonces
- Devuelve el valor del componente
thisValue
de la referenciaV
.- Devuelve
GetBase(V)
.
Para o2.foo
, no seguimos la rama en el paso 2, ya que no es una Super Referencia (como super.foo
), pero seguimos el paso 3 y devolvemos el valor base de la Referencia, que es o2
.
Uniendo todo, descubrimos que configuramos el Receiver
como la base de la Referencia original, y luego lo mantenemos sin cambios durante la caminata por la cadena de prototipos. Finalmente, si la propiedad que encontramos es una propiedad de acceso, usamos el Receiver
como el valor this al llamarlo.
En particular, el valor this dentro de un getter se refiere al objeto original desde donde intentamos obtener la propiedad, no al objeto donde encontramos la propiedad durante la caminata por la cadena de prototipos.
¡Intentémoslo!
const o1 = { x: 10, get foo() { return this.x; } };
const o2 = { x: 50 };
Object.setPrototypeOf(o2, o1);
o2.foo;
// → 50
En este ejemplo, tenemos una propiedad de acceso llamada foo
y definimos un getter para ella. El getter devuelve this.x
.
Luego accedemos a o2.foo
- ¿qué devuelve el getter?
Descubrimos que cuando llamamos al getter, el valor this es el objeto desde el cual originalmente intentamos obtener la propiedad, no el objeto donde lo encontramos. En este caso, el valor this es o2
, no o1
. Podemos verificarlo observando si el getter devuelve o2.x
o o1.x
, y de hecho, devuelve o2.x
.
¡Funciona! Pudimos predecir el comportamiento de este fragmento de código basándonos en lo que leímos en la especificación.
Accediendo a propiedades — ¿por qué invoca [[Get]]
?
¿Dónde dice la especificación que el método interno [[Get]]
de un objeto se invocará al acceder a una propiedad como o2.foo
? Seguramente eso debe estar definido en algún lugar. ¡No solo confíes en mi palabra!
Descubrimos que el método interno [[Get]]
de un objeto se llama desde la operación abstracta GetValue
, que opera sobre Referencias. Pero, ¿de dónde se llama a GetValue
?
Semántica en tiempo de ejecución para MemberExpression
Las reglas gramaticales de la especificación definen la sintaxis del lenguaje. Semántica de tiempo de ejecución define lo que los constructos sintácticos 'significan' (cómo evaluarlos en tiempo de ejecución).
Si no estás familiarizado con las gramáticas libres de contexto, ¡es una buena idea revisarlas ahora!
¡Echaremos un vistazo más profundo a las reglas gramaticales en un episodio posterior, mantengámoslo simple por ahora! En particular, podemos ignorar los subíndices (Yield
, Await
, etc.) en las producciones para este episodio.
Las siguientes producciones describen cómo se ve un MemberExpression
:
MemberExpression :
PrimaryExpression
MemberExpression [ Expression ]
MemberExpression . IdentifierName
MemberExpression TemplateLiteral
SuperProperty
MetaProperty
new MemberExpression Arguments
Aquí tenemos 7 producciones para MemberExpression
. Una MemberExpression
puede ser simplemente una PrimaryExpression
. Alternativamente, una MemberExpression
puede ser construida a partir de otra MemberExpression
y una Expression
combinándolas: MemberExpression [ Expression ]
, por ejemplo o2['foo']
. O puede ser MemberExpression . IdentifierName
, por ejemplo o2.foo
— esta es la producción relevante para nuestro ejemplo.
La semántica de tiempo de ejecución para la producción MemberExpression : MemberExpression . IdentifierName
define el conjunto de pasos a seguir al evaluarla:
- Que
baseReference
sea el resultado de evaluarMemberExpression
.- Que
baseValue
sea? GetValue(baseReference)
.- Si el código coincidente con esta
MemberExpression
es código en modo estricto, questrict
seatrue
; sino questrict
seafalse
.- Devuelve
? EvaluatePropertyAccessWithIdentifierKey(baseValue, IdentifierName, strict)
.
El algoritmo delega en la operación abstracta EvaluatePropertyAccessWithIdentifierKey
, así que necesitamos leerlo también:
EvaluatePropertyAccessWithIdentifierKey( baseValue, identifierName, strict )
La operación abstracta
EvaluatePropertyAccessWithIdentifierKey
toma como argumentos un valorbaseValue
, un nodo de parseoidentifierName
y un argumento Booleanostrict
. Realiza los siguientes pasos:
- Asegúrate:
identifierName
es unIdentifierName
.- Que
bv
sea? RequireObjectCoercible(baseValue)
.- Que
propertyNameString
sea elStringValue
deidentifierName
.- Devuelve un valor de tipo Reference cuyo componente base sea
bv
, cuyo componente de nombre referenciado seapropertyNameString
, y cuya bandera de referencia estricta seastrict
.
Es decir: EvaluatePropertyAccessWithIdentifierKey
construye una referencia que utiliza el baseValue
proporcionado como base, el valor de cadena de identifierName
como el nombre de la propiedad, y strict
como la bandera de modo estricto.
Eventualmente esta referencia se pasa a GetValue
. Esto se define en varios lugares en la especificación, dependiendo de cómo se termine utilizando la referencia.
MemberExpression
como un parámetro
En nuestro ejemplo usamos el acceso a la propiedad como un parámetro:
console.log(o2.foo);
En este caso, el comportamiento está definido en la semántica de tiempo de ejecución de la producción ArgumentList
que llama a GetValue
en el argumento:
Semántica de tiempo de ejecución:
ArgumentListEvaluation
ArgumentList : AssignmentExpression
- Que
ref
sea el resultado de evaluarAssignmentExpression
.- Que
arg
sea? GetValue(ref)
.- Devuelve una Lista cuyo único elemento es
arg
.
o2.foo
no parece una AssignmentExpression
pero lo es, por lo que esta producción es aplicable. Para descubrir por qué, puedes revisar este contenido adicional, pero no es estrictamente necesario en este punto.
La AssignmentExpression
en el paso 1 es o2.foo
. ref
, el resultado de evaluar o2.foo
, es la referencia mencionada anteriormente. En el paso 2 llamamos a GetValue
sobre ella. Por lo tanto, sabemos que se invocará el método interno de objeto [[Get]]
, y ocurrirá la exploración de la cadena de prototipos.
Resumen
En este episodio, vimos cómo la especificación define una característica del lenguaje, en este caso la búsqueda de prototipos, a través de todas las diferentes capas: los constructos sintácticos que activan la característica y los algoritmos que la definen.