jashkenas / underscore

JavaScript's utility _ belt
https://underscorejs.org
MIT License
27.29k stars 5.53k forks source link

Utility functions for prototype-based programming #2947

Open jgonggrijp opened 2 years ago

jgonggrijp commented 2 years ago

While class emulation is widespread nowadays, prototypes are JavaScript's true vehicle of inheritance. The following snippets of code, which contain equivalent pairs, demonstrate that prototype-based programming is more fundamental and explicit:

// define constructor and prototype

// class emulation version
class BaseConstructor {}
const basePrototype = BaseConstructor.prototype;

// prototype version
const basePrototype = {
    constructor() {}
};
const BaseConstructor = basePrototype.constructor;
BaseConstructor.prototype = basePrototype;
// inheritance

// class emulation version
class ChildConstructor extends BaseConstructor {}
const childPrototype = ChildConstructor.prototype;

// prototype version
const childPrototype = Object.create(basePrototype, {
    constructor() { return basePrototype.constructor.apply(this, arguments); }
});
const ChildConstructor = childPrototype.constructor;
ChildConstructor.prototype = childPrototype;
// instantiation

// class emulation version
const baseInstance = new BaseConstructor();

// prototype version
let baseInstance = Object.create(basePrototype);
baseInstance = baseInstance.constructor() || baseInstance;

Since prototypes are so fundamental, I believe there is space in Underscore for utility functions that make prototype-based programming more convenient. In draft, I propose the following. A real implementation would need more sophistication for ES3 compatibility, performance and possibly corner cases.

// get the prototype of any object
function prototype(obj) {
    return Object.getPrototypeOf(obj);
}

// mixin for prototypes that lets you replace
//     var instance = new BaseConstructor()
// by
//     var instance = create(basePrototype).init()
// Of course, prototypes can also skip the constructor and directly define their
// own .init method instead.
var initMixin = {
    init() {
        var ctor = this.constructor;
        return ctor && ctor.apply(this, arguments) || this;
    }
};

// mixin for prototypes so you can replace
//     var instance = create(prototype).init()
// by
//     var instance = prototype.construct()
// Of course, prototypes can also directly define their own .construct method
// instead.
var constructMixin = extend({
    construct() {
        return this.init.apply(create(this), arguments);
    }
}, initMixin);

// standalone version of the construct method, construct(prototype, ...args)
var construct = restArguments(function(prototype, args) {
    return (prototype.construct || constructMixin.construct).apply(prototype, args);
});

// inheriting constructor creation for class emulation interop
function wrapConstructor(base, derived) {
    return extend(
        derived && has(derived, 'constructor') && derived.constructor ||
        base && base.contructor && function() {
            return base.constructor.apply(this, arguments);
        } || function() {},
        // The following line copies "static properties" from the base
        // constructor. This is useless in prototype-based programming, but
        // might be important for class-emulated code.
        base && (base.constructor || null),
        { prototype: derived }
    );
}

// mixin for prototypes with a constructor so you can replace
//     class ChildConstructor extends BaseConstructor {}
// by
//     var childPrototype = basePrototype.extend({})
// Of course, prototypes can also directly define their own .extend method.
var extendMixin = {
    extend() {
        var derived = create.apply(this, arguments);
        // note: using the pre-existing standalone _.extend below
        return extend(derived, {constructor: wrapConstructor(this, derived)});
    }
};

// standalone version of the extendMixin.extend method, named differently in
// order to avoid clashing with the pre-existing _.extend. inherit also seems a
// more appropriate name for this function when used standalone.
var inherit = restArguments(function(base, props) {
    return (base.extend || extendMixin.extend).apply(base, props);
});

// collection of mixins for quick and easy interoperability with
// constructor-based code
var prototypeMixins = {
    init: initMixin,
    construct: constructMixin,
    extend: extendMixin,
    all: extend({}, constructMixin, extendMixin)
};

Note how construct and .extend/inherit are both based on create. This is no coincidence; in prototype-based programming, there is no fundamental distinction between prototypes and instances. Every object can have a prototype and be a prototype at the same time. From this point of view, .extend/inherit is just a special variant of construct that enables interoperability with class-emulated code. Without any class-emulated legacy, prototype, create and .init/construct would already cover all needs.

With the above utilities in place, we can revisit our examples from the beginning and find that the prototype-centric code is just as concise as the class-centric code:

// define constructor/prototype

// class-centric
class BaseConstructor {}

// prototype-centric
const basePrototype = {};
// inheritance

// class-centric
class ChildConstructor extends BaseConstructor {}

// prototype-centric
const childPrototype = inherit(basePrototype, {});
// instantiation

// class-centric
const baseInstance = new BaseConstructor();

// prototype-centric
const baseInstance = construct(basePrototype);

Related: https://github.com/jashkenas/backbone/issues/4245.

jashkenas commented 2 years ago

I fairly strongly feel like Underscore shouldn't try to introduce a new system for doing OOP-in-JS, elaborated on a bit here: https://github.com/jashkenas/backbone/issues/4245#issuecomment-999259553