Understanding the ECMAScript spec, part 2

288 阅读7分钟

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 ofOis called with property keyPand ECMAScript language valueReceiver, the following steps are taken:

  1. Return? OrdinaryGet(O, P, Receiver).

We’ll see shortly thatReceiveris the value which is used as the this value when calling a getter function of an accessor property.

OrdinaryGetis defined like this:

OrdinaryGet ( O, P, Receiver ) When the abstract operationOrdinaryGetis called with ObjectO, property keyP, and ECMAScript language valueReceiver, the following steps are taken:

  1. Assert:IsPropertyKey(P)istrue.
  2. Letdescbe? O.[[GetOwnProperty]](P ).
  3. Ifdescisundefined, then
    • Letparentbe? O.[[GetPrototypeOf]]().
    • Ifparentisnull, returnundefined.
    • Return? parent.[[Get]](P, Receiver "[Get]").
  4. IfIsDataDescriptor(desc)istrue, returndesc.[[Value]].
  5. Assert:IsAccessorDescriptor(desc)istrue.
  6. Letgetterbedesc.[[Get]].
  7. Ifgetterisundefined, returnundefined.
  8. 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 toOrdinaryGetagain. If we still don’t find the property, we call its prototype’s[[Get]]method, which delegates toOrdinaryGetagain, 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 invokeOrdinaryGetwithObeingo2andPbeing"foo".O.[[GetOwnProperty]]("foo")returnsundefined, sinceo2doesn’t have an own property called"foo", so we take the if branch in step 3. In step 3.a, we setparentto the prototype ofo2which iso1.parentis 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 invokesOrdinaryGetagain, this time withObeingo1andPbeing"foo".o1has 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 indescin step 2 is notundefined, so we don’t take theifbranch 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?

TheReceiverparameter 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.

OrdinaryGetpasses the originalReceiverthroughout the recursion, unchanged (step 3.c). Let’s find out where theReceiveris originally coming from!

Searching for places where[[Get]]is called we find an abstract operationGetValuewhich 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 howGetValueis defined:

GetValue ( V )

  1. ReturnIfAbrupt(V).
  2. IfType(V)is notReference, returnV.
  3. LetbasebeGetBase(V).
  4. IfIsUnresolvableReference(V)istrue, throw aReferenceErrorexception.
  5. IfIsPropertyReference(V)istrue, then
    • IfHasPrimitiveBase(V)istrue, then -- Assert: In this case,basewill never beundefinedornull.
      -- Setbaseto! ToObject(base).
    • Return? base.[[Get]](GetReferencedName(V "[Get]"), GetThisValue(V)).
  6. Else,
    • Assert:baseis 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. TheReceiverwe pass isGetThisValue(V). In this case, it’s just the base value of the Reference:

GetThisValue( V )

  1. Assert:IsPropertyReference(V)istrue.
  2. IfIsSuperReference(V)istrue, then
    1. Return the value of thethisValuecomponent of the referenceV.
  3. ReturnGetBase(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 theReceiverto 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 theReceiveras 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 calledfooand 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.xoro1.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 operationGetValuewhich operates on References. But where isGetValuecalled 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,Awaitand 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. AMemberExpressioncan be just aPrimaryExpression. Alternatively, aMemberExpressioncan be constructed from anotherMemberExpressionandExpressionby 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 . IdentifierNamedefine the set of steps to take when evaluating it:

Runtime Semantics: Evaluation forMemberExpression : MemberExpression . IdentifierName

  1. LetbaseReferencebe the result of evaluatingMemberExpression.
  2. LetbaseValuebe? GetValue(baseReference).
  3. If the code matched by thisMemberExpressionis strict mode code, letstrictbetrue; else letstrictbefalse.
  4. 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 operationEvaluatePropertyAccessWithIdentifierKeytakes as arguments a valuebaseValue, a Parse NodeidentifierName, and a Boolean argumentstrict. It performs the following steps:

  1. Assert:identifierNameis anIdentifierName
  2. Letbvbe? RequireObjectCoercible(baseValue).
  3. LetpropertyNameStringbeStringValueofidentifierName.
  4. Return a value of type Reference whose base value component isbv, whose referenced name component ispropertyNameString, and whose strict reference flag isstrict.

That is:EvaluatePropertyAccessWithIdentifierKeyconstructs a Reference which uses the providedbaseValueas the base, the string value ofidentifierNameas the property name, andstrictas 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 ofArgumentListproduction which callsGetValueon the argument:

Runtime Semantics: ArgumentListEvaluation
ArgumentList : AssignmentExpression

  1. Letrefbe the result of evaluatingAssignmentExpression.
  2. Letargbe? GetValue(ref).
  3. Return a List whose sole item isarg.

o2.foodoesn’t look like anAssignmentExpressionbut 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.

TheAssignmentExpressionin step 1 iso2.foo.ref, the result of evaluatingo2.foo, is the above mentioned Reference. In step 2 we callGetValueon 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.