ES6 classes: deferring the creation step
Please do the following substitutions in my message:
- "created-but-initialised" → "created-but-not-initialised"
- "an ordinary object" → "an object (i.e. a value of type Object) that is not a Non-Constructed Object" (2x)
—Claude
Le 27 juin 2014 à 15:56, Claude Pache <claude.pache at gmail.com> a écrit :
I like it! Cc'ing others who may have missed it. Boris is DOM guru you seek.
Does it address the bound function issue you cited in the previous thread? It appears not to, but I might be missing something (jetlag).
Brendan Eich wrote:
I like it! Cc'ing others who may have missed it. Boris is DOM guru you seek.
Forgot to suggest a better spec-name: "Pre-constructed" instead of "Non-Constructed".
Le 28 juin 2014 à 17:49, Brendan Eich <brendan at mozilla.org> a écrit :
I like it! Cc'ing others who may have missed it. Boris is DOM guru you seek.
Does it address the bound function issue you cited in the previous thread? It appears not to, but I might be missing something (jetlag).
It does address the issue (if my reasoning is correct).
Rethinking on the subject, I think that the intended semantics would have appeared more clearly, if I had kept a modified [[Construct]] internal method separate from the [[Call]] one. Then, the new
operator would invoke [[Construct]], but, more notably, super
would also invoke [[Construct]] when the this-binding is not yet initialised (i.e., when this
is a Non-Constructed Object). — super
invocations occurring when this
is initialised regularly invoke [[Call]].
So, roughly, the first super
in a constructor would invoke the super-constructor with the semantics of a constructor rather than of a method. Therefore, subclassing objects with even wildly different [[Construct]] and [[Call]] behaviours would just work.
Also, the @@new hook would just have been an exact override of the modified [[Construct]] internal method. (Which is another reason why I should have kept [[Construct]] and [[Call]] separate.)
This looks nice to me as well. But given the length I had trouble internalizing in what externally-visible ways it would change the status quo, especially with the optional @@create addition. The writeup as-is is very spec-language-focused. Would it be simple to summarize that for us?
There's also the question of what implementers think. To me it seemed a little more "weird" than @@create, such that the exact implementation details might not straightforwardly translate to implementations. But then again, implementers generally dislike @@create, so I don't think my intuition here is very good.
Thanks Claude for working this up.
InitializeThisBindings(nonconstructedObj, obj) abstract operation
This operation performs the actual initialisation of the this-bindings that were previously deferred:
- Assert
nonconstructedThisObj
is a Non-Constructed Object.- Assert
obj
is an ordinary object.- Replace all references to
nonconstructedThisObj
with references toobj
. (In particular, this step will effectively initialise the this-binding of every function environment record that used to referencenonconstructedObj
.)
I was vaguely thinking along similar lines last week, but was stumped at this point. Is step #3 possible? You'd basically have to (magically?) replace the "this" binding for any subclass constructors on the call stack. This is probably a question for Allen.
Le 29 juin 2014 à 00:36, Domenic Denicola <domenic at domenicdenicola.com> a écrit :
This looks nice to me as well. But given the length I had trouble internalizing in what externally-visible ways it would change the status quo, especially with the optional @@create addition. The writeup as-is is very spec-language-focused. Would it be simple to summarize that for us?
I plan to rewrite new version of the proposal very soon, with a less spec-language-oriented description (and with some amendments for better handling edge cases).
The problem of observability of created-but-not-initialised instances is not exactly solved in presence of the @@create hook. I think that the main interest is that without that hook, subclassing does work and the aforementioned problem is solved.
Le 29 juin 2014 à 04:46, Kevin Smith <zenparsing at gmail.com> a écrit :
Thanks Claude for working this up.
InitializeThisBindings(nonconstructedObj, obj) abstract operation
This operation performs the actual initialisation of the this-bindings that were previously deferred:
- Assert
nonconstructedThisObj
is a Non-Constructed Object.- Assert
obj
is an ordinary object.- Replace all references to
nonconstructedThisObj
with references toobj
. (In particular, this step will effectively initialise the this-binding of every function environment record that used to referencenonconstructedObj
.)I was vaguely thinking along similar lines last week, but was stumped at this point. Is step #3 possible? You'd basically have to (magically?) replace the "this" binding for any subclass constructors on the call stack. This is probably a question for Allen.
In fact, what I really need, is that the this-binding acts like a const-binding: It may be uninitialised at first, and will be initialised after the first call to a super
-method (in most non-buggy code, it will be the super-constructor).
Le 27 juin 2014 à 15:56, Claude Pache<claude.pache at gmail.com> a écrit :
Here is a counter-proposal (or an improved proposal, ad libidum), which is not tightly coupled to @@new.
This proposal seems ok to me from my DOM-centric perspective. ;)
Optional: the @@create hook
A @@create hook can easily be placed as follows: In each [[Call]] internal methods defined above, a call to (
thisArgument
.[[Constructor]]).@@create could replace the steps spanning from GetPrototypeFromConstructor(...) inclusive to InitializeThisBindings(...) exclusive.Whether such a hook is compatible with, e.g., DOM constructors, is left to the appreciation of the competent people. At worst, a built-in constructor could cheat by defining its own [[Call]] internal method that would refuse to run the @@create hook.
The problem with @@create is not so much whether built-in constructors call it. It's whether user code can invoke built-in @@create hooks and hence observe partially-constructed object.
However, with this proposal it seems easy enough to just not provide any such built-in @@create hooks, right?
Here is an updated version of my proposal. Superficially, there are notable changes on how the things are presented, but the observable behaviour remains basically the same.
For a quick understanding, the Simple Description should suffice. In the Detailed Semantics, I’ve tried to give just enough details to make the precise semantics clear.
Simple Description
If a constructor C
does not use super
, it is assumed to not be a subclass,
and it has the ES5- behaviour (or, equivalently, the current specced behaviour
without the user-overridable-and-callable @@create hook),
except that the [[Prototype]] may not always be taken from C.prototype
,
but from some D.prototype
, where D
is a subclass of C
as described below.
If a constructor C
uses super
in its code, let’s say:
class C extends B {
constructor(...args) {
/* 1: preliminary code that doesn't contain calls to a super-method */
// ...
/* 2: call to a super-constructor */
super(...whatever)
/* 3: the rest of the code */
// ...
}
}
the following occurs when C
is invoked as constructor,
e.g. using new C(...args)
:
-
During phase /* 1 */, the this-binding is uninitialised; trying to access it through an explicit
this
keyword will throw a ReferenceError. -
At phase /* 2 */, a call to the super-constructor (or, indeed, any super-method call) will in fact invoke it with the semantics of a constructor, and will initialise the this-binding. It is somewhat as if doing,
this = new super(...whatever)
except that the default prototype of the created object will be
D.prototype
rather thansuper.prototype
, whereD
is the constructor on which thenew
operator was originally applied (the reference toD
being forwarded by the super-call as needed). -
During phase /* 3 */, the this-binding is initialised, and any call to a super-method has the normal semantics of method (not constructor).
That’s it.
The fact that the super
is called with the semantics of a constructor means
that subclassing will just work, including for functions that have different
behaviour when used as constructor and when used as function or method.
This is the case, e.g., for bound functions (see 1), or, say, Date
(that is, without the need of some hack).
Note that constructor(...args) { super(...args) }
has now the same meaning as
constructor(...args) { return super(...args) }
(when called as constructor),
so that the issue mentioned in bug 2491 needs to be reconsidered.
This is resolved by tweaking Object.[[Construct]]
, as shown at the
end of that message.
The @@create hook is gone, in order to avoid completely the observability of partially-constructed built-in objects.
Also, there is no @@new hook 2, because the constructor
method is
the @@new hook. A notable fact is that a constructor can always override its
default constructed object by returning another object, which is a legacy
feature of ES5- non-subclassable constructors.
(And, in order to give credit where it is due, I must mention that it is the @@new proposal of Jason 2 which has been the starting point of my reflection, by trying to give the most rational semantics of the @@new behaviour.)
Detailed semantics
Additional semantics for [[Call]] internal method
Recall that the call behaviour of a function is encoded in the [[Call]] internal method. Besides its current behaviour, the following features are added.
The signature has a supplementary optional argument thisConstructor
:
F.[[Call]] (thisArgument, argumentsList, thisConstructor)
The intent of thisConstructor
is to keep track of the original constructor
on which a new
operator was applied.
The thisArgument
may receive the special value empty
, meaning that the
this-binding will not be initialised; it may be initialised once
(as for a const
-binding).
Moreover, the Completion record (either normal or abrupt) returned from [[Call]] will hold an additional [[thisValue]] field, which, unless otherwise specified:
- is set to the original
thisArgument
if it was not empty; or, - is set to the value (at the time of completion) of the this-binding, if it was initially uninitialised but has been initialised; or,
- is absent if the this-binding was left uninitialised.
(If an abrupt completion is forwarded, its [[thisValue]] field shall be modified as needed.)
We will use the following notations (they are properly methods of function environment records):
- GetThisBinding() ― get the value of the this-binding of the appropriate function environment record.
- InitializeThisBinding(
value
) ― initialise an uninitialised this-binding. - GetThisConstructor() — retrieve the
thisConstructor
value that was passed to [[Call]].
Modified semantics of the [[Construct]] internal method
The [[Construct]] internal method determines the behaviour of a function
when invoked as constructor. Relatively to what is currently specced, it has
a supplementary argument receiver
:
F.[[Construct]] (receiver, argumentsList)
The argument receiver
is intended to receive the reference to the original
constructor on which a new
operator was applied.
In particular, new F(...args)
will trigger F.[[Construct]](F, args)
.
Main differences from the currently specified [[Construct]] internal method are:
- when effectively constructing the object, the prototype is searched on
receiver.prototype
rather thanF.prototype
; - the Completion record returned by [[Construct]], if not abrupt, shall have its [[value]] field and its [[thisValue]] field set to a same value of type Object. (That condition is here for easing the subsequent use of the Completion Record.)
Also, it will be handy to use the following notation, where R
is a
Completion record typically returned from [[Call]]:
ReturnNormalizedConstructCompletion(`R`).
as an abbreviation for:
- If
R
is an abrupt completion, returnR
. - Else, if Type(
R.[[value]]
) is Object, returnCompletion{[[type]]: normal, [[value]]: R.[[value]], [[thisValue]]: R.[[value]]}
. - Else, if
R.[[thisValue]]
is present and Type(R.[[thisValue]]
) is Object, returnCompletion{[[type]]: normal, [[value]]: R.[[thisValue]], [[thisValue]]: R.[[thisValue]]}
. - Else, throw a ReferenceError, with its
[[thisValue]]
field set toR.[[thisValue]]
ifR.[[thisValue]]
is present.
F.[[Construct]] (receiver, argumentsList) for user-defined functions
When F
is a user-defined, non-arrow function its [[Construct]] internal
method does the following.
-
If
F
does not usesuper
(that is, in spec language, ifF.[[NeedsSuper]]
is false):- Let
obj
be the result of OrdinaryCreateFromConstructor(receiver.prototype
, "%ObjectPrototype").
// this is roughlyobj = Object.create(receiver.prototype || Object.prototype)
- ReturnIfAbrupt(
obj
). - Let
result
be the result ofF.[[Call]](obj, argumentsList, receiver)
. - ReturnNormalizedConstructCompletion(
result
).
- Let
-
If
F
usessuper
, the creation step is deferred:- Let
result
be the result ofF.[[Call]](empty, argumentsList, receiver)
. - ReturnNormalizedConstructCompletion(
result
).
- Let
Semantics of a super-method call
When, say, super.method(..args)
is called, the following steps are taken:
- Let
F
be the method referenced bysuper.method
. - Let
thisValue = GetThisBinding()
. - If
thisValue
is not empty,
a. Letresult
be the result ofF.[[Call]](thisValue, args)
. - Else,
a. Letreceiver = GetThisConstructor()
.
b. Letresult
be the result ofF.[[Construct]](receiver, args)
.
c. If Type(result.[[thisValue]]
) is Object,
i. InitializeThisBinding(result.[[value]]
).
d. Assert: If the test in previous step was negative, thenresult
is an abrupt completion. - Return
result
.
Special behaviour of the Object constructor
The [[Construct]] internal method of Object
:
Object.[[Construct]] (receiver, argumentsList)
has the following semantics:
- If
receiver
isObject
,
a. Letresult
beObject.[[Call](undefined, argumentsList)
.
// this is the usual factory function. - Else,
a. Letresult
be OrdinaryCreateFromConstructor(receiver
, "%ObjectPrototype").
// which is roughlyObject.create(receiver.prototype || Object.prototype)
- ReturnNormalizedConstructCompletion(
result
).
The test of step 1 distinguishes between invocations of [[Construct]]
coming directly from new
from those triggered by super
.
That will gracefully handle accidental calls of the Object constructor
that occur in situations described in bug 2491.
Moreover, the Completion record (either normal or abrupt) returned from [[Call]] will hold an additional [[thisValue]] field, which, unless otherwise specified:
- is set to the original
thisArgument
if it was not empty; or,- is set to the value (at the time of completion) of the this-binding, if it was initially uninitialised but has been initialised; or,
- is absent if the this-binding was left uninitialised.
Hmm, mixed feelings about extending the Completion record type to hold another field. It could be problematic for implementors to store this additional state efficiently. On second thought, it should be possible to merge [[thisValue]] with the existing [[Value]] field, and conditionally handle [[Value]] == empty in [[Construct]]? [1]
Moreover, the Completion record (either normal or abrupt) returned from [[Call]] will hold an additional [[thisValue]] field, which, unless otherwise specified:
- is set to the original
thisArgument
if it was not empty; or,- is set to the value (at the time of completion) of the this-binding, if it was initially uninitialised but has been initialised; or,
- is absent if the this-binding was left uninitialised.
Tail-call semantics may interfere with determining the value of "this-binding at the time of completion", because the execution context was already popped from the stack at that time. Probably solvable by determining the this-binding before performing the tail-call, plus some special casing when the tail-call expression is a super-call.
When, say,
super.method(..args)
is called, the following steps are taken:
- Let
F
be the method referenced bysuper.method
.
This part needs to be redefined. super.method
is an abbreviation for:
- Let env be GetThisEnvironment().
- Let baseValue be GetSuperBase(env).
- Let actualThis be GetThisBinding().
- Let result be baseValue.[[Get]]("method", actualThis).
But at this
-binding initialisation time, actualThis
is still the
empty placeholder object, so it cannot be used as the receiver argument
for the [[Get]] internal method call.
- During phase /* 1 */, the this-binding is uninitialised; trying to access it through an explicit
this
keyword will throw a ReferenceError.
This seems overly restrictive to me. The common case (as in ES5) will be classes that derive from Object, where no such restriction is necessary.
The crux of the problem is that some host-defined classes that cannot safely separate object allocation from initialization. What if the @@create hook was allowed to return undefined? In such a case, your "uninitialized" semantics would apply: attempting to dereference "this" before calling super(...) would throw an error, and super(...) would essentially set the "this" value? In all other cases, the currently drafted semantics would apply.
(Sorry for the delay of response, I am currently in a remote area.)
Le 7 juil. 2014 à 19:02, Kevin Smith <zenparsing at gmail.com> a écrit :
- During phase /* 1 */, the this-binding is uninitialised; trying to access it through an explicit
this
keyword will throw a ReferenceError.This seems overly restrictive to me. The common case (as in ES5) will be classes that derive from Object, where no such restriction is necessary.
(To be more precise, the intended restriction is a TDZ, just like const
declarations.)
There are trade-offs that should be made. Are there many cases where you want to manipulate this
before calling super
?
The crux of the problem is that some host-defined classes that cannot safely separate object allocation from initialization. What if the @@create hook was allowed to return undefined? In such a case, your "uninitialized" semantics would apply: attempting to dereference "this" before calling super(...) would throw an error, and super(...) would essentially set the "this" value? In all other cases, the currently drafted semantics would apply.
One feature of the proposal is that the first call to super
invokes the constructor with the semantics of [[Construct]] rather than [[Call]], so that subclassing classes that have different construct/call behaviour would just work. It implies that there is no preliminary creation step.
(Sorry for the delay of response, I am currently in a remote area.)
Le 6 juil. 2014 à 20:15, André Bargull <andre.bargull at udo.edu> a écrit :
Moreover, the Completion record (either normal or abrupt) returned from [[Call]] will hold an additional [[thisValue]] field, which, unless otherwise specified:
- is set to the original
thisArgument
if it was not empty; or,- is set to the value (at the time of completion) of the this-binding, if it was initially uninitialised but has been initialised; or,
- is absent if the this-binding was left uninitialised.
Hmm, mixed feelings about extending the Completion record type to hold another field. It could be problematic for implementors to store this additional state efficiently. On second thought, it should be possible to merge [[thisValue]] with the existing [[Value]] field, and conditionally handle [[Value]] == empty in [[Construct]]? [1]
An issue here is that I want to initialise the this-binding of the caller even when the callee ends abruptly (with throw
).
Moreover, the Completion record (either normal or abrupt) returned from [[Call]] will hold an additional [[thisValue]] field, which, unless otherwise specified:
- is set to the original
thisArgument
if it was not empty; or,- is set to the value (at the time of completion) of the this-binding, if it was initially uninitialised but has been initialised; or,
- is absent if the this-binding was left uninitialised.
Tail-call semantics may interfere with determining the value of "this-binding at the time of completion", because the execution context was already popped from the stack at that time. Probably solvable by determining the this-binding before performing the tail-call, plus some special casing when the tail-call expression is a super-call.
When, say,
super.method(..args)
is called, the following steps are taken:
- Let
F
be the method referenced bysuper.method
.This part needs to be redefined.
super.method
is an abbreviation for:
- Let env be GetThisEnvironment().
- Let baseValue be GetSuperBase(env).
- Let actualThis be GetThisBinding().
- Let result be baseValue.[[Get]]("method", actualThis).
But at
this
-binding initialisation time,actualThis
is still the empty placeholder object, so it cannot be used as the receiver argument for the [[Get]] internal method call.
Here is another setting that I've thought of: When super
is called with a not-yet-initialised this-binding, the this-binding of the callee is in fact a special pointer to the function environment record of the caller, and when the callee wants to read or update it's this-binding, it reads or update the one of the caller.
In the currently specced design of classes, the fact that the creation step and the initialisation step of built-in object may be separated by arbitrary user code is thought to be problematic.
Jason proposed a @@new behaviour in replacement of @@create that would avoid the issue 1. Here is a counter-proposal (or an improved proposal, ad libidum), which is not tightly coupled to @@new. In fact, it is designed to (well, TBH, it happens to) just work in absence of @@create or @@new hook, although it is possible to introduce them.
The basic idea is the following: the creation process (the @@create step) is deferred as late as possible. It will appear that, for built-in base classes like Array, creation occurs just before initialisation, making the created-but-initialised state of such objects unobservable, at least in absence of user-overridable @@create hook.
Non-Constructed Objects
Non-Constructed Objects are introduced in order to describe the state of not-yet-defined this-bindings.
Non-Constructed Objects is probably not be the greatest approach to spec the thing, especially when considering the awful Step 3 of InitializeThisBindings() algorithm below; it is just a convenient hack that allows me to expose the idea with minimal change from the current specification draft.
A Non-Constructed Object is a special placeholder exotic object with the following internal slots:
true
.Non-Constructed Objects may only appear as value of this-bindings inside functions (or, using the spec language, as value of the
thisValue
component of a function environment record). Any attempt to get an explicit reference to such an object in user code will throw an error. The only way to pass (implicitely) a reference to a Non-Constructed Object between different function environment records, is through calls tosuper
.InitializeThisBindings(nonconstructedObj, obj) abstract operation
This operation performs the actual initialisation of the this-bindings that were previously deferred:
nonconstructedThisObj
is a Non-Constructed Object.obj
is an ordinary object.nonconstructedThisObj
with references toobj
. (In particular, this step will effectively initialise the this-binding of every function environment record that used to referencenonconstructedObj
.)Additional runtime semantics of the
this
keywordAny attempt to get an explicit reference to the
thisValue
of a function environment record while it holds a Non-Constructed Object, shall throw an error.new C(...args)
When a constructor
C
is called with argumentsargs
, the following steps are taken. In particular, the actual initialisation of the this-value is deferred.thisValue
be a new Non-Constructed Object, with its internal slot [[Constructor]] set toC
.R
be the result ofC
.[[Call]](thisValue
,args
).thisValue
may now be a regular object.R
).R
) is Object, returnR
.thisValue
is a Non-Constructed Object, throw an error.thisValue
.No more [[Construct]]
[[Construct]] internal method is gone. Actually it is conflated with the [[Call]] internal method, modified as below. The key fact is that [[Call]] is able to see if it should have a [[Construct]]-like behaviour, by examining whether its
thisArgument
is a Non-Constructed Object.F.[[Call]] (thisArgument, argumentsList) for user-defined functions
User-defined functions has the currently specced [[Call]] behaviour Section 9.2.2, with the following additional step inserted somewhere near the beginning of the algorithm, e.g., after step 1:
1bis. If the
thisArgument
is a Non-Constructed Object, a. IfF
’s [[NeedsSuper]] internal slot is set to false (IOW, ifF
’s code doesn’t containsuper
), i. Letproto
be the result of GetPrototypeFromConstructor(thisArgument
.[[Constructor]], "%ObjectPrototype%"). ii. ReturnIfAbrupt(proto
). iii. Letobj
be ObjectCreate(proto
). iv. Perform InitializeThisBindings(thisArgument
,obj
). v. Assert: Now,thisArgument
is an ordinary object. b. Else,thisArgument
is left untouched. // it is meant to be handled at the occasion of the enclosedsuper
call.F.[[Call]] (thisArgument, argumentsList) for bound functions
In the algorithm sepcced in Section 9.4.1.1, step 2 is replaced with:
thisArgument
is a Non-Constructed Object, letboundThis
bethisArgument
. // this is the current [[Construct]] behaviour 2bis. Else, letboundThis
be the value ofF
’s [[BoundThis]] internal slot. // this is the current [[Call]] behaviourF.[[Call]] (thisArgument, argumentsList) for the built-in Object constructor
There is no change:
Object(...)
acts as a factory rather than as a constructor, as currently specified, and thethisArgument
is ignored. In particular trying to subclassObject
will lead to unexpected results. Note however thatnew Object
does still work.F.[[Call]] (thisArgument, argumentsList) for the built-in Array contsructor
The [[ArrayInitializationState]] internal slot is gone, and Step 4 of the algorithms in Sections 22.1.1.* is replaced with (where
O
is the this-value):O
is a Non-Constructed Object, a. Letproto
be the result of GetPrototypeFromConstructor(O
.[[Constructor]], "%ArrayPrototype%"). b. ReturnIfAbrupt(proto
) a. Letarray
be ArrayCreate(<<length>>,proto
). b. Perform InitializeThisBindings(thisArgument
,array
)The [[Call]] behaviour of other built-in constructors is left as an exercise to the reader.
Comments
There is a nice side-effect of the proposal: The new internal check intended to discriminate between call-as-function and call-as-constructor is easier and more robust. In particular,
However, it remains very hard for user-defined functions to distinguish correctly between constructor/initialisation-calls and method/function-calls, or to write code that works well in both cases. (At least the situation is not worse than in ES5-.)
Optional: the @@create hook
A @@create hook can easily be placed as follows: In each [[Call]] internal methods defined above, a call to (
thisArgument
.[[Constructor]]).@@create could replace the steps spanning from GetPrototypeFromConstructor(...) inclusive to InitializeThisBindings(...) exclusive.Whether such a hook is compatible with, e.g., DOM constructors, is left to the appreciation of the competent people. At worst, a built-in constructor could cheat by defining its own [[Call]] internal method that would refuse to run the @@create hook.
Optional: the @@new hook
Alternatively, the following hook may be installed: At the beginning of each call to F.[[Call]], the following steps are taken:
thisArgument
is a Non-Constructed Object, a. IfF
has an own property named @@new, i. LetR
be the result ofF
[@@new].[[Call]](thisArgument
.[[Constructor]],argumentsList
). ii. ReturnIfAbrupt(R
). iii. If Type(R
) is Object, α. InitializeThisBindings(thisArgument
,R
). iv. ReturnR
. b. etc.Note that we don’t look for inherited @@new property, in order to preserve the initialise-at-latest-time behaviour.
—Claude