briancavalier / creed

Sophisticated and functionally-minded async with advanced features: coroutines, promises, ES2015 iterables, fantasy-land
https://briancavalier.github.io/creed
MIT License
273 stars 20 forks source link

Why there is no `creed.of` method? #177

Open dmitriz opened 6 years ago

dmitriz commented 6 years ago

The FL Applicative spec includes the of method but it does not seem to be available on creed:

> creed.of(1)
TypeError: creed.of is not a function

Any reason not to have it?

It could be mentioned that of is actually more basic than Applicative and is part of the Pointed Functor Spec, see also https://github.com/MostlyAdequate/mostly-adequate-guide-it/blob/master/ch9.md#pointy-functor-factory.

It seems that creed.fulfill is doing what of is meant to do, which is somewhat non-standard name and is longer to write. Also, when it is not called of, the question arises whether it conforms to the Pointed Functor spec, which I understand it does.

If creed.fulfill is indeed intended to satisfy the Pointed Functor spec (together with map), maybe also alias it as of and add tests for the spec?

briancavalier commented 6 years ago

Hi @dmitriz. The FL has changed slightly over time with regard to where of should be placed. At one time, it was required to be on the type's constructor and/or prototype.

So, creed had placed it at creed.Promise.of, which is still present:

> var { Promise } = require('creed')
undefined
> Promise.of
[Function: of]

The language has changed to "type representative". The of section as well as the last paragraph of the type representative section seem to indicate that it's still required to be named constructor. That makes it sound like any promise creed creates would still need to have p.constructor.of. That's currently also true:

> var p = Promise.of(123)
undefined
> p.constructor.of
[Function: of]
> p.constructor.of === Promise.of
true

So, I think creed still satisfies Applicative / Pointed Functor. To be honest, I haven't read the FL spec in depth in a while, so I hope I'm reading all of that correctly!

It may still be perfectly reasonable to add of as a named export alias of fulfill. On one hand, it's nice because, like you said, it matches the FL name and it's short. On the other hand, having 2 exported names for the same thing can be confusing. What are you thoughts?

dmitriz commented 6 years ago

Thank you @briancavalier for the explanation, I would have never guessed it is on the creed.Promise namespace. ;) It does not even show up in the REPL:

> creed
{ enableAsyncTraces: [Function: enableAsyncTraces],
  disableAsyncTraces: [Function: disableAsyncTraces],
  resolve: [Function: resolve],
  reject: [Function: reject],
  future: [Function: future],
  never: [Function: never],
  fulfill: [Function: fulfill],
  all: [Function: all],
  race: [Function: race],
  isFulfilled: [Function: isFulfilled],
  isRejected: [Function: isRejected],
  isSettled: [Function: isSettled],
  isPending: [Function: isPending],
  isNever: [Function: isNever],
  isHandled: [Function: isHandled],
  getValue: [Function: getValue],
  getReason: [Function: getReason],
  coroutine: [Function: coroutine],
  fromNode: [Function: fromNode],
  runNode: [Function: runNode$1],
  runPromise: [Function: runPromise$1],
  delay: [Function: delay],
  timeout: [Function: timeout],
  any: [Function: any],
  settle: [Function: settle],
  merge: [Function: merge],
  shim: [Function: shim],
  Promise: 
   { [Function: CreedPromise]
     resolve: [Function: resolve],
     reject: [Function: reject],
     all: [Function: all],
     race: [Function: race] } }

My personal reaction is, whether the nested namespace is really necessary and not adding any extra complexity making the library harder to use (and discover its features). I can also see another resolve there, which feels even more confusing.

Would it not be simpler to have the main library creed as the type representative with all methods defined there? That would automatically remove the double resolve problem. Then of can also be put there and declared to be identical with fulfill, which I think is very helpful to know and not confusing at all. The name of alludes to the Pointed Functor and fulfill to the actual implementation.

On the other hand, the creed.Promise can be confused with the native Promise, which still leaves me wondering about their difference, apart from having some more methods. Of course, I might be missing some point haven't used it that much.

bergus commented 6 years ago

@dmitriz

On the other hand, the creed.Promise can be confused with the native Promise

That's the whole idea. The Creed Promise constructor can be used as a drop-in replacement for the native promise constructor.

dmitriz commented 6 years ago

@bergus

That's the whole idea. The Creed Promise constructor can be used as a drop-in replacement for the native promise constructor.

Hm... Then why not just use the native Promise for the common methods? Any advantage? Any difference?

briancavalier commented 6 years ago

tl;dr I'm open to simplifying/streamlining the API for a 2.0 release

@dmitriz Creed was intended to help bridge between A+ / ES and FL. It was created at a time when native promises had significant performance problems, async/await wasn't easy to use everywhere, and FL was still fairly new.

So, many of the 1.x API decisions were made under quite different circumstances than exist today. It's easier to see some of the API inconsistencies with hindsight. I'm sure there's plenty of room for improvement, and I do appreciate your fresh perspective on it.

The API is intended to be used primarily as named exports, and creed.Promise is exported mostly just in case someone might ever need it. Perhaps not the best reason, but again, hindsight. of being on Promise was mostly for FL compat.

As @bergus mentioned, creed's Promise was intended as a drop in replacement for A+/ES. The shim() function will forcibly install creed's Promise as global Promise. There were (and still are, imho) advantages to that:

  1. Afaik, creed is still faster and more memory friendly than native promises.
  2. Using multiple promise implementations in a single app incurs assimilation penalties. Many promise implementations, including creed, can optimize interactions (bypassing then) with their own promises, but are forced to interact with other implementations by only using then. Thus, if your app uses explicit promise via creed, it is beneficial that other promises created via global Promise are also creed promises.
  3. Creed's async stack traces are pretty helpful.

All of that said, I'd certainly be open to simplifying/streamlining the API for a 2.0 release.

unscriptable commented 6 years ago
  1. Creed's async stack traces are pretty helpful.

Correction: Creed's async stack traces are awesome!

dmitriz commented 6 years ago

@briancavalier Many thanks for your detailed explanations, greatly appreciated!

I can see the benefits for both replacing the native Promise, as well as of some more "lighter" way to use creed in addition to the native promises.

All of that said, I'd certainly be open to simplifying/streamlining the API for a 2.0 release.

A big selling point of creed I could see, is to use the proper functional operators like map that are lacking for the native promises. A very lightweight way to use it, with as little other changes to the code as possible, could be to follow the Static Land's Functor Spec as

creed.map(someFunction, nativePromise)

which would return what I would call a "creed promise", as opposed to the JS native promise. The latter would be the wrong type to return here because e.g. it does not allow to wrap promise into promise.

Now I understand that creed does not aim to be SL-compatible (which would be nice!), looking for FL instead. The problem with the FL-compatibility though, it does not seem to provide similar lightweight ways to use libraries, if I understand it correctly. Instead of the point-free (aka point-less 😄 ) style, it requires to use methods, that I somehow need to get first for my nativePromise instance. This makes it more verbose with at least two new operations instead of one. Also the instance methods could be more invasive than static ones and harder to use in functional composition pipelines.

My guess is that the currently intended way is

creed.resolve(nativePromise).map(someFunction)

which does not feel as obvious and straightforward, due to the non-standard creed.resolve method that is easy to confuse with Promise.resolve. If I understand correctly, the latter will unwrap any nested promise, whereas the former will not, making it for a subtle difference I would need to delve into, where all I wanted was really to get my map working. ;)

Perhaps something like

let creedPromise = creed.fromPromise(nativePromise)

could be a lightweight way of cooking up a "creed promise" that would be obvious to any outsider?

Another suggestion, since the native promise is by now established, perhaps call this flavour a "Creed Promise", as a matter of terminology. That would make it easy to be used in explanations along with native promises, as in the above examples.

briancavalier commented 6 years ago

Again, there is history: Static land didn't exist (or at least, I wasn't aware of it) when creed was created, and FL did exist, so picking a "standard" on which to base the API was easy 😄 . Also, tree-shaking wasn't practical and/or widely used, and function composition (which will tend to require partial application) as a programming model in JS wasn't prevalent.

Since then, I decided to implement both SL and "functions-mostly" in another project, and then eventually transition to functions-only. I've been watching this closely to see when the time is right to adopt it. I might rather go in that direction, once it gets a bit more consensus, but it seems to leave the tree-shaking question open.

Methods aren't all bad: they allow typeclass-like dispatching, whereas functions require either using switch statements or passing around typeclass dictionaries to achieve the same, both of which can be cumbersome. IIUC, the latter is one reason FL chose methods as its primary typeclass representation.

Creed's design takes advantage of that to provide optimized implementations for fulfilled, rejected, and never promises. I've found that representing some small-ish number of core low-level operations via methods plus a public API via functions works quite well. Creed simply uses methods where there was opportunity for different promise variants to provide a specialized implementation.

It would be an interesting research project to see how shifting more toward functions compares, both readability/maintainability-wise and the ability to optimize cases like fulfilled, rejected, and never.

briancavalier commented 6 years ago

I like being explicit with fromPromise (this also matches some other JS projects). I have also come to prefer just as point, since there are variadic uses of of in the JS ecosystem that make it confusing (e.g. Array.of, Observable.of). However, it seems likely that the unified FL would adopt of. Either is ultimately fine with me.

If I understand correctly, the latter will unwrap any nested promise, whereas the former will not,

creed.resolve and creed.Promise.resolve are the same function.

Another suggestion, since the native promise is by now established, perhaps call this flavour a "Creed Promise", as a matter of terminology.

Yes, that seems helpful now that "promise" tends to mean ES Promise.

dmitriz commented 6 years ago

@briancavalier

Again, there is history: Static land didn't exist (or at least, I wasn't aware of it) when creed was created, and FL did exist, so picking a "standard" on which to base the API was easy 😄 . Also, tree-shaking wasn't practical and/or widely used, and function composition (which will tend to require partial application) as a programming model in JS wasn't prevalent.

Aha, interesting to know, thanks! The number of standards is not as small anymore these days, e.g. Jabz prefers combine over concat (that I tend to agree with as concat deals with the more special free monoid), also flatMap is used by several libraries instead of chain, which is less confusing (there are other chains in JS) and consistent with Scala, and generally more descriptive and intuitive to folks outside FP.

Since then, I decided to implement both SL and "functions-mostly" in another project, and then eventually transition to functions-only. I've been watching this closely to see when the time is right to adopt it. I might rather go in that direction, once it gets a bit more consensus, but it seems to leave the tree-shaking question open.

That looks very interesting.

Methods aren't all bad: they allow typeclass-like dispatching, whereas functions require either using switch statements or passing around typeclass dictionaries to achieve the same, both of which can be cumbersome. IIUC, the latter is one reason FL chose methods as its primary typeclass representation.

I find both useful to have. Methods are hard to beat when writing something like

promise.map(f).map(g).flatMap(res => doSmth(a, res))

whereas functions can be nicer to use with non-native but conforming promises

pipe(map(f), map(g), flatMap(res => doSmth(a, res)))

that you can run against any promise with the same abstract code, as long as the first map can pick it up.

Creed's design takes advantage of that to provide optimized implementations for fulfilled, rejected, and never promises. I've found that representing some small-ish number of core low-level operations via methods plus a public API via functions works quite well. Creed simply uses methods where there was opportunity for different promise variants to provide a specialized implementation.

That makes perfect sense. It is probably best to keep the core as small as possible, with choices made for mostly optimisation-motivated reasons, and external plugins providing aliasing or more user-friendly api.

It would be an interesting research project to see how shifting more toward functions compares, both readability/maintainability-wise and the ability to optimize cases like fulfilled, rejected, and never.

This discussion for flyd can be relevant, basically, the best of both worlds seems to be the best :) https://github.com/paldepind/flyd/issues/137