stampit-org / stampit

OOP is better with stamps: Composable object factories.
https://stampit.js.org
MIT License
3.02k stars 102 forks source link

Composables: Towards an open standard for Stamps #151

Closed ericelliott closed 9 years ago

ericelliott commented 9 years ago

UPD: moved to the new repo.

Stamp Composable Standards

Definitions

stamp() should be able to take multiple types of objects and simply extend them, e.g., a function or an array. However, stamps with function or array base objects should not extend the built-in prototypes for Object or Array.

compose(stamps...) => stamp

Compose takes any number of stamps as arguments and returns a new stamp composed from the passed in stamps. If no stamps are passed in, it returns an empty stamp.

The Stamp Descriptor

The names and definitions of the fixed properties that form the stamp descriptor. The stamp descriptor properties are made available on each stamp as stamp.compose.*

@koresar has proposed yet another great solution for Stampit: a standard composables specification inspired by the Promises specification:

_.isFunction(someObject.create); // true
_.isArray(someObject.create.inits); //true - optional (we should rename the "init()" somehow.
_.isPlainObject(someObject.create.refs); //true - optional 
_.isPlainObject(someObject.create.props); //true - optional 
_.isPlainObject(someObject.create.methods); //true - optional 

This is a great start. We should probably attempt to support all of the object features afforded by ES6/ES7, including the ability to configure properties and use symbols:

_.isPlainObject(stamp.create.symbols); // symbols

Maybe props and refs should be converted to property descriptors? So this:

stampit({
  props: { foo: 'bar' }
});

Gets translated to this:

stamp.create.properties.foo = {
  descriptor: { // see note below
    value: 'bar',
    configurable: true,
    enumerable: true,
    writable: true
  }
};

Implementations should cache when possible

Most of the time, properties will use the default assignment settings. Implementations (like stampit) should cache them so that we can copy quickly as we do today.

Presets

We could create presets. The above is the default setting for properties created by assignment, e.g., using Object.assign(), so this could be a preset that means the same thing:

stamp.create.properties.foo = {
  preset: 'assignment',
  value: 'bar' // value would be the only descriptor setting available at the object root
};

Custom presets

Another cool feature is that users could define and register their own presets:

stamp.create.presets.cloaked = {
    enumerable: false,
    configurable: true,
    writable: true    
};

Which you'd select like this:

stamp.create.properties.foo = {
  preset: 'cloaked',
  value: 'bar'
};

Now you have a non-enumerable instance.foo.

Formalizing names

The fixed locations don't need shortcut names (user's won't be typing them all the time), so let's spell them all out:

_.isFunction(someObject.create); // true
_.isArray(someObject.create.initializers); //true - optional 
_.isPlainObject(someObject.create.references); //true - optional 
_.isPlainObject(someObject.create.properties); //true - optional 
_.isPlainObject(someObject.create.methods); //true - optional 
koresar commented 9 years ago

Thinking of Open Standard.

Functions are the first class citizens in JavaScript. I was worried all the time that stamps cannot produce functions as object instances.

There is always a workaround via .init(() => { return function () {}; }) But as Eric says

it's a work-around, not a solution. Now we have a user education problem


I believe that the .create() function should accept not refs object, but the object itself.

MyStamp(); // creates new object

const plainObject = { key: value };
MyStamp(plainObject); // returns the plainObject object

const myFunc = function whatver() {};
MyStamp(myFunc); // returns the myFunc object

Thoughts?


More thinking about Open Standard.

In other words, stamps should flawlessly process any object. No exception, no warning, nothing. The type of the processed object should be up to the user. (Just like promises do.)

In the following example I'm using sound processing terminology:

const Cutoff = compose(
  refs({ min: 5, max: 250 }), 
  init(function({ instance }) { 
    return instance < this.min ? this.min : 
      intance > this.max ? this.max :
      instance; })
);
const Aplify = compose(
  refs({ factor: 2.5 }), 
  init(function({ instance }) { return this.factor * instance; })
);

const Amplificator = compose(Aplify, Cutoff);
Amplificator(200); // returns 250 because 200*2.5 is more than 250 cutoff upper limit
Amplificator(20); // returns 50 beacuse 20*2.5 is within the cutoff range 5-250
Amplificator(0); // returns 5 because 0 is less than 5 cutoff lower limit
ericelliott commented 9 years ago

Agreed. .create() should take an object to be augmented. Any type of extensible object should work.

koresar commented 9 years ago

What about the following idea.

Take a look at this stamp:

const Connection = compose(
  refs({ url: { host: 'localhost', port: 80 } }), 
  init(function({ stamp, instance }) { this.clone = () => stamp(instance); }),
  methods({ connect() {...} })
);

I see that refs, init, and methods are repetitive.

Why won't we simply ducktype those? The order and number of the arguments should not matter. The following should produce the same stamp as above.

const Connection = compose(
  function({ stamp, instance }) { this.clone = () => stamp(instance); },
  { url: { host: 'localhost', port: 80 } }, 
  { connect() {...} }
);

Such a nice shortened syntax gives the idea that the compose is not the best name for the function. I believe stamp would serve better.

Thoughts?

koresar commented 9 years ago

Another proposal which I strongly believe is for the best.

Rename methods to proto.

someObject.create.proto

Why?

koresar commented 9 years ago

Take this code

const Connection = compose(
  function({ stamp, instance }) { this.clone = () => stamp(instance); },
  { url: { host: 'localhost', port: 80 } }, 
  { connect() {...} }
);

It looks very much alike class declaration: constructor, property url, method connect. But what if we had only single function stamp which does it all - creates new stamps, composes existing?

const Connection = stamp(
  function({ stamp, instance }) { this.clone = () => stamp(instance); },
  { url: { host: 'localhost', port: 80 } },
  { connect() {...} },
  SecondStamp
);

And then the stamps can be composed:

var ComposedConnection = stamp(Connection, ThirdStamp);

I like this syntax very much. :)

P.S. Ducktyping rocks. :)

koresar commented 9 years ago
Statics and deep properties.

Proposal syntax.

import {stamp, deepProps, statics} from 'stamp';

const Connection = stamp(
  function({ stamp, instance }) { this.clone = () => stamp(instance); },
  { url: { host: 'localhost', port: 80 } },
  { connect() {...} },
  statics({ printUrl: function () { console.log(this.url); } }),
  deepProps({ url: { protocol: 'https' } }),
);

Alternatively, statics can be added directly to a stamp. It should be identical to the statics above:

Connection.printUrl = function () { console.log(this.url); };

const ComposedConnection = stamp(Conneciton, SecondStamp);
ComposedConnection.printUrl(); // <-- should not throw
koresar commented 9 years ago

@ericelliott descriptors should be implemented using decorators I believe.

koresar commented 9 years ago

https://medium.com/google-developers/exploring-es7-decorators-76ecb65fb841

troutowicz commented 9 years ago

Can someone put up an example that showcases the creatables in action?

Rename methods to proto.

This was brought up in another issue. I voted no. Putting anything other than functions on the prototype is a code smell. We should not encourage it.

Ducktyping rocks. :)

I like ducktyping, but your proposal doesn't sit well with me. The structure is too loosey goosey. :/

ericelliott commented 9 years ago

@koresar:

import {stamp, deepProps, statics} from 'stamp';

const Connection = stamp(
  function({ stamp, instance }) { this.clone = () => stamp(instance); },
  { url: { host: 'localhost', port: 80 } },
  { connect() {...} },
  statics({ printUrl: function () { console.log(this.url); } }),
  deepProps({ url: { protocol: 'https' } }),
);

It's a clever idea, but code should be optimized for reading, not writing. This is definitely less readable than:

import {stamp, deepProps, statics} from 'stamp';

const Connection = stamp(
  init(({ stamp, instance }) => { this.clone = () => stamp(instance); }),
  refs({ url: { host: 'localhost', port: 80 } }),
  methods({ connect() {...} }),
  statics({ printUrl: function () { console.log(this.url); } }),
  deepProps({ url: { protocol: 'https' } }),
);

I'm cool with the name statics and deepProps, and while we're on the subject of readability, with deepProps providing a more descriptive name, I think we should rename refs back to state.

Also, re: letting people hold state on the delegate prototype, is there a significant difference between doing that, and holding state in the statics?

IMO, if you need shared state (which you should avoid), a static seems like a much better place to put it... doesn't it?

ericelliott commented 9 years ago

Can someone put up an example that showcases the creatables in action?

Stampit is an example of createables in action. We're just talking about publishing a spec so that other libraries can implement standard createables, too.

koresar commented 9 years ago

Eric, I agree to all your suggestions. Especially

code should be optimized for reading, not writing

But this one I tend to disagree with because it ruins readability you've just mentioned:

I think we should rename refs back to state.

"refs" is explicitly saying that "I'm going to shallow copy your data". "state" doesn't say if it gets shallow copied or deep copied.

koresar commented 9 years ago

Take promises for example. They just work. The only global variable which got introduced is Promise solely to create new promise chains.

You don't need to import Promise from 'Promise' if all you need is to continue execution:

db.getById(123).then( bla bla bla ); // <-- See? No need to import stuff

We also don't want people to import stamp each the time they need to extend a stamp with one more method.

Moreover, "createable" should sound like "I can create", but actually sound like "I can be created". Confusing a little. Right?

So, maybe we should rename "creatable" to "stampable", i.e. an object which can be stamped?

Benefits: 1) Any stamp can be extended/composed by simple MyStamp.stamp({ init: function() {} }) or alike. 2) No need to extra import stamp. 3) I feel that functionality is duplicated by MyStamp() and MyStamp.create() calls. We don't need both. One is enough. 4) People familiar with promises will understand the idea of stamps easier. 5) The create word is used in programming more often than stamp. There'll be less collisions.

IMO this would be an ideal world if such thing would exist.

Thoughts?

koresar commented 9 years ago

Another good name for "stampable" could be "composable". This one is even better!

Analogies:

ericelliott commented 9 years ago

Stamp Standards in Two Parts

The stamp contract specifications

Default exports

create(baseObject, stamps...)

create() should be able to take multiple types of objects and simply extend them, e.g., a function or an array. However, stamps with function or array base objects should not extend the built-in prototypes for Object or Array.

compose(baseObject, stamps...)

Compose always takes any number of stamps as arguments and returns a new stamp composed from the passed in stamps. If no stamps are passed in, it returns an empty stamp.

Core stamp creation utilities

Extended utilities - Useful utilities not part of the standard specifications.

ericelliott commented 9 years ago

@koresar addressing some of your thoughts:

It's very important that we don't conflate the creation of stamps with the creation of instances. compose() returns a stamp. .create() returns an object instance as described by the stamp. These are two very different things.

Any stamp can be extended/composed by simple MyStamp.stamp({ init: function() {} }) or alike.

We should only use one property on the created stamp. I strongly feel that property should be called .create() - implying that we're creating an object instance (not a new stamp).

I have no argument against exposing myStamp.create.compose(), but I think most users will prefer just to import compose(), instead.

I don't think we should call .compose() ".stamp()". I like that .compose() is a verb, and it implies that you can compose stamps.

koresar commented 9 years ago

Eric, please hear me.

Functionality is duplicated by MyStamp() and MyStamp.create()

That's why we should drop the .create and have a .compose instead.

"Less is more" you said in your recent tweet.

ericelliott commented 9 years ago

Ah, I see what you mean. Updated.

:+1:

JosephClay commented 9 years ago

descriptors:

One of the shortfalls that I've experienced with v2 has been with descriptors, as Object.assign() doesn't correctly copy over items like { get foo() {}, set foo() } whereas class does. Would descriptors solve this and how would it solve this (in both ES5 and ES6)?

Also, would creating a stamp with a descriptor { writeable: false } be able to safely be composed with another stamp with the same descriptor?

other stuff:

Edits: spelling, pronouns, derps

ericelliott commented 9 years ago

One of the shortfalls that I've experienced with v2 has been with descriptors, as Object.assign() doesn't correctly copy over items like { get foo() {}, set foo() } whereas class does.

Yes, it would. The ability to add descriptors means that we will have full support for getters and setters, which are included in the descriptors API.

Also, would creating a stamp with a descriptor { writeable: false } be able to safely be composed with another stamp with the same descriptor?

Yes, provided that the other stamp's definition does not attempt to write to the property. I imagine it would throw the first time you try to write to it, so as long as your unit tests are thorough, you should be able to catch such collisions in your programs.

If only one stamp is passed to compose, should it noop instead of defaulting? Would it follow the principle of least astonishment for it to be a clone() if only one stamp is passed?

No, it will return a new stamp based on the stamp you passed in. Stamps are immutable since 2.0.

Is merge the replacement for deepProps? I commonly refer to it as _.merge due to lodash.

Yes. I think it's a much less confusing name. =)

I like @koresar's comparison to the Promise spec.

Me too. He's full of good ideas lately. =)

troutowicz commented 9 years ago

This is looking good, lots of discussion. With the recent ideas, does the below look accurate?

/* composable === some object */

_.isFunction(composable); // true
_.isArray(composable.initializers); //true - optional 
_.isPlainObject(composable.references); //true - optional 
_.isPlainObject(composable.properties); //true - optional 
_.isPlainObject(composable.methods); //true - optional

Note: Am I thinking too much about stampit specific implementation? What should be included in the actual specification?

ericelliott commented 9 years ago

You're missing descriptors & symbols in the tests.

You're composable function has an identity crisis. There is no parent object when not invoked as a method. It should use an empty composable. Otherwise, looking good!

We could start writing composable unit tests. We should host spec tests in a composable spec repo. I'll set it up later. :)

ericelliott commented 9 years ago

Typos from phone. :)

koresar commented 9 years ago

I'm confused with the Tim examples.

What's "base object"? AFAIK there's no such thing as base or parent object.

I'll get home and will post a complete set of examples as I see it.

On Sun, 12 Jul 2015 14:59 Eric Elliott notifications@github.com wrote:

Typos from phone. :)

— Reply to this email directly or view it on GitHub https://github.com/stampit-org/stampit/issues/151#issuecomment-120687335 .

koresar commented 9 years ago

The complete Open Composables Standard as I see it.

Stamp()
import Stamp from 'stamp'; // alternatively the Stamp can be global like `Promise` in ES6

const composable = Stamp(
  { // a stamp-description object with the following properties (names are far from final)
    methods: ... 
    assign: ...
    run: ...
    merge: ...
    statics: ...
  },
  anotherComposable // a composable. There can be more arguments
);
composable()
_.isFunction(composable); // true

const obj = composable(baseObject, args...); // creates objects.
composable.compose()
_.isFunction(composable.compose); //true

var combinedComposable = composable.compose(
  anotherComposable, // a composable
  { // a stamp-description object
    methods: ... 
    assign: ...
    run: ...
    merge: ...
    statics: ...
  },
  thirdComposable // one more composable, etc. etc. etc.
);

// stamp-description properties (far from final list and names)
_.isArray(composable.compose.initializers); //true - optional (format of the object is yet to decide)
_.isObject(composable.compose.references); //true - optional (format of the object is yet to decide)
_.isObject(composable.compose.properties); //true - optional (format of the object is yet to decide)
_.isObject(composable.compose.methods); //true - optional (format of the object is yet to decide)
_.isObject(composable.compose.statics); //true - optional (format of the object is yet to decide)

----- The end. Finita. Basta. Fin. -----


The above is the entire Standard. Do you see how short the Open Composables Standard can be?

Less is more (C) Eric Elliott

The things described in the initial top message of this thread are handy ecosystem utilities similar to Promise.all() or Promise.reject() or Promise.resolve() etc. And I actively like it and support it very much! Although, they should not be part of the core specs.

troutowicz commented 9 years ago

You're composable function has an identity crisis.

I'm not sure I understand what you mean.

What's "base object"? AFAIK there's no such thing as base or parent object.

"base object" is your "stamp-description object". "description object" does sound better. "parent object" is just that, the parent from which the .compose method is attached. (aka "parent description object")

Your write-up looks good, but I think we should drop any uses of "stamp" from the spec and use "composable". Also composable() returns object instances, we should clarify that.

JosephClay commented 9 years ago

I believe we have to define the terms we use in the spec, otherwise the interpretation is arbitrary (e.g. merge, references vs properties etc...).

Also, initializers must be described in the spec to remove ambiguity. Any guesswork here by the implemenation will mean that different composable libs won't work together. For example, all A+ compliant Promise libs must conform to .then()'s specification...so everyone can:

// dummy code
(new Promise()).then(function(onFulfilled, onRejected) {});

var Promise = require('promise');
(new Promise()).then(function(onFulfilled, onRejected) {});

var Promise = require('bluebird');
(new Promise()).then(function(onFulfilled, onRejected) {});

var Promise = require('q');
(new Promise()).then(function(onFulfilled, onRejected) {});

Even if the answer is "nothing/you decide" we should answer:

Outside of that:

koresar commented 9 years ago

@troutowicz

I think we should drop any uses of "stamp" from the spec and use "composable"

Think of an analogy: Stamp=Promise, Composable=Thenable. (Also "composable" is too long word as for me.)

koresar commented 9 years ago

@JosephClay

Define how assign/merge/statics are merged. Which are deep, which are not, which use Object.assign and which use Object.defineProperties etc...

I have most of that in my head. Should be written down instead though.

Define what happens if "invalid" configurations are passed. What happens to foo in .compose({ foo: 'bar' }).

TypeError should be thrown in most (if not all) cases.

koresar commented 9 years ago

@JosephClay

What's the context of initializers?

The initializer function binding: 1) If the object instance _.isObject then the initializer will be bound to it. 2) If the object is not _.isObject then the initializer will be bound to the mergeUnique(Object.assign({}, references)) temporaty object (the mergeUnique is the supermixer function).

What parameters are passed?

Initializers receive the same thing as they receive in stampit v2 - { stamp, instance, args } object.

koresar commented 9 years ago

@JosephClay

I believe we have to define the terms we use in the spec, otherwise the interpretation is arbitrary (e.g. merge, references vs properties etc...).

Definitely! Any proposals?


Terminology

(please, speak if you don't like)

The word composable is we mutually agreed with I believe.

I strongly like the word Stamp. Firstly, because it's short. Secondly - because it's a brand name.

I propose to use stamp-description object or simply stamp descriptor. Ok?

I like assign and merge words because they imply what will be done to my data. How do you feel about it?

I'm okay with the methods word. We all agreed on that. (Although, I secretly like proto.)

I'm okay with statics. I doubt someone might find a better name for it.

I'm still thinking of better name for the initialize. That's why in my above examples you can see run.


Did I miss anything?

troutowicz commented 9 years ago

Think of an analogy: Stamp=Promise

OK, fine with me. I just wasn't sure how much stampit lingo we were wanting to use in a spec.

koresar commented 9 years ago

The factory implementation pseudocode (not taking into account the defineProperty stuff, because it's hard):

function factory(instance, args...) {
  const context = _.isObject(instance) ? instance : {};
  _.merge(context, this.compose.merge);
  _.assign(context, this.compose.assign); // references are taking over deep props
  context.__proto__ = this.compose.methods;
  this.compose.initializers.forEach((init) => {
    const result = init.call(context, { instanace, stamp: this, args });
    if (!_.isUndefined(result)) {
      instance = result;
    } 
  });
  return instance;
}
JosephClay commented 9 years ago

Terminology

stamp descriptor:

A map containing the following properties and values:

TODO: find a place for this to sit

initializers

merge, assign, statics

TODO: define the expectation of the end result of each object. e.g.

JosephClay commented 9 years ago

^ please correct if anything is amiss

ericelliott commented 9 years ago

I've tried to capture discussion RE: terminology in the description above.

I have not yet captured the parameters for initializers.

The more I see "initializer" used instead of "init" or "run", the more I like it, simply because it provides an obvious vocabulary. "runners" don't quite have the same ring, do they? =)

I'd like to stick to verbs for utility function names, e.g. assignStatic() instead of statics(). Users are free to import them with whatever names they like, e.g.:

import statics from 'stampit/utils/assignStatic';
koresar commented 9 years ago

@JosephClay I believe you are largely confused on what stamp is. :)

composable: a factory function that creates stamp instances.

Composable and Stamp are essentially the same thing. Have you heard of Thenable and Promise? It's the same. Composable is a function which have property .compose which is also a function. Stamp is a Composable with predefined structure (which we are discussing here).

compose: a function that combines stamps and plain objects into a new stamp descriptor.

into a new stamp, but not descriptor. IMO stamp descriptor is a part of stamp, but not the stamp itself.

stamp: an object returned from a composable factory using a stamp descriptor.

I didn't quite caught this sentence.

assign: a shallow map of property name and values attached to the stamp on stamp creation.

attached to the stamp, but assigned to new instance objects created by the stamp. Correct? Same goes for merge.

methods: a map of property name and function values that are added to the stamp's prototype on stamp creation.

Wrong. Stamps do not have prototypes. Object instances created by the stamp do have prototypes.

statics: a map of property name and values attached to the composable.

attached to a stamp (aka composable).

initialize: a function to be executed against the stamp's scope on stamp creation.

Very wrong. :) Stamp is a factory function, it creates objects! a function to be executed against the stamp's scope on object instance creation.

instance: the current context of the initializer.

Nope. Instance is the object being created (stamped). Most of the time instance is the initializer's binding context.

{writeable: false} will throw an exception on merge when x

I'm hugely against any exceptions. Long to explain, but this is to implicit. Sometimes object creation will throw, sometimes will not... Inconsistency sucks. Better skip conflicting properties.

JosephClay commented 9 years ago

I believe you are largely confused on what stamp is. :)

I am! After reading through your comments and re-reading this thread and the stampit README, I'm starting to see where I'm going wrong. My brain is tangled :smile:. I'm using stamp and object instance in the wrong places (or worse, interchangably).

IMO stamp descriptor is a part of stamp, but not the stamp itself.

I see. I was seeing them as the same thing due to the specifics of the descriptor being accessible from the stamp.

Composable and Stamp are essentially the same thing. Have you heard of Thenable and Promise? It's the same.

I can see the relationship you're trying to define here. They're not quite 1:1, but pretty close concept.

I'm hugely against any exceptions. Long to explain, but this is to implicit. Sometimes object creation will throw, sometimes will not... Inconsistency sucks. Better skip conflicting properties.

I'm not on either side of this one...but it shouldn't be an idle decision. Seems that it could work either way.


The rest of your comments are spot on. Between your comments and EE's updates above, I think I'm on track.

ericelliott commented 9 years ago

I added a stamp descriptor section. Please look it over & comment.

koresar commented 9 years ago

@ericelliott we need to discuss this radical and largely confusing method.

stamp(baseObject, stamps...) => objectInstance

How come we pass both user data and composables one next to another?

What about easy private state (arguments passed to the init functions)?

ericelliott commented 9 years ago

@koresar You're right.

koresar commented 9 years ago

I went through the top message of this issue. I think I like every part of it.

There's one function name choice which will not lint in most projects. The set function name. Any better names you have in mind people?

ericelliott commented 9 years ago

There's one function name choice which will not lint in most projects. The set function name. Any better names you have in mind people?

Why won't set() lint? It should.

koresar commented 9 years ago

Let's move our discussions to the special repo: https://github.com/stampit-org/stamp-specification/issues

Please, create new issues to disccus each particilar topic.

koresar commented 9 years ago

Why won't set() lint? It should.

Yep. Double checked with couple of linters intalled on my machine. They all accept such properties. Good.

koresar commented 9 years ago

Seems like we have migrated all the idea we had here. Closing this one in favour of the new repository. Initial post updated accordingly.

Tahnks people for the productive discussion. Cheers.