slightlyoff / Promises

DOM Promises IDL/polyfill
Apache License 2.0
153 stars 28 forks source link

A proposal for ending the wrapping/unwrapping war. #53

Open slightlyoff opened 11 years ago

slightlyoff commented 11 years ago

Doing this here because the mailing lists have turned into an unfollowable mess.

First, some cautionary notes: this thread will use the terms "wrapping" and "unwrapping" to refer to APIs that treat Futures as something other than direct values. Posts here that use different terminology will be edited to conform.

Next, please be civil and cordial. Those who don't agree with you aren't bad and they might not even be wrong. Goodwill will get us where we want to go.

OK, down to it:

It seems to me that the contended bit of the design is .then() (and by extension, .catch()). Both pro and anti-unwrapping camps can have their own way at resolution time using .resolve() and .accept() respectively. And both have valid points:

On top of these points, there are preferences for styles of use. These can't ever be truly enforced with rigor because it's trivial to write wrapping/unwrapping versions of direct functions and vice versa. So there are cultural differences that we must admit with regards to preferred styles of use.

With the caveat that all involved are willing to live-and-let-live, I observe that it is possible to end the current debate in everyone's favor so long as we add a "direct" version of .then(). That is to say, one which has the semantics of .accept() with one level of unwrapping for a returned thenable and not .resolve() (infinite unwrapping) for values returned from callbacks.

I'll submit .when() as a straw-man name, but I'm not precious about it.

So the question now is: is everyone cc'd here willing to make such live-and-let-live compromise? And if not, why not? And is there some other detail which I've missed?

/cc @domenic @dherman @wycats @annevk @tabatkins @erights

domenic commented 11 years ago

a "direct" version of .then(), e.g. one which has the semantics of .accept() and not .resolve() for values returned from callbacks

I think I know what you mean by this, but just to be sure, could you tell us what the fulfillment value ("acceptance value"?) of x, y, and z are in the following?

var a = new Future({ accept } => accept(5));
var b = new Future({ accept } => accept(a));

var x = a.when(() => 5);
var y = a.when(() => a);
var z = a.when(() => b);
puffnfresh commented 11 years ago

I tried reading this but had a hard time understanding in between the cautionary notes and preferences stuff. The idea is to not recursively join promises on then and resolve. when called via new methods?

I know we're in JavaScript-land but some pseudo type-signatures would really clear this up for me.

slightlyoff commented 11 years ago

Hey @domenic:

x == 5, y == 5, and z == a

At least that's my read of the design that @tabatkins was gunning for. I'm open to arguments that would make y == a and z == b, but I've been told that nobody wants that = )

domenic commented 11 years ago

Ah. I don't think that's very conceptually coherent :-/. If you want a counterpart to accept, it should be y ~ a and z ~ b. I think that this desire for conceptual coherence was the main idea behind @erights's "The Paradox of Partial Parametricity".

slightlyoff commented 11 years ago

My goal here is to remove the strain from .then(). I think there is a missing method. What it does, I'm somewhat less concerned about.

Waiting for @tabatkins and @dherman to weigh in.

sicking commented 11 years ago

Like Dominic, I think that if we were to have a separate .when() function, then it would be most useful/consistent if that function gave

x == 5 y == a z == b

This is the result you'd get if the implementation of .when() called .accept(returnValue) rather than .resolve(returnValue).

But I could be biased because I think that .resolve() and .then() should never recursively unwrap promises. I rather think the question is if we should allow them to become deeply wrapped or not.

If we're going to have both .then() and .when() I would strongly prefer to have clearer names. I.e. where it's more clear what the difference in behavior is between them.

tabatkins commented 11 years ago

@domenic x is 5, y is 5, and z is Future<5>, or a.

@sicking Oh goodness, no. That would make it a map, which is much less useful. What I've wanted this whole time is a monadic bind function, which does single-level unwrapping. (Once you have bind, you get map for free.)

@slightlyoff If we're going to go this way, I think it would probably be worthwhile to be strict about things, and make .when() strict about the return values of its callbacks. Right now it's loosey-goosey, acting like a bind if it can, and falling back to a map otherwise. Being strict means that x would be a rejected promise containing a TypeError, because the return value of the callback wasn't a promise, then y is 5 and z is a.

That behavior is more predictable, though less generally useful. I might be okay with this - .then() is the really loose, friendly function that accepts direct values or promises, and it fully unwraps so that its returned promise only ever contains direct values. .when() is the stricter and more predictable monadic function.

In that case, let's call the function .chain() instead, like @puffnfresh does in his Fantasy Land spec. It's a good general-purpose name for the monadic binding operation (I never really understood why Haskell calls it "bind" in the first place), which means that other structures can re-use the same name without trouble, and we can do good monadic stuff. It's also further from .then() in name-space, which should help people to distinguish them, and I think it's somewhat clearer in general - you're chaining two promises together, which is why you can't return a non-promise.

puffnfresh commented 11 years ago

:+1:

https://github.com/puffnfresh/fantasy-land#chain

The chain method would allow us to immediately make use of Fantasy Land derived functions for DOMFutures, which would be really great. The only requirement is that the law associativity is obeyed, which seems to be true.

tabatkins commented 11 years ago

So, some details you missed: ^_^

(Assuming that the new operation is called chain.)

Your proposal details differentiate between then and chain by the way they treat their callback return values, with then fully unwrapping and chain single-unwrapping. This works fine if you only use then or only use chain, but if you mix them, you'll run into trouble - a then callback can receive a Future, or a chain callback can have its argument unceremoniously collapsed.

Instead, they should differentiate on the argument side - if the Future contains nested Futures, the then callbacks don't get called until they all resolve and you end up with a plain value, while the chain callbacks fire as soon as the outer future resolves. It's possible for a then callback to return a nested Future, but if you then continue to use then you won't ever see it.

jakearchibald commented 11 years ago

Anyone willing to create (preferably real-world) JS examples of when & how you'd use one over the other? My first reaction is the single unwrap is weird but feel I'm missing something

slightlyoff commented 11 years ago

Still hoping @dherman will weigh in, but a few observations:

Thanks.

tabatkins commented 11 years ago

@jakearchibald Lots of discussion about this in the various threads. Single-unwrap helps in scenarios where different futures represent fundamentally different types of things. For example, a database retrieval might return a Future, and the thing stored in the database might be a Future. You may want to just wait for the database retrieval to finish and then get to work on the value immediately, rather than having to wait for both of them to finish.

This is especially true when we add LazyFuture or whatever, which doesn't start its operation until someone registers a callback.

I don't like multi-unwrap at all (unless done explicitly), because I think generally it'll just paper over programming errors, but fuck it, if I can get a good chain out of this, I'll accept a recursive-unwrapping then.

@slightlyoff I don't understand. Your starting post in this thread was attempting to resolve the issue by splitting it into two operations, and now you're trying to suggest that we don't need two operations at all? Don't bait-and-switch me, bro.

But no, you can't build chain on top of recursive-unwrapping then without bullshit extra wrappers designed solely to foil auto-unwrapping, because your Futures won't stack. It would be extremely clumsy and dumb, and wouldn't work like a normal Future at all.

shogun70 commented 11 years ago

If only there were an alternative method like .then()which exposed the new Future's resolver to it's callbacks. Then you could accept / reject / resolve your intended .then() callbacks in anyway you need. So you could do:

.thenWithResolver( resolveOnce( getSomething ) )  // unwrap once if getSomething(value) returns a promise

.thenWithResolver( accept( getSomething ) ) // always accept, even if only a promise

.thenWithResolver( rejectPromises( getSomething ) ) // Don't give me promises!

.thenWithResolver( rejectPromiseStreams( getSomething ) ) // I'm not trusting your promise a second time!

.thenWithResolver( future_invoke( obj, "getSomething" ) ) // .then( obj.getSomething.bind(obj) )

.thenWithResolver( translatePromises( getSomething ) ) // It's a promise Jimmy, but not as we know it.

It's just an idea off the top of my head really. In general though, the right way to do all these things is to create another new Future inside a .then() callback.

tabatkins commented 11 years ago

@shogun70 What you're saying doesn't make any sense. Neither have any connection to the question of nested promises.

Returning a Future from a .then() callback is already a useful and accepted pattern - it gets unwrapped and the chained future adopts its state. The question is about returning a Future for a Future.

tabatkins commented 11 years ago

@slightlyoff So, the best, most consistent model is this:

Futures can stack. No magic, nothing special, you can just wrap anything in a Future, including more Futures. then waits until all nested futures have resolved before calling its callbacks with the final non-future value. chain just waits for the outermost future, and calls its callbacks with whatever the value of that is, whether it's a plain value or another future. Both are the "simpler" option, depending on who you ask.

(The treatment of callback return values isn't strictly important, but then can accept either futures or plain values, treating the latter as if it had been wrapped in an accepted future. chain only accepts futures. Both have their chained future adopt the returned future's value, doing single-level unwrapping. Of course, if you keep using then, the fact that it only unwraps the return value one level is invisible, since your callback isn't called until all the levels are unwrapped.)

This solves all problems, forever. If you have a nested Future where the two Futures are substantially different kinds of things, such that you only want to wait for the outer future to finish, not both, you can use chain to interact with it. If you don't care about all those details, and just want the final value, you can use then. There are use-cases for both - I've given use-cases for chain, and the use-cases for then are obvious, and all of the "I don't give a crap about your asynchrony, just call me when you're done" variety.

This also happens to satisfy people who like monads, because chain is the monad operation over futures.

It's possible to build then on top of chain without too much difficulty, but you can't do the opposite without a lot of annoying bullshit, like a non-thenable wrapper object designed solely to foil the recursive resolving.

slightlyoff commented 11 years ago

So just to report on the TC39 meeting's results, we have made the following changes to a new Promise.idl that is now the basis for discussion:

I accept that .chain() explains .then() and not vice-versa, but I'm not sure that the new current state is bad: it neither precludes adding .chain() in the future, nor does it prevent libraries from adding their side-contract version, nor does it prevent anyone from advocating for their preferred style.

tabatkins commented 11 years ago

Ugh, that's incoherent, though! Setting aside chain for the moment, the consensus you describe means that then is not guaranteed to get a plain value - authors can construct a nested future with fulfill and then call .then() on it, and the callback gets a future, not a value.

This is just silly. If you want Mark/Domenic's then, then either remove fulfill so you can never produce a nested Future (I don't recommend this) or make then do the unwrapping at the read side, not the return side. The difference is unobservable to code that only produces singly-wrapped Futures, but it acts much better when nested Futures show up.

And no, you can't introduce chain via subclasses. Just try it. The only way to do it has already been demonstrated by Domenic, and it produces something that is unusable by things that expect normal promises without special-case ugliness. It also requires ES6 argument destructuring just to be usable; without that, it's beyond the pale in usability. Even with destructuring, it's not all that usable - Domenic uses an {x} in his code, which looks like a standard placeholder argument name, but it's not - x is actually part of the contract, and you have to use it (or use the longer {x:foo} form to name your argument foo).

shogun70 commented 11 years ago

It's just functional composition @tabatkins. .then() callbacks don't need to be modified - they can still return a value-or-promise. Or a promise-for-a-promise. To modify the way you handle those things - single unwrap; infinite unwrap; immediate accept; etc - it's just the wrappers that need to change.

So,

.then( getSomething )

can receive exactly the same getSomething callback as

.thenWithResolver( resolve ( getSomething ) )

Assuming thenWithResolver passes its new future's resolver as the first arg (like new Future() init callbacks), resolve is simply:

function resolve( fn ) {
    return function( r, value ) { // this doesn't need try-catch, because ...
        result = fn( value );  // ... if this throws `.thenWithResolver` will reject
        r.resolve( result );
    }
}

In long hand that would be:

function resolve( fn ) {
    return function _resolve( r, value ) {
        result = fn( value );
        if ( isThenable ( result ) ) { // approximately: ( result && typeof result.then === 'function' )
            result.then ( _resolve.bind( null, r ), r.reject );
        }
        else r.accept( result );
    }
}

Continuing on:

.thenWithResolver( accept( getSomething ) ) // always accept, even if only a promise
...
function accept( fn ) { 
    return function( r, value ) { r.accept( fn (value ) ); }
}

chain seems to be a single unwrap:

.thenWithResolver( resolveOnce ( getSomething ) ) //  unwrap once if getSomething(value) returns a promise
...
function resolveOnce( fn ) {
    return function ( r, value ) {
        var result = fn( value );
        if ( isThenable ( result ) {
            // unwrap once and then accept, even if a promise
            result.then( r.accept, r.reject ); // assuming `accept` and `reject` are bound methods
        }
        else r.accept( value );
    }
}

This approach seems quite straight-forward. I can't see any difficulties for someone who knows enough to need non-default behavior.

tabatkins commented 11 years ago

@shogun70 I've argued this with you in the mailing list, and won't repeat it here. Your proposal is less convenient for common cases, in return for being slightly more convenient for interfacing with legacy callback APIs. It doesn't match any of the major promise libraries.

shogun70 commented 11 years ago

You are reacting to a non-essential implication of my proposal which I haven't even mentioned in this discussion.

Lower-level alternatives for then / catch don't require the deprecation of then / catch.

And they provide more flexibility, including the feature you are after.

Why is it up to the API to say that JS devs will only want the default behavior?

You are very aware that some JS devs want non-default behavior, because you are one of those people. Why limit that non-default behavior to only one option, when you can leave that decision to the coder?

tonymorris commented 11 years ago

Every time I read these javascript discussions on Future, I see an emphasis on the fact that it is a monad. The Future monad brings great benefits. Of course, we'd also need a library that parameterises on all monads to truly exploit this benefit. Good luck with that! However, this is not why I write.

Rather, I wish to stand in place and defend the utility of the forgotten future function. Future is a semi-comonad and I further this claim with the following: while future's monad is ubiquitous and useful in everyday programming, its dual, the semi-comonad, is at least just as much, if not more!

The following table demonstrates the relevant functoriality to this discussion.

Please do not forget my friend, cobind.

tabatkins commented 11 years ago

Indeed, Future has cobind as well (or as I usually see it named, extend). You can implement that on top of either Future design. ^_^

cowwoc commented 11 years ago

The problem with this API is that it is neither intuitive, nor symmetric for the lay man.

accept() and reject() are clear, so are success() and failure(). Once you begin talking about resolve() or fulfill() you went off in the wrong direction.

I suggest renaming:

fulfill() to success() resolve() to next() or chain() reject() to failure() then() to onSuccess() catch() to onFailure()

These might not be the most elegant names, but they are damn intuitive (at least to me).

Please rename the API methods to something more intuitive and symmetric. Thank you!