justinfagnani / mixwith.js

A mixin library for ES6

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Alternative implementation (multiple inheritance with branching prototype chain)

trusktr opened this issue · comments

Currently, mixwith creates a prototype chain where the mixin prototypes are between the subclass and super class.

Another possibility could be to use a Proxy and intercept the property get mechanism. The subclass would have a new prototypes property which is an array of prototypes (multi-inheritance). The get interceptor would loop through the prototypes and lookup the property being requested on each prototype. As for precedence, the proxy would loop from the end of the array and stop at the first prototype chain where the property is found.

Possible syntax:

class ThreeDeeObject extends multiple(Transform, EventEmitter, TreeNode) {}

where multiple returns a constructor who's prototype is the Proxy created by multiple.

If we then have

class Sphere extends ThreeDeeObject {}

then the prototype lookup works the same, but when it gets to the prototype of the multiple used in ThreeDeeObject's definition, then the lookup will use our get interceptor and basically lookup will branch into one of the prototypes in the prototypes array.

Just thought I'd jot the idea down here. I'm sure it has pros and cons compared to the mixin approach here.

Made a working ES5-based example (see pros/cons below):

// just an idea: multiple inheritance...
function multiple(...constructors) {
    let constructorName = ''
    let multiPrototype = {}

    for (let i=0, l=constructors.length; i<l; i+=1) {
        const constructor = constructors[i]

        constructorName += constructor.name + (i == l-1 ? '' : '+')
        // f.e. SomeClass_OtherClass_FooBar

        let props = SimplePropertyRetriever.getOwnAndPrototypeEnumerablesAndNonenumerables(constructor.prototype)
        for (let prop of props) {

            if (!multiPrototype[prop] && !Object.prototype[prop]) {

                // TODO: make this better, some props might be non writable,
                // only have get but not set, etc.
                // XXX Using Proxy will help, as we won't define new properties, we
                // will simply just forward lookup.
                Object.assign(multiPrototype, {
                    get [prop] () { return constructor.prototype[prop] },
                    set [prop] (value) { return constructor.prototype[prop] = value },
                })
            }

        }
    }

    // temporary object to store the new constructor, because
    // using an object allows us to programmatically assign a name to the
    // function, which we otherwise cannot do without eval().
    let tmp = {

        // This new constructor doesn't do much, just has all the given
        // constructor prototypes mixed in to it's own prototype. Be sure to
        // call each constructor manually in the class that extends this
        // new multi-class.
        [constructorName](...args) {
            Object.call(this, ...args)
        }

    }

    tmp[constructorName].prototype = multiPrototype
    tmp[constructorName].prototype.constructor = tmp[constructorName]

    // we add this helper method because ES6 class constructors aren't manually callable.
    // f.e., We can't do `Foo.call(this, ...args)` in the subclass that extends
    // this multi-class, so we use this helper instead:
    // `this.callSuperConstructor(Foo, ...args)`.
    tmp[constructorName].prototype.callSuperConstructor = function callSuperConstructor(nameOrRef, ...args) {
        let ctor = constructors.find(ctor => ctor === nameOrRef || ctor.name == nameOrRef)
        if (!ctor) return

        let obj = new ctor(...args)
        Object.assign(this, obj)
    }

    return tmp[constructorName]
}

// borrowed from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Enumerability_and_ownership_of_properties
var SimplePropertyRetriever = {
    getOwnAndPrototypeEnumerablesAndNonenumerables: function (obj) {
        return this._getPropertyNames(obj, true, true, this._enumerableAndNotEnumerable);
    },
    // Private static property checker callbacks
    _enumerableAndNotEnumerable : function (obj, prop) {
        return true;
    },
    // Inspired by http://stackoverflow.com/a/8024294/271577
    _getPropertyNames : function getAllPropertyNames(obj, iterateSelfBool, iteratePrototypeBool, includePropCb) {
        var props = [];

        do {
            if (iterateSelfBool) {
                Object.getOwnPropertyNames(obj).forEach(function (prop) {
                    if (props.indexOf(prop) === -1 && includePropCb(obj, prop)) {
                        props.push(prop);
                    }
                });
            }
            if (!iteratePrototypeBool) {
                break;
            }
            iterateSelfBool = true;
        } while (obj = Object.getPrototypeOf(obj));

        return props;
    }
}

Usage:

class One {
    constructor(arg) {
        console.log('One constructor')
        this.one = arg
    }
    foo() {console.log('foo')}
}

class Two {
    constructor(arg) {
        console.log('Two constructor')
        this.two = arg
    }
    bar() {console.log('bar')}
}

class Three extends Two {
    constructor(arg1, arg2) {
        console.log('Three constructor')
        super(arg1)
        this.three = arg2
    }
    baz() {console.log('baz')}
}

class FooBar extends multiple(Three, One) {
    constructor(...args) {
        super() // needed, although does nothing.

        // call each constructor. We can pas specific args to each constructor if we like.
        //
        // XXX The following is not allowed with ES6 classes, class constructors are not callable. :[ How to solve?
        // One.call(this, ...args)
        // Three.call(this, ...args)
        //
        // XXX Solved with the callSuperConstructor helper.
        this.callSuperConstructor(One, args[0])
        this.callSuperConstructor(Three, args[1], args[2])
    }
    oh() {console.log('oh')}
    yeah() {console.log('yeah')}
}

let f = new FooBar(1,2,3)

// this shows that the modifications to `this` by each constructor worked:
console.log(f.one, f.two, f.three) // logs "1 2 3"

// all methods work:
f.foo()
f.bar()
f.baz()
f.oh()
f.yeah()

Precendence: methods/props on the first class passed to multiple have highest precedence, and precedence decreases towards the last argument to multiple, so if in the call multiple(Foo, Bar) both Foo and Bar classes have the same method, then Foo takes precedence.

We could modify the iteration order inside multiple to give the last argument highest precedence.

Pros:

  • The arguments to each constructor can be specific.
  • Uses actual classes (they can be predefined to extend other things), see #13
  • Could use in tandem with mixins, mix().with() (though I'm not sure I see a use case for that yet)
  • What else?

Cons:

  • The inheritance chain isn't clear when inspecting the prototype chain in devtools.
  • Extra overhead from the getters/setters on the multiPrototype object.
  • Requires the callSuperConstructor helper to run super constructors' logic. We could place that logic into the subclass constructor, but it would look ugly there.
  • Methods that modify this of the classes passed to multiple don't modify the this of the subclass? There is some solution...
  • Super doesn't work in this implementation (but it might if we use Proxies).
  • What else?

I updated it, so it bind()s methods to the this of the prototype chain before the branching into the prototypes of the classes passed to multiple:

// just an idea: multiple inheritance...
function multiple(...constructors) {

    let constructorName = ''
    let multiClassPrototype = {}

    for (let i=0, l=constructors.length; i<l; i+=1) {
        const constructor = constructors[i]

        constructorName += constructor.name + (i == l-1 ? '' : '+')
        // f.e. SomeClass_OtherClass_FooBar
    }

    // temporary object to store the new constructor, because
    // using an object allows us to programmatically assign a name to the
    // function, which we otherwise cannot do without eval().
    let tmp = {

        // This constructor doesn't call super constructors, do that manually
        // with this.callSuperConstructor(Class, ...args).
        [constructorName](...args) {
            Object.call(this, ...args)

            if (multiClassPrototype._multiClassPropsInitialized) return

            for (let i=0, l=constructors.length; i<l; i+=1) {
                const constructor = constructors[i]

                let props = SimplePropertyRetriever.getOwnAndPrototypeEnumerablesAndNonenumerables(constructor.prototype)
                for (let prop of props) {

                    if (!multiClassPrototype[prop] && !Object.prototype[prop]) {
                                        // ^ because this constructor extends
                                        // from Object, so we have those props
                                        // in our proto chain already.

                        // TODO: make this better, some props might be non writable,
                        // only have get but not set, etc.
                        // XXX Using Proxy will help, as we won't define new properties, we
                        // will simply just forward lookup.
                        Object.defineProperty(multiClassPrototype, prop, {
                            get() {
                                let result = constructor.prototype[prop]
                                if (typeof result == 'function')
                                    return result.bind(this)
                                return result
                            },
                            set(value) { return constructor.prototype[prop] = value },

                            // TODO
                            // enumerable,
                            // configurable,
                        })
                    }

                }
            }

            multiClassPrototype._multiClassPropsInitialized = true
        }

    }

    tmp[constructorName].prototype = multiClassPrototype
    tmp[constructorName].prototype.constructor = tmp[constructorName]

    // we add this helper method because ES6 class constructors aren't manually callable.
    // f.e., We can't do `Foo.call(this, ...args)` in the subclass that extends
    // this multi-class, so we use this helper instead:
    // `this.callSuperConstructor(Foo, ...args)`.
    tmp[constructorName].prototype.callSuperConstructor = function callSuperConstructor(nameOrRef, ...args) {
        let ctor = constructors.find(ctor => ctor === nameOrRef || ctor.name == nameOrRef)
        if (!ctor) return

        let obj = new ctor(...args)
        Object.assign(this, obj)
    }

    return tmp[constructorName]
}

It works fine on basic examples, but it's falling apart on classes that have getters/setters; I'm not sure how to tackle that yet. I have a feeling it'd be much easier using Proxy, but that's not even in Safari yet (coming out in Safari 10), so it's not an option (yet), and the polyfills are limited in functionality and performance.

I really like the fact that I can leave all my classes as plain classes without parameterizing them (i.e. not using factory functions to produce the classes), and that each class can have it's own prototype chain, so the prototype chain of the subclass branches when it reaches the "multi-class", which is interesting. I suppose we can use @@hasInstance to implement the instanceof to check against the prototype branches, so it returns true when the instance being checked matches with any of the prototypes supplied to multiple...

I was originally calling it MultiClass, using it as

class Foo extends new MultiClass(Bar, Baz) { ... }

To visualize the prototype branching of my example above, it's like this in concept:

              FooBar
                |
                |
           +----+-----+
           |          |
         Three       One
           |
          Two

But it's actually like this in implementation, due to the prototype created for the "multiclass":

              FooBar
                |
                |
            MultiClass
                |
                |
           +----+-----+
           |          |
         Three       One
           |
          Two

Where the MultiClass sets up the branching logic (ideally would be with a Proxy), and where the name of the "MultiClass" is actually the combination of the names of the classes passed in, so in our example it would be Three_One:

              FooBar
                |
                |
             Three_One
                |
                |
           +----+-----+
           |          |
         Three       One
           |
          Two

I chose to name it like that (f.e. Three_One) so that it would be ahint in the devtools as to what the FooBar class extends from, hinting at a multi-class composed of Three and One.

The current implementation is very premature, but the idea might be nice (lookup forwarding with Proxy instead of fiddling with descriptors, and a custom @@hasInstance implementation so that instanceof checks against all the prototypes in the fork point. It also needs some way to ensure that all methods' this is the this of the subclass instance (f.e. new FooBar in the example), which I think will also be easier using Proxy.

Alright, updated! It now works with getters/setters, and properly binds functions to this of the ultimate subclass. The only thing is that checking instanceof on any of the classes doesn't work (yet), so we cannot do

class Foo extends multiple(Bar, Baz) {...}
let f = new Foo
console.log(f instanceof Bar) // logs false, but I want it to log true
console.log(f instanceof Baz) // logs false, but I want it to log true

Here's the new implementation:

// Just an idea: multiple inheritance...
// IMPORTANT NOTE: This assumes that the prototype of the classes are not
// modified after definition, otherwise the multi-inheritance won't work (not
// with this implementation at least, but could be possible to implement).
function multiple(...constructors) {

    let constructorName = ''
    let multiClassPrototype = {}
    let multiClassPropCache = {}

    let protoPropsFlattenedForEachConstructor = [] // in same order as `constructors`
    let allProps = []

    for (let i=0, l=constructors.length; i<l; i+=1) {
        const constructor = constructors[i]

        constructorName += constructor.name + (i == l-1 ? '' : '+')
        // f.e. SomeClass_OtherClass_FooBar

        let props = SimplePropertyRetriever.getOwnAndPrototypeEnumerablesAndNonenumerables(constructor.prototype)
        protoPropsFlattenedForEachConstructor.push(props)

        for (let prop of props) {
            if (!allProps.includes(prop))
                allProps.push(prop)
        }
    }

    // temporary object to store the new constructor, because
    // using an object allows us to programmatically assign a name to the
    // function, which we otherwise cannot do without eval().
    let tmp = {

        // This constructor doesn't call super constructors, do that manually
        // with this.callSuperConstructor(Class, ...args).
        [constructorName](...args) {
            Object.call(this, ...args)

            // TODO store _multiClassPropsInitialized in the scope of the
            // `multiple` call instead of in multiClassPropCache.
            if (multiClassPropCache._multiClassPropsInitialized) return

            for (let i=0, l=allProps.length; i<l; i+=1) {
                const prop = allProps[i]

                for (let i=0, l=constructors.length; i<l; i+=1) {
                    const ctorProps = protoPropsFlattenedForEachConstructor[i]
                    const ctor = constructors[i]

                    if (ctorProps.includes(prop)) {

                        // check if the prop is a getter or setter. If so, we
                        // copy the getter to the multiClassPrototype. Basically
                        // we're just mixing the getters/setters onto the
                        // multiClassPrototype, which is not ideal, but seems to
                        // be the only option for now (maybe we can change this
                        // when we update to use Proxy).
                        let owner = ctor.prototype
                        while (!owner.hasOwnProperty(prop)) {
                            owner = Object.getPrototypeOf(owner)
                        }
                        let descriptor = Object.getOwnPropertyDescriptor(owner, prop)
                        if (typeof descriptor.set != 'undefined' || typeof descriptor.get != 'undefined') {
                            Object.defineProperty(multiClassPrototype, prop, descriptor)
                        }

                        // Otherwise, we make a new getter/setter.
                        else {
                            Object.defineProperty(multiClassPrototype, prop, {
                                get() {
                                    let value = null

                                    if (multiClassPropCache.hasOwnProperty(prop)) {
                                        value = multiClassPropCache[prop]
                                    }
                                    else {
                                        value = ctor.prototype[prop]
                                    }

                                    if (typeof value == 'function') {
                                        return value.bind(this)
                                    }
                                    return value
                                },
                                set(value) { multiClassPropCache[prop] = value },
                            })
                        }

                        // break because we found the constructor with the
                        // property we're looking for (it has "highest
                        // precedence"), so we don't need to continue looking.
                        break
                    }
                }

            }

            multiClassPropCache._multiClassPropsInitialized = true
        }

    }

    tmp[constructorName].prototype = multiClassPrototype
    tmp[constructorName].prototype.constructor = tmp[constructorName]

    // we add this helper method because ES6 class constructors aren't manually callable.
    // f.e., We can't do `Foo.call(this, ...args)` in the subclass that extends
    // this multi-class, so we use this helper instead:
    // `this.callSuperConstructor(Foo, ...args)`.
    tmp[constructorName].prototype.callSuperConstructor = function callSuperConstructor(nameOrRef, ...args) {
        let ctor = constructors.find(ctor => ctor === nameOrRef || ctor.name == nameOrRef)
        if (!ctor) return

        let obj = new ctor(...args)
        Object.assign(this, obj)
    }

    return tmp[constructorName]
}

I've updated my implementation, and added a custom isInstanceOf helper function to use instead of instanceof directly, so it detects classes that have a MultiClass and does something different in that case (checking each prototype branch).

But, I really don't like the current implementation because I have not yet found a way to avoid re-defining the getters/setters on the MultiClass prototype (the getters/setters from the classes passed into the multiple() call).

Even if I use Proxy to intercept the property lookups (instead of defining my own getters/setters as in the above impl), I think this still might not be solvable because I don't see how to make the getters/setters of the superclasses to be applied onto the this of the ultimate subclass without just copying the descriptors.

@justinfagnani What's your opinion on copying the descriptors? Do you think that's fine? It does limit cases where a prototype gets modified after creation, in which case the copied descriptors would be out of sync.

(I've decided to abandon this approach, in favor of #13 (comment), although it was good food for thought on what I now want to do)

Copying descriptors won't work with super and doesn't allow for constructors. The whole point of subclass factories is to enable these features. Thanks for the exploration!

so, how can i do multiple inheritance?

We can make super work if we do the prototype branching with a Proxy. I will attempt this soon!