Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Embrace prototypes #4245

Open
jgonggrijp opened this issue Dec 21, 2021 · 12 comments
Open

Embrace prototypes #4245

jgonggrijp opened this issue Dec 21, 2021 · 12 comments
Labels

Comments

@jgonggrijp
Copy link
Collaborator

jgonggrijp commented Dec 21, 2021

The ES6 class emulation convention doesn't sit well with Backbone, mostly because it provides no convenient way to set non-function prototype properties. In fact, in my opinion, the ES6 class emulation convention doesn't sit well in general for this same reason. On top of that, classes don't sit well with JavaScript, anyway. I'm not the first to say this; consider Walker 2014 and Crockford 2008. I consider #4079 a symptom of classes not sitting well.

Therefore, in Backbone version 2, rather than adapting the library to ES6 classes, I would like to do away with classes entirely and embrace prototypes instead. That would mean that instead of the following in Backbone 1,

import { extend } from 'underscore';
import { Model } from 'backbone';

const CustomModel = Model.extend({
    idAttribute: '_id',
    // ...
});

// or

class CustomModel extends Model { /*...*/ }
extend(CustomModel.prototype, {
    idAttribute: '_id',
};

// in either case:
const aCustomModelInstance = new CustomModel(attributes, options);

we would be writing something like the following in Backbone 2:

import { model } from 'backbone';

const customModel = model.extend({
    idAttribute: '_id',
    // ...
});

const aCustomModelInstance = customModel.construct(attributes, options);

where model is an object that serves as a prototype, instead of a function that emulates a class.

model.extend(protoprops) (and collection.extend, etcetera) would default to just being a shorthand for Object.create(model, protoprops). (I would likely use _.create instead of Object.create, but that is an implementation detail.) This method can still be overridden by plugins in order to enable things like shorthand syntax at prototype extension time.

model.construct(attributes, options) would first do Object.create(model) and then perform the same logic on the created instance as the current constructor. In fact, we could retain the old constructor and simply implement model.construct as Object.create(model).constructor(attributes, options). This would enable people who really want to use class emulation to set Model = model.constructor and continue working in the old way.

In summary, the code would not necessarily change that much. It's just that the library exports prototypes instead of constructors, extend moves from the constructor to the prototype and there is a new construct method that replaces the new keyword. As a result, everyone using Backbone can seamlessly and interchangeably write their code in the same way, regardless of what particular flavor of class emulation they are using.

Feedback welcome. I'm not starting on Backbone 2 anytime soon, so there is plenty of time.

@jashkenas
Copy link
Owner

It's a neat idea, but I strongly feel like modern JavaScript should be standardizing on the class idiom that has been introduced in recent versions, instead of trying to pave other pathways at this point. You really don't want to have to explain to every user of the library how your idiosyncratic approach to OOP-in-JS works. So, preferably:

  class User extends Backbone.Model {

Although I agree that ES6 classes could have been designed better than they were, I don't agree that prototypes are more fundamental — I think that prototypes have always been a (minor) mistake. The basic argument is this: When you're working with Objects, you have some objects that tend to serve as blueprints, and other objects that are formed in the shape of those blueprints to hold real data and do real work. It almost never makes sense to start with a real object-containing-data and then use that to blueprint new objects — you pretty much always start with a blank state.

So we have:

  • Prototype -> Object (instance)
  • Class -> Object (instance)

I think that recognizing the fact that these aren't the same types of objects — that one is always a blueprint, and never holds data directly, and the other is an instantiation of that blueprint — is a good thing. Ultimately, classes and prototypes become the same pattern, but classes are clearer about the fact that you have two different roles these objects are serving, and are clearer about which role belongs to which.

JavaScript screwed this up with a funky scheme where a prototype was tacked on as a property on a constructor, so that you actually had three objects involved in this dance, but the basic pattern of use has always been essentially classical. e.g. var myInstance = new MyClass(); And I think that's what you'll find if you look at any language that affects a prototype-based OOP.

So let's not muddle matters further, just use class.

@jgonggrijp
Copy link
Collaborator Author

jgonggrijp commented Dec 22, 2021

I agree that there is a distinction to be made in any case, between the blueprint and the instance. For the record, this is why I was proposing separate extend and construct methods; if I believed that it should be possible to use instances with data (or even bound events!) as prototypes again, I would probably go with only Object.create and initialize. While I didn't mention this, I also think that construct should set the extend method of the created instance to undefined, in order to help users avoid mistakes.

And I think [class emulation is] what you'll find if you look at any language that affects a prototype-based OOP.

No, JavaScript is really the odd one out in this regard. There are relatively few prototype-based languages to begin with, with probably Lua being the currently most popular after JavaScript and Self being a historical name that might ring a bell with some people. JavaScript is the only one that pretends to have classes, since recently. wiki-en:Lua§Object-oriented programming has a code example that follows a pattern very similar to what I was proposing in the opening post. wiki-en:Prototype-based programming§Criticism articulates your main points of recognizability and safety, and also mentions JavaScript as a special case that added class-like syntactic sugar.

Regarding recognizability, I must point out that

var user = model.extend({
    idAttribute: '_id',
    // ...
});

looks extremely similar to

var User = Model.extend({
    idAttribute: '_id',
    // ...
});

which has been in the Backbone documentation for the past 11 years. There is still a large crowd of faithful Backbone fans, who seem to take no offense from this pattern. Contrast this with

class User extends Model {
    // only methods here
}

_.extend(User.prototype, {
    idAttribute: '_id',
    // other properties here, which you'd generally prefer to place above the methods
});

which is what you end up with if you try to use current Backbone with ES6 classes. This _.extend(ClassName.prototype, {...}) footer pattern is arguably even less recognizable than my proposed prototype.extend({...}) pattern. I've been working on a large Backbone project with TypeScript classes, where I had to apply this pattern everywhere, and I frankly hate it.

Standardization, sure. I'm not married to the prototype proposal, but I believe we need a solution for the conflict between ES6 classes and current Backbone. I can think of a few options:

  • Resign to the footer _.extend pattern. I hope you agree this is neither acceptable nor recognizable.
  • Encourage Backbone users to program in CoffeeScript, which has saner classes. I like CoffeeScript, but it's arguably rowing against the stream, with its class emulation being non-standard and most JS programmers not finding it recognizable, either.
  • Redesign Backbone to the point where prototype properties no longer play a prominent role. To me, this seems a huge sacrifice, as well as a needlessly large breaking change and a lot of work, just because ES6 decided to give us terrible syntactic sugar.
  • Edit to add: decorators would be an attractive option, but this is currently not a viable option and it is uncertain whether it ever will. See next comment for details.
  • My prototype proposal, which is admittedly not very standard, but overall seems the least invasive and least obtrusive option in the list. As I stated, people can even still do Model = model.constructor and then class User extends Model if they really want to. Safety can be addressed by adding safeguards to the construct method.

If you can think of another option, please share it.

@jgonggrijp
Copy link
Collaborator Author

jgonggrijp commented Dec 22, 2021

In my previous comment, I forgot to mention decorators. This is an interesting idea, which could theoretically allow syntax like the following:

@props({
    idAttribute: '_id',
    // more prototype properties
})
class User extends Model {
    // methods
}

@props({
    tagName: 'button',
})
class SubmitButton extends View {
    @on('click')
    submit() { /*...*/ }
}

This is much better than the _.extend footer. Unfortunately, decorators also have a bunch of problems:

  • The proposal is still a moving target, which means that it is currently impossible to write a sustainable implementation.
  • Different tools currently implement decorators in different ways, so even a portable implementation is not possible.
  • The proposal has been around for a very long time and it is uncertain when, if ever, it will end up being actually standardized.
  • I have seen versions of the proposal that weren't powerful enough to enable syntax like the above. The current version is powerful enough, but only just. The @on method decorator can only work if you also use the @props class decorator, and only because the currently proposed algorithm applies the method decorators before calling the class decorator and the class decorator can access the metadata that was accumulated by the method decorators. A very slight change to the proposed algorithm can break this again. Concluding, there is no hard guarantee that decorators will actually offer a solution if they eventually end up being standardized.

I was aware of decorators when I started the large Backbone+TypeScript project that I mentioned in the previous comment, but ended up not using them because of the above reasons.

Relevant: #3560, https://benmccormick.org/2015/07/06/backbone-and-es6-classes-revisited.

@jashkenas
Copy link
Owner

If you can think of another option, please share it.

I'm just freestyling here, so forgive any holes or omissions, but — I think a promising direction for a 2.0 would be to adapt Backbone’s current structure to the limitations of class. Which now includes support for "public instance fields" as well.

class User extends Backbone.Model {
  idAttribute = '_id'
  signup() { ... }
}

class SubmitButton extends Backbone.View {
  tagName = 'button'
  submit() { ... }
}

@jgonggrijp
Copy link
Collaborator Author

Unfortunately - and this is my real gripe with ES6 classes - that syntax desugars to this:

function User(attributes, options) {
    Backbone.Model.call(this, attributes, options);
    this.idAttribute = '_id';
}

User.prototype.signup = function() { ... };

function SubmitButton(options) {
    Backbone.View.call(this, options);
    this.tagName = 'button';
}

SubmitButton.prototype.submit = function() { ... };

Which means that idAttribute and tagName are only overridden after the parent constructor has already run. So this syntax means something very different from the similar-looking Backbone.Model.extend({...}) notation.

@jgonggrijp
Copy link
Collaborator Author

For completeness I'll mention a few more options:

  • Instead of plain-value prototype properties, we could resort to defining a method or a getter that always returns the same value. This possibility was also named in Backbone and ES6 Classes #3560 and Ben McCormick's article. I find this very ugly, but I'll acknowledge that it is a way to keep the entire prototype definition together in a single notation.
  • As a variation of the _.extend footer pattern, we could first define a separate userMeta object with the plain-value properties that we want to be on the prototype, then the class notation with the methods, and finally a line _.extend(User.prototype, userMeta);. This moves the plain-value prototype properties to a better place (i.e., before the methods), in exchange for fragmenting the prototype definition even further.
const userMeta = {
    idAttribute: '_id',
};

class User extends Backbone.Model {
    signup() { ... }
}

_.extend(User.prototype, userMeta);

Side note: after writing my earlier comment about decorator notation, I realized that the old .extend method, as well as its hypothetical prototype-centric equivalent, already allows for a similar notation as the @on event binding, without requiring any new syntax:

const SubmitButton = Backbone.View.extend({
    tagName: 'button',
    submit: on('click', function() { ... }),
});

It just takes some administration, where the on helper function sets a temporary property on the function, which .extend then reads and removes again. This is similar to how decorators would work according to the current proposal.

@blikblum
Copy link

FYI in my fork, idAttribute and cidPrefix are read from a static class field, i.e., a constructor property

https://github.com/blikblum/nextbone/blob/master/nextbone.js#L549

@jgonggrijp
Copy link
Collaborator Author

@blikblum To me, that feels very much like a workaround, but I'll admit it is a clever one.

@blikblum
Copy link

defaults and Collection model can also be defined as static fields as well as methods

@blikblum To me, that feels very much like a workaround, but I'll admit it is a clever one.

IMO this is the closest to get using ES classes. The DX is pretty good. See example below:

import { Model, Collection } from 'nextbone'

class Task extends Model {
  static defaults  = {
    title: '',
    done: false
  }
}

class Tasks extends Collection {
  static model = Task
}

const tasks = new Tasks()
tasks.fetch()

It can be written as:

import { Model, Collection } from 'nextbone'

class Task extends Model {
  defaults() {
     return {
       title: '',
       done: false
     }
  }
}

const tasks = new Collection({model: Task})
tasks.fetch()

@jashkenas
Copy link
Owner

@blikblum’s static API looks like a reasonable (even if not completely ideal) way to work around the ordering limitations of ES6 classes to me.

@jgonggrijp
Copy link
Collaborator Author

@blikblum's workaround pivots on this helper function, which replaces the current role of _.result:

https://github.com/blikblum/nextbone/blob/51638eafb8f2b83f087105b11edc89baeb9a5fa3/nextbone.js#L93-L97

While it looks reasonable at first sight, it effectively means that anything that needs to be accessible through getClassProp must never be set on the prototype, even if it is a method. Consider the following example:

import { Model, Collection } from 'nextbone'

class Task extends Model {
    defaults() {
        return {
            title: '',
            done: false
        }
    }
}

class PrioritizedTask extends Task {
    static defaults = {
        title: '',
        priority: 1,
        done: false
    };
}

(new PrioritizedTask()).get('priority'); // undefined

getClassProp gives prototype properties precedence over constructor properties, so Task.prototype.defaults wins over PrioritizedTask.defaults. This could be "fixed" by giving constructor properties priority over prototype properties instead, but this would result in the opposite problem, i.e., a prototype method on the subclass not being able to override a constructor property on the superclass. The only reliable way to have any methods in the inheritance chain at all, is to make those static, too (which requires a change in the definition of getClassProp as well). So we end up with this:

class Task extends Model {
    static defaults() {
        return {
            title: '',
            done: false
        }
    }
}

class PrioritizedTask extends Task {
    static defaults = {
        title: '',
        priority: 1,
        done: false
    };
}

(new PrioritizedTask()).get('priority'); // 1

There are many problems with this:

  • It would amount to implementing our own alternative means of inheritance, instead of the one that is built into JavaScript.
  • It would break all existing Backbone code, just to address a notational inconvenience.
  • It would be inconsistent with other methods that should stay on the prototype for notational convenience, such as get and set.
  • It would open up a new opportunity for shooting yourself in the foot (i.e., by forgetting the static keyword).
  • Static methods that access this behave in a surprising way as they end up being invoked as constructor[method].call(instance) inside getClassProp. Besides being surprising to human readers, type checkers, linters and other such tools will trip over this as well.

I don't want to go there.

So far, we have collected many non-ideal solutions. I think that the following requirements summarize a solution that we could all agree to be ideal:

  1. Convenience: it must be possible to keep the entire blueprint in a single notational entity.
  2. Efficiency: there should be no need to define a method or a getter when it can be a fixed value.
  3. Sustainability: the notation should work both today and in the future, without having to change the underlying implementation.
  4. Semantic consistency: inheritance must continue to work in the standard way.
  5. Syntactic consistency: it should be possible to use an ES class as the blueprint notation.

Going over all the solutions that have passed again, it is easy to identify why none of them was entirely satisfactory:

  • ES classes "as is" cannot meet the convenience and efficiency requirements at the same time. Methods and getters are inefficient when the value is fixed, assigning prototype properties outside the class body is inconvenient.
  • Instance fields do not meet the semantic consistency requirement.
  • The prototype-centric approach that I proposed cannot offer convenience and syntactic consistency at the same time.
  • CoffeeScript does not meet the syntactic consistency requirement.
  • Redesigning Backbone to no longer use prototype properties, such as in the static approach, does not meet the semantic consistency requirement.
  • Decorators do not meet the sustainability requirement (yet).

Rather than sacrificing one of the requirements, I suggest aiming high and just letting this sit for the time being. I think we can afford, and hope it will pay off, to wait until somebody comes up with an ideal solution (or decorators become viable).

In the meanwhile, I would still like to experiment with the prototype-centric approach. I'll implement it in a plugin instead of in Backbone itself.

@jashkenas
Copy link
Owner

Rather than sacrificing one of the requirements, I suggest aiming high and just letting this sit for the time being. I think we can afford, and hope it will pay off, to wait until somebody comes up with an ideal solution (or decorators become viable).

Sounds good! Thanks for digging in deeper...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

3 participants