Reputation: 7821
When creating a new object via a good old ES5 constructor function: When is the new object created?
A guess: Is it created immediately when the JS engine encounters the new
keyword, directly before the constructor function is executed?
Similarly to above, but for classes: When is the new object created?
A guess: Since we can subclass built-in objects with class
syntax, I am thinking the engine must know what type (exotic
vs ordinary
) its parent object is. Therefore, I was thinking perhaps the new object is created right when the engine encounters the extends
keyword and can read what type the parent is.
In both cases, when is the prototype property set? Is it before or after executing the constructor function / ClassBody?
Note 1: It would be great if the answer could include links to where in the ECMAScript specification each of the two creations occur. I have been searching around a lot and have been unable to find the right algorithm-steps.
Note 2: With "created" I mean space allocated in memory and type set (exotic vs ordinary), at a minimum.
Upvotes: 1
Views: 438
Reputation: 161477
When creating a new object via a good old ES5 constructor function: When is the new object created?
The spec-level definition of object construction behavior is defined by the [[Construct]]
function. For standard JS functions (function Foo(){}
, the definition of this function is initialized in 9.2.3 FunctionAllocate where functionKind
will "normal"
. Then you can see on step 9.a
, the [[Construct]]
slot is declared to point at section 9.2.2 and [[ConstructorKind]]
is set to "base"
.
When user code calls new Foo();
to construct an instance of this function, it will call from 12.3.3 The new
operator to 12.3.3.1.1 EvaluateNew to 7.3.13 Construct to [[Construct]]
, which calls the slot initialized above, passing the arguments, and the Foo
function as newTarget
.
Digging into 9.2.2 [[Construct]]
, we can see that step 5.a
performs:
- a. Let
thisArgument
be ?OrdinaryCreateFromConstructor(newTarget, "%ObjectPrototype%")
.
which answers your question of when. The this
object is created here by essentially doing Object.create(Foo.prototype)
(with a little extra ignorable logic in there). The function will then continue along and at step 8
it will do
- If kind is
"base"
, performOrdinaryCallBindThis(F, calleeContext, thisArgument)
.
which you can kind of think of as doing this = thisArgument
, which will set the value of this
in the function, before it actually calls the logic of the Foo
function on step 11
.
The primary difference for ES6 classes vs ES5-style constructor functions is that the [[Construct]]
methods are only used once, at the first level of construction. For example, if we have
function Parent(){}
function Child(){
Base.apply(this, arguments);
}
Object.setPrototype(Child.prototype, Parent.prototype);
new Child();
the new
will use [[Construct]]
for Child
, but the call to Parent
uses .apply
, meaning that it isn't actually constructing the parent, it's just calling it like a normal function and passing along an appropriate this
value.
This is where things become complicated, as you've noticed, because it means that Parent
doesn't actually have any influence over the creation of this
, and just has to hope that it is given an acceptable value.
Similarly to above, but for classes: When is the new object created?
The main difference with ES6 class syntax is that because the parent function is called with super()
instead of Parent.call
/Parent.apply
, the [[Construct]]
function of parent functions is called rather than [[Call]]
. Because of this, it's actually possible to get into 9.2.2 [[Construct]]
with [[ConstructorKind]]
set to something other than "base"
. It's this change in behavior that affects when the object is constructed.
If we revisit our example above now, with ES6 classes
class Parent {
constructor() {
}
}
class Child extends Parent {
constructor() {
super();
}
}
Child
is not "base"
, so when the Child
constructor initially runs, the this
value is uninitialized. You can kind of think of super()
as doing const this = super();
, so just like
console.log(value);
const value = 4;
would throw an exception, because value
had not been initialized yet, it is the call to super()
that calls the parent [[Construct]]
, and then initializes the this
inside of the Child
constructor function body. The parent [[Construct]]
behaves just like it would in ES5 if it were function Parent(){}
, because [[ConstructorKind]]
is "base"
.
This behavior is also what allows ES6 class syntax to extend native types like Array
. The call to super()
is what actually creates the instance, and since the Array
function knows all that it needs to know to create a real functional array, it can do so, and then return that object.
In both cases, when is the prototype property set? Is it before or after executing the constructor function / ClassBody?
The other key piece that I glossed over above is the exact nature of newTarget
mentioned above in the spec snippets. In ES6, there is a new concept that is the "new target", which is the actual constructor function passed to new
. So if you do new Foo
, you're actually using Foo
in two different ways. One is that you're using the function as a constructor, but the other is that you're using that value as the "new target". This is critical for the nesting of class constructors, because when you call a chain of [[Construct]]
functions, the actual constructor being called will work it's way up the chain, but the newTarget
value will remain the same. This is important because newTarget.prototype
is what is used to actually set the prototype of the final constructed object. For instance, when you do
class Parent extends Array {
constructor() {
console.log(new.target); // Child
super();
}
}
class Child extends Parent {
constructor() {
console.log(new.target); // Child
super();
}
}
new Child();
The call to new Child
will call the Child
constructor, and also set it as the newTarget
value to Child
. Then when super()
is called, we're using [[Construct]]
from Parent
, but also passing Child
as the newTarget
value still. This repeats for Parent
and means that even though Array
is responsible for creating an array exotic object, it can still use newTarget.prototype
(Child.prototype
) to ensure that the array has the correct prototype chain.
Upvotes: 2
Reputation: 5193
new
will call Construct, which in turn will call the related function's internal [[Construct]]. I will only discuss the normal [[Construct]] here, and not care about e.g. Proxies having custom behavior for it, as that is imho not related to the topic.
In the standard scenario (no extends
), in step 5.a, [[Construct]] calls OrdinaryCreateFromConstructor, and the return of that will be used as this
(see OrdinaryCallBindThis, where it is used as argument). Note that OrdinaryCallEvaluateBody comes at a later step - the object is created, before the constructor function is evaluated. For new f
, it is basically Object.create(f.prototype)
. Generally, it's Object.create(newTarget.prototype)
. This is the same for class
and the ES5 way. The prototype is obviously set there aswell.
The confusion probably stems from the case, where extends
is being used. In that case, [[ConstructorKind]] is not "base" (see step 15 of ClassDefinitionEvaluation), so in [[Construct]], step 5.a does not apply anymore, nor is OrdinaryCallBindThis called. The important part here happens in the super call. Long story short, it calls Construct with the SuperConstructor and current newTarget, and binds the result as this
. Accordingly, as you may know, any access to this
before the super call results in an error. As such, the "new object" is created in the super call (note that the discussed applies again to that call to Construct - should the SuperConstructor not extend anything, the non-deriving case, otherwise this one - with the only difference being newTarget).
To elaborate on the newTarget forwarding, here is an example of how this behaves:
class A { constructor() { console.log(`newTarget: ${new.target.name}`); } }
class B extends A { constructor(){ super(); } }
console.log(
`B.prototype's prototype: ${Object.getPrototypeOf(B.prototype).constructor.name}.prototype`
);
console.log("Performing `new A();`:");
new A();
console.log("Performing `new B();`:");
new B();
As [[Construct]] calls OrdinaryCreateFromConstructor with newTarget as parameter, which is always forwarded, the prototype used will be the correct one at the end (in above example, B.prototype
, and note that this in turn has A.prototype
as prototype, aka Object.getPrototypeOf(B.prototype) === A.prototype
). It's good to look at all the related parts (super call, Construct, [[Construct]], and OrdinaryCreateFromConstructor), and watch how they get/set or pass newTarget along. Note here aswell that the call to PrepareForOrdinaryCall also gets the newTarget, and sets it in the FunctionEnvironment of related SuperConstructor calls, so that additional chained super calls will obtain the correct one aswell (for the case of extending from something that is in turn extending from something).
Last but least, constructors can use return
to produce any object they want. This usually leads to the objects created in the previously described steps to be simply discarded. However, you can do the following:
const obj = {};
class T extends Number {
constructor() {
return obj;
}
}
let awkward = new T();
In this very awkward case, there is no call to super
, which is however also no error, as the constructor simply returns some previously made object. Here, at least from what i could see, no object will be created at all when using new T()
.
There is another side effect. Should you extend from a constructor, which returns some self-made object, the forwarding of newTarget and all that has no effect, the prototype of the extending class is simply lost:
class A {
constructor() {
// The created object still has the function here.
// Note that in all normal cases, this should not
// be in the constructor of A, it's just to show
// what is happening.
this.someFunc();
//rip someFunc, welcome someNewFunc
return {
someNewFunc() { console.log("I'm new!"); }
};
}
}
class B extends A {
constructor() {
super();
//We get the new function here, after the call to super
this.someNewFunc();
}
someFunc() { console.log("something"); }
}
console.log("Performing `new B();`:");
let obj = new B();
console.log("Attempting to call `someFunc` on the created obj:");
obj.someFunc(); // This will throw an error.
PS: I read a lot of this in the spec for the first time myself aswell, so there may be some mistakes. My own interest was to find out how extending built-ins works (stemming from a different debate from a while ago). To understand that, after the above, needs only one last thing: we notice e.g. for the Number constructor, that it checks for "If NewTarget is undefined [...]", and otherwise properly calls OrdinaryCreateFromConstructor, with NewTarget, while adding the internal [[NumberValue]] slot, then setting it in the next step.
Edit to attempt answering questions in the comments:
I think you are still looking at class
and the ES5 way as two separate things. class
is almost entirely syntactic sugar, as has already been mentioned in comments on the question. A class is nothing more than a function, similar to the "old ES5 way".
Towards your first question, the "method" you mention, is the function, which one would use in the ES5 way (and what the variable will hold, class A extends Number {}; console.log(typeof A === "function" && Object.getPrototypeOf(A) === Number);
). The prototype is set, to achieve what you earlier noted as "inheriting static properties". Static properties are nothing more than properties on the constructor (if you ever used the ES5 way).
The [[HomeObject]] is used for access to super
, as explained in table 27. If you look at what the related calls do (see table 27, GetSuperBase), you will notice it, in essence, just does "[[HomeObject]].[[GetPrototypeOf]]()". That will be the superclass prototype, as it should be, so that super.someProtoMethod
works on the superClass' prototype.
For the second question, i think it's best to just go through an example:
class A { constructor() { this.aProp = "aProp"; } }
class B extends A { constructor() { super(); this.bProp = "bProp"; }
new B();
I'll try to list the interesting steps, performed in order, when new B();
is being evaluated:
new
calls Construct, which, as there is no current newTarget, calls [[Construct]] of B
with newTarget now set to B
.
[[Construct]] encounters a kind which is not "base", and as such does not create any object
PrepareForOrdinaryCall, for the execution of the constructor, generates a new execution context, along with a new FunctionEnvironment (where [[NewTarget]] will be set to newTarget!), and makes it the running execution context.
OrdinaryCallBindThis is also not performed, and this
stays uninitialized
OrdinaryCallEvaluateBody will now start executing the constructor of B
The super call is encountered and executed:
GetNewTarget() retrieves the [[NewTarget]] from the FunctionEnvironment, which was previously set
Construct is called on the SuperConstructor, with the retrieved newTarget
It calls [[Construct]] of the SuperConstructor, with the newTarget
The SuperConstructor has kind "base", as such it performs OrdinaryCreateFromConstructor, but with the newTarget set. This is now in essence Object.create(B.prototype)
, and note again, that Object.getPrototypeOf(B.prototype) === A.prototype
, that's already set on the function B
, from the class construction.
Similarly to above, a new execution context is being made, and this time, OrdinaryCallBindThis is also done. The SuperConstructor will execute, produce some object, the execution context is popped again. Note that should A
in turn extend something else again, newTarget is properly set everywhere again, so it would just go deeper and deeper.
super takes the result from Construct (the object that the SuperConstructor produced, which does have B.prototype
as prototype, should nothing exceptional happen - as discussed, e.g. the constructor returns some other value, or the prototype was manually changed), and sets it as this
in the current environment, which is the one that is used to execute the constructor of B
(the other has been popped already).
execution of the constructor of B
continues, with this
now initialized. It is an Object, which has B.prototype
as prototype, which in turn has A.prototype
as prototype, and on which the A
constructor was already called (again, should nothing exceptional have happened), so this.aProp
already exists. The constructor of B
will then add bProp
, and that object is the result of new B();
.
Upvotes: 3