1

I have a sub-class of Array that I'm using for an internal project. Some of the methods I add need to return a new array. I'm trying to figure out what's the best way to create that new array.

I don't want to hard-code it to create an array of my specific subclass because if someone else subclasses my class and that's the source array class, then it should create an object of that subclass. In other words, I want to create a new object that is the same class as the current this object no matter how many other subclasses of that there are below mine.

The Array class itself already does something like this. If you sub-class Array and then use the normal .map() function on an instance of your sub-class, it returns a new Array using your class.

The ECMAScript spec for this is here for .map() and here for ArraySpeciesCreate() that .map() uses, but I can't figure out what all this spec logic translates to in terms of actual real-world Javascript code.

Right now, I'm just using:

let newArray = new this.constructor();

and it seems to work in my own little world, but I'm wondering if all that logic in ArraySpeciesCreate() should involve more code than this?

FYI, here's ArraySpeciesCreate() from the ECMAScript spec which .map() is supposed to follow to create the new array it returns. That is what I'm presumably trying to follow also.

enter image description here

What actual Javascript code would one use to implement this in your own class?


Here's an example of a method from my Array subclass:

// break an array up into chunks
// the last chunk may have fewer items
// returns an array of arrays
chunk(chunkSize) {
    if (!Number.isInteger(chunkSize) || chunkSize <= 0) {
        throw new TypeError('chunkSize must be a positive integer');
    }
    const output = new this.constructor();
    const numChunks = Math.ceil(this.length / chunkSize);
    let index = 0;
    for (let i = 0; i < numChunks; i++) {
        output.push(this.slice(index, index + chunkSize));
        index += chunkSize;
    }
    return output;
}

This line of code in that method:

const output = new this.constructor();

is the one I'm asking about that is supposed to implement the ArraySpeciesCreate() logic.

jfriend00
  • 683,504
  • 96
  • 985
  • 979
  • ... just out of curiosity ... did the OP already stumble across [`Symbol.species`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/species)? – Peter Seliger May 25 '20 at 20:34
  • @PeterSeliger - Nope, didn't know about that or exactly how to use it here. The MDN example is actually for the opposite of what I'm asking (how to NOT return a derived class). Should I be getting that value instead of using `this.constructor`? – jfriend00 May 25 '20 at 20:34
  • ... Bergi already took over ... nothing to add. – Peter Seliger May 25 '20 at 20:44

3 Answers3

3

I still hold the opinion that you shouldn't subclass Array, but I can show how ArraySpeciesCreate would look if implemented in ECMAScript:

if (!Array.isArray(this))               // if not called on an array
    return new Array(len);

const {constructor} = this;
if (typeof constructor == "function"    // if the constructor looks like a constructor,
  && !Function.prototype.isPrototypeOf(constructor) // but appears to stem from a
                                        // different realm (iframe etc)
  && constructor.name == "Array")       // and might be that realm's builtin Array
    return new Array(len);

const C = constructor?.[Symbol.species];
if (C == null)                          // if there's no subclass species
    return new Array(len);

return new C(len);

You probably can leave out the weird edge case of testing for cross-realm instances, which isn't really working precisely anyway. (I doubt there's a good way to check for these, and it seems impossible to reproduce the GetFunctionRealm steps - though maybe you want to throw in some check for constructor being a native function).

In general, it just comes down to accessing Symbol.species on this.constructor, and using the result of that instead of the current class for constructing the new instance.

Alternatively, you could just cheat and use Array.prototype.slice.call(this, 0, 0) :-)

Another good solution is the ArraySpeciesCreate function from the es-abstract library, which tries to implement abstract operations as precise as possible.

Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • The same issue applies to sub-classing Set or Map or any object that has methods that return new instances so this is not only about not sub-classing Array. – jfriend00 May 25 '20 at 20:44
  • So, I see that in my own code, `this.constructor[Symbol.species]` on an instance of my class is already set to my class. Is that something that the interpreter does automatically when you define and instantiate an object from a `class` definition? So, if that is defined, I should prefer it over just `this.constructor`? – jfriend00 May 25 '20 at 20:55
  • Can the different realm stuff happen in nodejs? Or is that something you see in browsers with frames? – jfriend00 May 25 '20 at 20:56
  • Well actually in the case given `new (this.constructor[Symbol.species] ?? this.constructor)` should be enough – Jonas Wilms May 25 '20 at 20:57
  • Are there cases with manually done sub-classes (not using ES6 `class`) where `this.constructor` might not be set? – jfriend00 May 25 '20 at 21:00
  • @jfriend00 sure if lazy people do `.prototype = Object.create(...)` – Jonas Wilms May 25 '20 at 21:02
  • @jfriend00 1) Extending `Map` or `Set` is just as bad :P 2) I don't see why `[Symbol.species]` would be defined unless you did it yourself. Can you share a full code sample? 3) I'm not certain about node.js, I've never heard of it, but it might be possible with the [`vm` module](https://nodejs.org/api/vm.html) maybe? – Bergi May 25 '20 at 21:12
  • @JonasWilms I think it's meant to be `new (this?.constructor?.[Symbol.species] ?? MyArraySubclass)`, not using `this.constructor` at all. – Bergi May 25 '20 at 21:13
  • @Bergi - So, you think one should never create your own useful methods for Set, Map or Array or really for any collection by sub-classing? Why? – jfriend00 May 25 '20 at 21:17
  • Yes, I can confirm that `this.constructor[Symbol.species]` (where `this` is an instance of my class) is automatically set to my sub-class constructor and `this.constructor === this.constructor[Symbol.species]`. I didn't do anything to set it (I didn't even know about it until an hour ago). I'm running node v14.3.0. – jfriend00 May 25 '20 at 21:24
  • @jfriend00 I think subclassing is just the wrong tool for extending the functionality of a data structure. It doesn't compose well if multiple people try to do it, it needs many ugly conversions between "normal" and "special" arrays, and has essentially the same problems as extending the `Array.prototype` directly when you want to pass around instances. [Protocols](https://github.com/tc39/proposal-first-class-protocols) or the [call operator](https://github.com/tc39/proposal-bind-operator) offer much better solutions for defining extension methods. – Bergi May 25 '20 at 21:28
  • @jfriend00 Oh, I forgot (and had I typo when trying it out myself): `Array` subclasses inherit the [`Array[Symbol.species]` getter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/@@species) which implicitly returns the constructor it was accessed on. – Bergi May 25 '20 at 21:33
  • I posted the code I came up with in an answer. I'm not trying to detract from your answer (which is the only reason I understand any of this), but thought it would be useful to share the code I came up with and didn't think it belonged in the question. FYI, in trying to test my code, I tried nulling out `this.constructor` on an instance and the regular Array version of `this.slice()` throws `TypeError: object.constructor[Symbol.species] is not a constructor` if you do that so it appears that the `Array` class requires `object.constructor[Symbol.species]` to be present. – jfriend00 May 25 '20 at 21:50
  • @jfriend00 "*I tried nulling out `this.constructor`*" - don't `null` it out, set it to `undefined`. The error message is imprecise, it didn't even try to access `Symbol.species`. The `.constructor` must either be `undefined` or an object (most likely a function) with a `[Symbol.species]` that is `undefined`, `null` or a constructor function. – Bergi May 26 '20 at 09:17
2

Credit here goes to Bergi for explaining what the ECMAScript logic meant in terms of actual Javascript code.

For completeness, I wanted to share a general purpose utility function I'm using to create another object like the one I already have (even if it's a derived class I don't know about) that isn't just Array-specific. Since any subclass might want to use this type of logic, it makes we wonder why this logic isn't built into Javascript.

// create a new object using the class of an existing object (which might be a derived class)
// by following the ECMAScript procedure except for the realm detection part

function isConstructor(f) {
    return typeof f === "function" && !!f.prototype;
}

function speciesCreate(originalObject, fallbackConstructor, ...args) {
    const {constructor} = originalObject;
    if (constructor) {
        const C = constructor[Symbol.species];
        if (isConstructor(C)) {
            return new C(...args);
        } else if (isConstructor(constructor)) {
            return new constructor(...args);
        }
    }
    return new fallbackConstructor(...args);
}

So, in my ArrayEx class, instead of using this inside of a method to create a new object of the same class as the current instance:

let newObj = new this.constructor();

I would use this:

let newObj = speciesCreate(this, ArrayEx);

And, you can pass arguments to the constructor if needed for any specific circumstance.


An issue I see with this logic is if a derived class overrides Symbol.species and sets it to some base class, but I'm intending to create a new object that has at least the capabilities of my class, the derived class would thwart that. I guess that is what it is. If a derived class breaks things by doing this, I guess they deal with the consequences of the breakage.

jfriend00
  • 683,504
  • 96
  • 985
  • 979
0

Now I know this is an old question, but I just gave it a go, and I think I produced some nifty code for this. I didn't do much testing, but by the looks of it, it does the job. The full TypeScript source code --which includes doc-comments, and typed overload signatures etc. can be found in this gist as a self-contained module.

The main difference in my version --besides call signature and partial application-- is how it performs an instanceof check on the target againtst the fallback, as a substitute for the isArray operation in step 3 and 4. I think this helps make everything a bit more predictable: Is the target an instance of the fallback constructor? If no, just use the fallback instead of potentially returning something that the caller isn't prepared to handle.


Running the transpiled code (included at the end) in node managed to handle built-in classes using @@species just fine. From Promise to RegExp the results look correct, so it seems properly generic.

console.log(speciesCreate(Map)(new Map(), [[1, 2], [3, 4]]));
// --> Map(2) { 1 => 2, 3 => 4 }
console.log(speciesCreate(Set, new Set(), [1, 2, 3]));
// --> Set(3) { 1, 2, 3 }
console.log(speciesCreate()(/abc*/g, '123', 'g'));
// --> /123/g
console.log(speciesCreate(Promise)((async () => { })(), (resolve, reject) => setTimeout(resolve, 100)));
// --> Promise { <pending> }
console.log(speciesCreate(Array, [], 10));
// --> [ <10 empty items> ]
console.log(speciesCreate()(Uint8Array.from([]), [1, 2, 3]));
// --> Uint8Array(3) [ 1, 2, 3 ]

The function includes basic input validation and error messages, and should reproduce the behaviour in the spec pretty accurately. It's also overloaded to give 3 use options:

  1. when given all arguments, it creates a derived object.
  2. when given just the fallback constructor, it returns a factory function (target, ...args) => derived for easy re-use.
  3. when called without arguments, it returns another factory function which tries to infer what constructor to use instead of doing the instanceof operation.

So version 3 doesn't have a fallback and will just throw if it can't resolve it. I think this makes everything a bit tighter and more predictable.

Again, to see it in more detail and with doc comments and type annotation check out the gist.

// # speciesCreate.ts
// ## TypeGuards
const isConstructor = (arg) =>
  typeof arg === 'function' && typeof arg.prototype === 'object';
const isNullish = (arg) =>
  arg === undefined || arg === null;
const isObject = (arg) =>
  typeof arg === 'function' || (typeof arg === 'object' && arg !== null);

// ## Main function
export function speciesCreate(Fallback, ...args) {
    // pre-check if `Fallback` is a constructor if the argument was provided
    if (Fallback !== undefined && !isConstructor(Fallback))
        throw new TypeError('`Fallback` must be a constructor function');
    // Inner core function for automatic partial application.
    function speciesCreate(target, ...args) {
        // if `Fallback` wasn't provided
        if (!Fallback && !isConstructor(Fallback ??= target?.constructor))
            throw new Error('Cannot automatically infer from `target` what fallback to use for `@@species`.');
        if (target instanceof Fallback) {
            let C = target.constructor;
            if (isObject(C))
                C = C[Symbol.species];
            if (isConstructor(C))
                return Reflect.construct(C, args);
            if (!isNullish(C))
                throw new TypeError('Invalid `target` argument for `@@species` use.');
        }
        return new Fallback(...args);
    }
    return args.length ? speciesCreate(...args) : speciesCreate;
}