15

I'm looking for any indications whether or not "superclassing" a builtin type will work according to the specification. That is, given any hypothetical conformant implementation of ECMAScript, does "superclassing" a builtin break the runtime by affecting the creation algorithm of the class constructor?

"Superclassable", a term I'm coining, refers to a class whose objects returned by constructing it, or calling it as a function if applicable, will be created with the same internal slots (except for [[Prototype]]), regardless of what its direct superclass is, as long as the initial [[Prototype]] of the class constructor and the class prototype are still in each respective inheritance chain after reassigning them. Consequently, in order to be "superclassable", a class must not call super() during creation.

When "superclassing" an Array, I would expect it to look something like this:

// clearly this would break Array if the specification allowed an implementation
// to invoke super() internally in the Array constructor
class Enumerable {
  constructor (iterator = function * () {}) {
    this[Symbol.iterator] = iterator
  }

  asEnumerable() {
    return new Enumerable(this[Symbol.iterator].bind(this))
  }
}

function setSuperclassOf (Class, Superclass) {
  /* These conditions must be satisfied in order to
   * superclass Class with Superclass
   */
  if (
    !(Superclass.prototype instanceof Object.getPrototypeOf(Class.prototype).constructor) ||
    !(Superclass instanceof Object.getPrototypeOf(Class).constructor) ||
     (Superclass.prototype instanceof Class)
  ) {
    throw new TypeError(`${Class.name} cannot have their superclass set to ${Superclass.name}`)
  }
  
  // Now we can superclass Class with Superclass
  Object.setPrototypeOf(Class.prototype, Superclass.prototype)
  Object.setPrototypeOf(Class, Superclass)
}

setSuperclassOf(Array, Enumerable)

const array = new Array(...'abc')

// Checking that Array is not broken by Enumerable
console.log(array[Symbol.iterator] === Array.prototype[Symbol.iterator])

// Checking that Enumerable works as expected
const enumerable = array.asEnumerable()

console.log(array instanceof Enumerable)
console.log(!(enumerable instanceof Array))

for (const letter of enumerable) {
  console.log(letter)
}

One of my biggest concerns is that internally, in a possibly conformant implementation, Array could potentially look like this, which would mean that Array is not "superclassable":

class HypotheticalArray extends Object {
  constructor (...values) {
    const [value] = values

    // this reference would be modified by superclassing HypotheticalArray
    super()

    if (values.length === 1) {
      if (typeof value === 'number') {
        if (value !== Math.floor(value) || value < 0) {
          throw new RangeError('Invalid array length')
        }

        this.length = value
        return
      }
    }
    
    this.length = values.length

    for (let i = 0; i < values.length; i++) {
      this[i] = values[i]
    }
  }
  
  * [Symbol.iterator] () {
    const { length } = this

    for (let i = 0; i < length; i++) {
      yield this[i]
    }
  }
}

// Array constructor actually inherits from Function prototype, not Object constructor
Object.setPrototypeOf(HypotheticalArray, Object.getPrototypeOf(Function))

class Enumerable {
  constructor (iterator = function * () {}) {
    this[Symbol.iterator] = iterator
  }

  asEnumerable() {
    return new Enumerable(this[Symbol.iterator].bind(this))
  }
}

function setSuperclassOf (Class, Superclass) {
  /* These conditions must be satisfied in order to
   * superclass Class with Superclass
   */
  if (
    !(Superclass.prototype instanceof Object.getPrototypeOf(Class.prototype).constructor) ||
    !(Superclass instanceof Object.getPrototypeOf(Class).constructor) ||
     (Superclass.prototype instanceof Class)
  ) {
    throw new TypeError(`${Class.name} cannot have their superclass set to ${Superclass.name}`)
  }
  
  // Now we can superclass Class with Superclass
  Object.setPrototypeOf(Class.prototype, Superclass.prototype)
  Object.setPrototypeOf(Class, Superclass)
}

setSuperclassOf(HypotheticalArray, Enumerable)

const array = new HypotheticalArray(...'abc')

// Array is broken by Enumerable
console.log(array[Symbol.iterator] === HypotheticalArray.prototype[Symbol.iterator])

// Checking if Enumerable works as expected
const enumerable = array.asEnumerable()

console.log(array instanceof Enumerable)
console.log(!(enumerable instanceof HypotheticalArray))

// Iteration does not work as expected
for (const letter of enumerable) {
  console.log(letter)
}

However, Array is "superclassable" if a conformant implementation is required not to call super():

class HypotheticalArray {
  constructor (...values) {
    const [value] = values

    // doesn't ever invoke the superclass constructor
    // super()

    if (values.length === 1) {
      if (typeof value === 'number') {
        if (value !== Math.floor(value) || value < 0) {
          throw new RangeError('Invalid array length')
        }

        this.length = value
        return
      }
    }
    
    this.length = values.length

    for (let i = 0; i < values.length; i++) {
      this[i] = values[i]
    }
  }
  
  * [Symbol.iterator] () {
    const { length } = this

    for (let i = 0; i < length; i++) {
      yield this[i]
    }
  }
}

class Enumerable {
  constructor (iterator = function * () {}) {
    this[Symbol.iterator] = iterator
  }

  asEnumerable() {
    return new Enumerable(this[Symbol.iterator].bind(this))
  }
}

function setSuperclassOf (Class, Superclass) {
  /* These conditions must be satisfied in order to
   * superclass Class with Superclass
   */
  if (
    !(Superclass.prototype instanceof Object.getPrototypeOf(Class.prototype).constructor) ||
    !(Superclass instanceof Object.getPrototypeOf(Class).constructor) ||
     (Superclass.prototype instanceof Class)
  ) {
    throw new TypeError(`${Class.name} cannot have their superclass set to ${Superclass.name}`)
  }
  
  // Now we can superclass Class with Superclass
  Object.setPrototypeOf(Class.prototype, Superclass.prototype)
  Object.setPrototypeOf(Class, Superclass)
}

setSuperclassOf(HypotheticalArray, Enumerable)

const array = new HypotheticalArray(...'abc')

// Array is not broken by Enumerable
console.log(array[Symbol.iterator] === HypotheticalArray.prototype[Symbol.iterator])

// Checking if Enumerable works as expected
const enumerable = array.asEnumerable()

console.log(array instanceof Enumerable)
console.log(!(enumerable instanceof HypotheticalArray))

// Iteration works as expected
for (const letter of enumerable) {
  console.log(letter)
}

With that in mind, I'd like to reference a few points from the current draft, ECMAScript 2018:

§22.1.1 The Array Constructor

The Array constructor:

  • creates and initializes a new Array exotic object when called as a constructor.
  • is designed to be subclassable. It may be used as the value of an extends clause of a class definition. Subclass constructors that intend to inherit the exotic Array behaviour must include a super call to the Array constructor to initialize subclass instances that are Array exotic objects.

§22.1.3 Properties of the Array Prototype Object

The Array prototype object has a [[Prototype]] internal slot whose value is the intrinsic object %ObjectPrototype%.

The Array prototype object is specified to be an Array exotic object to ensure compatibility with ECMAScript code that was created prior to the ECMAScript 2015 specification.

(emphasis added)

My understanding is that a conformant implementation is not required to internally call super() within the Array constructor in order to properly initialize the instance as an array exotic, nor does it require Object to be the direct superclass of Array (though my first quote of §22.1.3 certainly seems to imply that bit).

My question is, does the first snippet above work according to the specification, or does it only work because the currently existing implementations allow it to? i.e. is the implementation of the first HypotheticalArray non-conformant?

And for full bounty award, I'd also like to apply this question to String, Set, Map, and TypedArray (by which I mean Object.getPrototypeOf(Uint8Array.prototype).constructor).

I will award 500 bounty points for the first answer that rigorously addresses my questions about the practice of "superclassing" the above builtins in ECMAScript 2015 and up (the draft in which Object.setPrototypeOf() was introduced).

I do not intend to support ECMAScript edition 5.1 and below, as modifying a builtin's inheritance chain is only possible by accessing __proto__, which is not part of any ECMAScript specification and is therefore implementation-dependent.

P.S. I am fully aware of the reasons that practices like this are discouraged, which is why I would like to determine if the specification allows for "superclassing" without "breaking the web", as TC39 likes to say.

Community
  • 1
  • 1
Patrick Roberts
  • 49,224
  • 10
  • 102
  • 153
  • Are you asking if you're allowed to change the prototype chain of an array (or other built-in types)? – Barmar Jul 05 '18 at 18:39
  • @Barmar essentially, yes, but only with the restrictions I've described in my Straw man proposal of a "superclassable" class. And I want to know if the specification guarantees that it will behave without unintended side-effects in any given implementation. – Patrick Roberts Jul 05 '18 at 18:43
  • Why in the world would you modify the behavior of existing globals like this? Even if it technically works, you'd be breaking the expectations of any existing code in the page. – loganfsmyth Jul 05 '18 at 18:53
  • @loganfsmyth The whole point of this question is to ensure that according to the specification, that doesn't happen. Unless existing code expects `Object.getPrototypeOf(Array.prototype)` to be `Object.prototype` (which there is _no_ good reason to expect that), there is no way someone would _reasonably_ expect this to break any existing code bases. There's a difference between expecting inheritance (which I'm not breaking), and expecting a direct superclass (which I'm asserting is an unreasonable expectation here). – Patrick Roberts Jul 05 '18 at 19:01
  • Alright, fair enough. – loganfsmyth Jul 05 '18 at 19:10
  • One thing I've noticed is that if you `class Test extends Object {}`, the resulting class requires a call to `super()` in the `constructor()` regardless of whether the `constructor` is explicitly defined in the definition or not, because "superclassing" it with `class Verbose { constructor () { console.log('called super()') } }` will result in a console log any time `Test` is constructed. However when declaring `class Test {}` and "superclassing" it with `Verbose`, there is no call to super. Is this behavior guaranteed by the specification? Because it seems directly related to my question. – Patrick Roberts Jul 06 '18 at 22:13
  • Are you sure that `Object.setPrototypeOf(Array, Enumerable)` lets the constructors `super` point to enumerable? – Jonas Wilms Jul 07 '18 at 08:15
  • @JonasW. if you look at my answer, I'm saying that (fortunately) `Array`'s constructor never calls `super()` according to the specification, but for a class whose [[ConstructorKind]] is "derived", I'm saying yes, that would modify what `super` references, and therefore a derived class is not "superclassable". – Patrick Roberts Jul 07 '18 at 08:37
  • I should clarify, a derived class is actually superclassable as long as the inserted superclass has an implicit constructor (which according to the specification is defined as `constructor (...args) { super(...args) }`), or it forwards arguments like that in addition to its own class-specific initialization. – Patrick Roberts Jul 07 '18 at 08:45

2 Answers2

3

Based on the guarantee outlined in §9.3.3 CreateBuiltinFunction ( steps, internalSlotsList [ , realm [ , prototype ] ] ) and the steps in §22.1.1 The Array Constructor, no possible invocation of Array(…) or new Array(…) will call the Object constructor, or the constructor of Array's resolved super class at the time of invocation, and therefore "superclassing" Array is guaranteed to behave properly in any conformant implementation of ECMAScript 2018.

Due to the discovery of §9.3.3, I suspect the same conclusion will be drawn for the remaining classes in the current specification, though there is a lot more research required to determine whether this is accurate, and guaranteed back to ECMAScript 2015.

This is not a full answer, and therefore I will not be accepting it. A bounty will still be rewarded to a full answer, regardless of whether or not it is provided before my question is eligible for bounty.

Patrick Roberts
  • 49,224
  • 10
  • 102
  • 153
3

Calling your setSuperclassOf function on any ECMAScript builtin class will not affect the behaviour of the constructor.

Your HypotheticalArray constructor should not - must not - call super(). In the spec, you should not just look at the The Array Constructor section which gives a short overview, but also at the subsections §22.1.1.1 Array(), §22.1.1.2 Array(len) and §22.1.1.3 Array(...items) which give the detailed algorithms of what happens when you call Array (as either a function or constructor). They do look up the prototype of the newTarget (to be subclassable as usual - since ES6), but they do not look up the prototype of the Array function itself. Instead, they all do directly dispatch to the ArrayCreate algorithm, which just creates an object, sets up its prototype and installs the exotic property semantics.

It's similar for String (which dispatches to the StringCreate algorithm when called as a constructor), the abstract TypedArray constructor (which just throws and explicitly states that "The TypedArray constructors do not perform a super call to it."), the concrete TypedArray constructors (which dispatch to the AllocateTypedArray and IntegerIndexedObjectCreate algorithms), and the Map and Set constructors (which both dispatch to the OrdinaryCreateFromConstructor and ObjectCreate algorithms). And afaik it's the same for all other builtin constructors as well, though I haven't checked each of them individually, there are just too many as of ES8.

My understanding is that because Array.prototype itself is an Array exotic object, a compliant implementation is not required to internally call super() within the Array constructor in order to properly initialize the instance as an array exotic

No, that has nothing to do with it. An object doesn't become exotic because it inherits from an exotic object. An object is exotic because it was specifically created as such. The value of Array.prototype can be really anything, it's not relevant for the creation of array instances - apart from that it'll be used as the prototype when new Array is called (in contrast to new ArraySubclass).

Regarding Object.setPrototypeOf(Array.prototype, …), notice that Array.prototype is not even an immutable prototype exotic object like Object.prototype, so yes you are allowed to do that.

Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • @PatrickRoberts *ArrayCreate* does not call `Object` or resolve any superclasses either. It's not generic at all in that regard - it only is generic over the subclass prototype object to inherit from. – Bergi Jul 08 '18 at 15:43
  • @PatrickRoberts Yes, I am saying that calling `super()` in the `HypotheticalArray` is illegal (would not conform to the specification of `Array`), see the second sentence of my answer. And no, "superclassing" is not possible (not supported) by any builtin class, calling `setSuperclassOf` will not affect their constructor behaviour at all. – Bergi Jul 08 '18 at 15:49
  • 1
    You seem to be misunderstanding what I'm saying. I'm calling a class "superclassable" if calling `setSuperclassOf` _does not affect their behavior_ (other than providing new methods to instances of the class) – Patrick Roberts Jul 08 '18 at 18:30
  • And looking back at my previous comment, I think "generic" might have been a poor word choice on my part. I think we're in complete agreement other than the definition of "superclassable". – Patrick Roberts Jul 08 '18 at 18:37
  • @PatrickRoberts Oh, right, I understood "superclassable" as "supports a dynamic superclass through `setSuperclassOf`". Removed the term from my answer now. – Bergi Jul 09 '18 at 12:52
  • At this point I think your answer qualifies for the bounty, fair and square. I was considering waiting for a few more days, but looking over this, it has pretty much all the information I was looking for. – Patrick Roberts Jul 09 '18 at 13:58