Skip to content

Instantly share code, notes, and snippets.

@claudepache
Last active August 29, 2015 14:18
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save claudepache/ca20472f050443644f29 to your computer and use it in GitHub Desktop.
Save claudepache/ca20472f050443644f29 to your computer and use it in GitHub Desktop.
Enabling ECMAScript classes to work on preallocated objects

This is a proposal for amending the semantics of the ECMAScript 2015 class construct, for allowing their constructors to be passed preallocated objects.

The issue

  • User-defined constructors defined through function may either produce fresh objects, using new Foo, or initialise a preallocated object, using Foo.call(thisValue). The latter form of invocation is popular when doing subclassing or mixin.

  • Builtin constructors always produce fresh objects. They cannot initialise preallocated objects, and there were serious technical issues (IIRC, with builtins from the DOM) in attempting to do so. They cannot be subclassed the traditional pre-ES6 way, e.g. using Foo.call(thisValue).

The challenge is to make ES6 classes (that is, constructors defined through the keyword class) work seamlessly for both cases. Ideally, the constraints are:

  1. ES6 classes should be able to subclass builtins that cannot work on preallocated objects; but,
  2. ES6 classes should just work with preallocated objects (i.e., when invoked as Foo.call(thisValue)), when possible (i.e., when not subclassing builtins and other restrictions); and,
  3. ES6 classes that cannot work on preallocated objects (e.g., those that extend builtins) should protest loudly when trying to be used that way.

Classes as specified in ECMAScript 2015 verify Constraint 1. The following proposal allows to have Constraints 2 and 3 as well.

Proposed solution

Constructors (functions that can be invoked through a new expression) are classified between those that can work on preallocated objects, and those that cannot. Let’s call them preallocatable and non-preallocatable respectively (bikeshed possible for better name...)

The rules for determining the... uh... preallocatability of constructors are the following:

  • Builtins (Array, Map, ...) are typically non-preallocatable. (However one might make Error constructors preallocatable, because Error objects don’t really have a specified useful internal state.)
  • Constructors defined using the function construct are preallocatable.
  • Constructors defined using the class construct
    • are non-preallocatable if they extend a non-preallocatable constructor;
    • are preallocatable otherwise.
  • Constructors obtained by the .bind() method are non-preallocatable. This is because bound functions cannot be passed a foreign this value.

Now, let’s take a random constructor Foo:

  • When invoked as constructor (e.g., new Foo), the semantics are already known and there is nothing to change.
  • When invoked as function (e.g., Foo.call(obj))
    • if Foo is preallocatable, a normal function call occur;
    • if Foo is non-preallocatable, it depends. When Foo is the Map builtin a TypeError is thrown. (If you like splitting hairs: a normal function call occur, but that invocation is specified to always throw.) Alternative behaviours are discussed in the next section.

Inside a user-defined class constructor, the super() invocation has the following semantics:

  • If the this value is not yet initalised (or, rather, allocated), the super-constructor is invoked with the semantics of constructor and the this value is initalised accordingly, as currently specified. The super() expression evaluates to the newly-initialised this value.
  • If the this value is already initialised (allocated):
    • if the super-constructor is preallocatable, it is invoked with the semantics of a function call, forwarding the this value. The super() expression evaluates to returned value, or to the this value if the returned value is undefined.
    • if the super-constructor is non-preallocatable, a TypeError is thrown.

Those rules allow super() to just work when a class constructor is invoked either with new or through a direct call passing a preallocated object. The TypeError prevents buggy calls to non-preallocatable super-constructors.

Direct calls on non-preallocatable constructors

What should be the behaviour of Foo.call(thisValue) when Foo is non-preallocatable? There are the following possibilities:

  1. Throw a TypeError. This is how Map is specified.
  2. Ignore the this value and pretend that it was invoked as a constructor. This is what Array does.
  3. Have some alternative semantics. This is how Date (mis)behaves.

For bound functions, Option 3 is used, basically because it is not possible to guess the programmer’s intentions. For constructors defined through the class construct, any three possibility could be picked, but Option 3 is the one that gives the most freedom (e.g., if someone wants to mimic the Date behaviour, don’t ask me why). However, one sould be careful to remain safe, in the sense that Constraint 3 (see Section “The Issue”) is not violated.

Concretely, let’s take Foo a non-preallocatable user-defined class constructor, and let’s say that Foo.call(thisValue) will do the most naive thing, that is, invoke Foo with the semantics of function call, passing thisValue as the this value.

Because Foo is non-preallocatable, it is extending by definition a non-preallocatable constructor. Then, as soon as the super-constructor is invoked using super(), a TypeError is thrown. That means that, without an explicit action from the programmer, a TypeError will probably be thrown, like Option 1 but somewhat later.

Moreover, the programmer can easily opt-in for Option 2 or 3 with a simple test on new.target.

Example

class Foo { /* ... */ }
class Bar extends Foo { /* ... */ }

// the two following lines are equivalent, unless the programmer
// does strange things in the code of Foo or of Bar:
let bar = new Bar(42)
let bar = Object.create(Bar.prototype); Bar.call(bar, 42)

// Now let’s try:
class MagicArray extends Array { /* ... */ }

// the following code will throw when the MagicArray constructor
// attempts to invoke `super()`:
let a = Object.create(MagicArray.prototype); MagicArray.call(a)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment