Let’s practice our awesome spec reading skills some more. If you haven’t had a look at the previous episode, now it’s a good time to do so!
Ready for part 2?
A fun way to get to know the spec is to start with a JavaScript feature we know is there, and find out how it’s specified.
Warning! This episode contains copy-pasted algorithms from the ECMAScript spec as of February 2020. They’ll eventually be out of date.
We know that properties are looked up in the prototype chain: if an object doesn’t have the property we’re trying to read, we walk up the prototype chain until we find it (or find an object which no longer has a prototype).
For example:
const o1 = { foo: 99 };const o2 = {};Object.setPrototypeOf(o2, o1);o2.foo;// → 99
Where’s the prototype walk defined?
Let’s try to find out where this behavior is defined. A good place to start is a list of Object Internal Methods.
There’s both[[GetOwnProperty]]
and[[Get]]
— we’re interested in the version that isn’t restricted to_own_properties, so we’ll go with[[Get]]
.
Unfortunately, theProperty Descriptor specification typealso has a field called[[Get]]
, so while browsing the spec for[[Get]]
, we need to carefully distinguish between the two independent usages.
[[Get]]
is an essential internal method. Ordinary objects implement the default behavior for essential internal methods. Exotic objects can define their own internal method[[Get]]
which deviates from the default behavior. In this post, we focus on ordinary objects.
The default implementation for[[Get]]
delegates toOrdinaryGet
:
[[Get]] ( P, Receiver ) When the
[[Get]]
internal method ofO
is called with property keyP
and ECMAScript language valueReceiver
, the following steps are taken:
- Return
? OrdinaryGet(O, P, Receiver)
.
We’ll see shortly thatReceiver
is the value which is used as the this value when calling a getter function of an accessor property.
OrdinaryGet
is defined like this:
OrdinaryGet ( O, P, Receiver ) When the abstract operation
OrdinaryGet
is called with ObjectO
, property keyP
, and ECMAScript language valueReceiver
, the following steps are taken:
- Assert:
IsPropertyKey(P)
istrue
.- Let
desc
be? O.[[GetOwnProperty]](P )
.- If
desc
isundefined
, then
- Let
parent
be? O.[[GetPrototypeOf]]()
.- If
parent
isnull
, returnundefined
.- Return
? parent.[[Get]](P, Receiver "[Get]")
.- If
IsDataDescriptor(desc)
istrue
, returndesc.[[Value]]
.- Assert:
IsAccessorDescriptor(desc)
istrue
.- Let
getter
bedesc.[[Get]]
.- If
getter
isundefined
, returnundefined
.- Return
? Call(getter, Receiver)
.
The prototype chain walk is inside step 3: if we don’t find the property as an own property, we call the prototype’s[[Get]]
method which delegates toOrdinaryGet
again. If we still don’t find the property, we call its prototype’s[[Get]]
method, which delegates toOrdinaryGet
again, and so on, until we either find the property or reach an object without a prototype.
Let’s look at how this algorithm works when we accesso2.foo
. First we invokeOrdinaryGet
withO
beingo2
andP
being"foo"
.O.[[GetOwnProperty]]("foo")
returnsundefined
, sinceo2
doesn’t have an own property called"foo"
, so we take the if branch in step 3. In step 3.a, we setparent
to the prototype ofo2
which iso1
.parent
is notnull
, so we don’t return in step 3.b. In step 3.c, we call the parent’s[[Get]]
method with property key"foo"
, and return whatever it returns.
The parent (o1
) is an ordinary object, so its[[Get]]
method invokesOrdinaryGet
again, this time withO
beingo1
andP
being"foo"
.o1
has an own property called"foo"
, so in step 2,O.[[GetOwnProperty]]("foo")
returns the associated Property Descriptor and we store it indesc
.
Property Descriptor is a specification type. Data Property Descriptors store the value of the property directly in the[[Value]]
field. Accessor Property Descriptors store the accessor functions in fields[[Get]]
and/or[[Set]]
. In this case, the Property Descriptor associated with"foo"
is a data Property Descriptor.
The data Property Descriptor we stored indesc
in step 2 is notundefined
, so we don’t take theif
branch in step 3. Next we execute step 4. The Property Descriptor is a data Property Descriptor, so we return its[[Value]]
field,99
, in step 4, and we’re done.
What’s Receiver
and where is it coming from?
TheReceiver
parameter is only used in the case of accessor properties in step 8. It’s passed as the this value when calling the getter function of an accessor property.
OrdinaryGet
passes the originalReceiver
throughout the recursion, unchanged (step 3.c). Let’s find out where theReceiver
is originally coming from!
Searching for places where[[Get]]
is called we find an abstract operationGetValue
which operates on References. Reference is a specification type, consisting of a base value, the referenced name, and a strict reference flag. In the case ofo2.foo
, the base value is the Objecto2
, the referenced name is the String"foo"
, and the strict reference flag isfalse
, since the example code is sloppy.
Side track: Why is Reference not a Record?
Side track: Reference is not a Record, even though it sounds like it could be. It contains three components, which could equally well be expressed as three named fields. Reference is not a Record only because of historical reasons.
Back to GetValue
Let’s look at howGetValue
is defined:
ReturnIfAbrupt(V)
.- If
Type(V)
is notReference
, returnV
.- Let
base
beGetBase(V)
.- If
IsUnresolvableReference(V)
istrue
, throw aReferenceError
exception.- If
IsPropertyReference(V)
istrue
, then
- If
HasPrimitiveBase(V)
istrue
, then -- Assert: In this case,base
will never beundefined
ornull
.
-- Setbase
to! ToObject(base)
.- Return
? base.[[Get]](GetReferencedName(V "[Get]"), GetThisValue(V))
.- Else,
- Assert:
base
is an Environment Record.- Return
? base.GetBindingValue(GetReferencedName(V), IsStrictReference(V))
The Reference in our example iso2.foo
, which is a property reference. So we take branch 5. We don’t take the branch in 5.a, since the base (o2
) is not a primitive value (a Number, String, Symbol, BigInt, Boolean, Undefined, or Null).
Then we call[[Get]]
in step 5.b. TheReceiver
we pass isGetThisValue(V)
. In this case, it’s just the base value of the Reference:
- Assert:
IsPropertyReference(V)
istrue
.- If
IsSuperReference(V)
istrue
, then
- Return the value of the
thisValue
component of the referenceV
.- Return
GetBase(V)
.
Foro2.foo
, we don’t take the branch in step 2, since it’s not a Super Reference (such assuper.foo
), but we take step 3 and return the base value of the Reference which iso2
.
Piecing everything together, we find out that we set theReceiver
to be the base of the original Reference, and then we keep it unchanged during the prototype chain walk. Finally, if the property we find is an accessor property, we use theReceiver
as the this value when calling it.
In particular, the this value inside a getter refers to the original object where we tried to get the property from, not the one where we found the property during the prototype chain walk.
Let’s try it out!
const o1 = { x: 10, get foo() { return this.x; } };const o2 = { x: 50 };Object.setPrototypeOf(o2, o1);o2.foo;// → 50
In this example, we have an accessor property calledfoo
and we define a getter for it. The getter returnsthis.x
.
Then we accesso2.foo
- what does the getter return?
We found out that when we call the getter, the this value is the object where we originally tried to get the property from, not the object where we found it. In this case the this value iso2
, noto1
. We can verify that by checking whether the getter returnso2.x
oro1.x
, and indeed, it returnso2.x
.
It works! We were able to predict the behavior of this code snippet based on what we read in the spec.
Accessing properties — why does it invoke[[Get]]
?
Where does the spec say that the Object internal method[[Get]]
will get invoked when accessing a property likeo2.foo
? Surely that has to be defined somewhere. Don’t take my word for it!
We found out that the Object internal method[[Get]]
is called from the abstract operationGetValue
which operates on References. But where isGetValue
called from?
Runtime semantics for MemberExpression
The grammar rules of the spec define the syntax of the language. Runtime semantics define what the syntactic constructs “mean” (how to evaluate them at runtime).
If you’re not familiar with context-free grammars, it’s a good idea to have a look now!
We’ll take a deeper look into the grammar rules in a later episode, let’s keep it simple for now! In particular, we can ignore the subscripts (Yield
,Await
and so on) in the productions for this episode.
The following productions describe what a MemberExpressionlooks like:
MemberExpression : PrimaryExpression MemberExpression [ Expression ] MemberExpression . IdentifierName MemberExpression TemplateLiteral SuperProperty MetaProperty new MemberExpression Arguments
Here we have 7 productions forMemberExpression
. AMemberExpression
can be just aPrimaryExpression
. Alternatively, aMemberExpression
can be constructed from anotherMemberExpression
andExpression
by piecing them together:MemberExpression [ Expression ]
, for exampleo2['foo']
. Or it can beMemberExpression . IdentifierName
, for exampleo2.foo
— this is the production relevant for our example.
Runtime semantics for the productionMemberExpression : MemberExpression . IdentifierName
define the set of steps to take when evaluating it:
Runtime Semantics: Evaluation for
MemberExpression : MemberExpression . IdentifierName
- Let
baseReference
be the result of evaluatingMemberExpression
.- Let
baseValue
be? GetValue(baseReference)
.- If the code matched by this
MemberExpression
is strict mode code, letstrict
betrue
; else letstrict
befalse
.- Return
? EvaluatePropertyAccessWithIdentifierKey(baseValue, IdentifierName, strict)
.
The algorithm delegates to the abstract operationEvaluatePropertyAccessWithIdentifierKey
, so we need to read it too:
EvaluatePropertyAccessWithIdentifierKey( baseValue, identifierName, strict ) The abstract operation
EvaluatePropertyAccessWithIdentifierKey
takes as arguments a valuebaseValue
, a Parse NodeidentifierName
, and a Boolean argumentstrict
. It performs the following steps:
- Assert:
identifierName
is anIdentifierName
- Let
bv
be? RequireObjectCoercible(baseValue)
.- Let
propertyNameString
beStringValue
ofidentifierName
.- Return a value of type Reference whose base value component is
bv
, whose referenced name component ispropertyNameString
, and whose strict reference flag isstrict
.
That is:EvaluatePropertyAccessWithIdentifierKey
constructs a Reference which uses the providedbaseValue
as the base, the string value ofidentifierName
as the property name, andstrict
as the strict mode flag.
Eventually this Reference gets passed toGetValue
. This is defined in several places in the spec, depending on how the Reference ends up being used.
MemberExpression
as a parameter
In our example, we use the property access as a parameter:
console.log(o2.foo);
In this case, the behavior is defined in the runtime semantics ofArgumentList
production which callsGetValue
on the argument:
Runtime Semantics: ArgumentListEvaluation
ArgumentList : AssignmentExpression
- Let
ref
be the result of evaluatingAssignmentExpression
.- Let
arg
be? GetValue(ref)
.- Return a List whose sole item is
arg
.
o2.foo
doesn’t look like anAssignmentExpression
but it is one, so this production is applicable. To find out why, you can check out thisextra content, but it’s not strictly necessary at this point.
TheAssignmentExpression
in step 1 iso2.foo
.ref
, the result of evaluatingo2.foo
, is the above mentioned Reference. In step 2 we callGetValue
on it. Thus, we know that the Object internal method[[Get]]
will get invoked, and the prototype chain walk will occur.
Summary
In this episode, we looked at how the spec defines a language feature, in this case prototype lookup, across all the different layers: the syntactic constructs that trigger the feature and the algorithms defining it.