slightlyoff / Promises

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

Capture resolution of forwarding for accept() in API and prose #26

Closed slightlyoff closed 11 years ago

slightlyoff commented 11 years ago

After much investigation and discussion with Mark Miller, the decision regarding forwarding in accept() is:

domenic commented 11 years ago

What if you pass a thenable? IMO it should be treated the same as a future (just like the return values for onaccept/onreject).

domenic commented 11 years ago

Also, you have a naming problem now: accept(foreverPendingFuture) will cause the original future to never be accepted.

I'd suggest using resolve and accept in place of accept and acceptDirectly. (Although accept is sadly less awkward than acceptDirectly, so perhaps just resolve and acceptDirectly?)

erights commented 11 years ago

Ignoring acceptDirectly for now, since that's not part of the normal API developers will think about.

I think "accept" is a great mnemonic name for the dual of "reject", and it solves the terminology problem we've been having with "resolve" vs "resolved". The key is not to call the fulfilled state "accepted" as that just recreates an analogous terminology problem. Let's just keep the Promise/A+ terminology for the states: "pending", "fulfilled", and "rejected", where "resolved" means "fulfilled" or "rejected". Now it's no longer confusing to say that

var p2 = Q.promise((accept, reject) => { accept(p1); })

causes p2's eventual resolution to be the same as p1's.

domenic commented 11 years ago

@erights I actually realized that there's another state, which I had been missing the whole time. Namely, the state where a promise is "locked in," either because it's fulfilled or rejected, or because it's adopting the state of another promise.

Your solution, viz.

is definitely a good one, although it leaves out what to call that locked-in state.

Alternately, going back to older terminology, I had realized that if "resolved" is used for this locked-in state, there's no conflict, and we simply need a new word for "fulfilled | rejected". Thus I had proposed:

I think having a word for this locked-in state is pretty useful, as it signifies the point after which you can no longer change a promise's outcome (e.g. see how it's used in the above-linked proposal).


Honestly, upon realizing this, I felt kind of dumb, because I thought maybe you and @kriskowal were already using "resolved" in this way, and I'd gotten confused by thinking of it as meaning "fulfilled or rejected." I'm glad to see you use "resolved = fulfilled or rejected" above; it reassures me my confusion wasn't a totally manufactured problem!

Also, apologies for using random new terms like "locked in" and "adopt", but I needed some neutral conceptual terms onto which I could map the words we're thinking of actually using.

erights commented 11 years ago

High order bit: I'm happy with any terminology that makes the right distinctions and doesn't suggest the wrong semantics.

For the record: The "resolve" vs "resolved" confusion is my fault, and goes back to E as documented at http://erights.org/talks/promises/paper/tgc05.pdf and Part 3 of http://erights.org/talks/thesis/markm-thesis.pdf I'm very happy we now have several good options for healing this.

That said, I like the terminology aspect of your proposal. But the repair of Alex's proposal would be fine with me.

jakearchibald commented 11 years ago

What's the justification for having an accept method that may do the opposite? Feels confusing.

I'd rather have .accept(anything) always fulfil, and .reject(anything) anyways reject.

I'm not against being able to defer to the result of another future, but that feels like a method of its own which should reflect the ambiguity in it's name, perhaps mirror(future), follow(future), match(future). This method could throw if it doesn't receive a future.

erights commented 11 years ago

@jakearchibald I don't much care what we call these as long as we adopt terminology that is self consistent, not accident prone, distinguishes the normal case from the experts-and-esoteric-needs-only case, and does not suggest a semantics contrary to its actual semantics. There are two naming issues that must be co-designed: the adjective names of the states and the verb names of the operations.

Historically, going back to E, the names of the states were

and the names of the operations were

This has three problems, all of which are historically my fault

What E got right is that the actual semantics of "resolve" is the useful one -- if the argument is a promise, then it causes the promise it is paired with to follow the argument promise.

We seem to at least have consensus on renaming "Broken" to "Rejected" and "break" to "reject".

Promises/A+ has the following names for the states:

but does not itself propose a non-confusing name for the "resolve" operation. @slightlyoff's proposal at the beginning of this thread would rename "resolve" to "accept", which I like because it is a mnemonic dual for "reject". (Originally, Alex's "fulfilled" state was also called "accepted" which recapitulates the "resolve" vs "resolved" confusion, so let's not do that.) Alex also proposes calling the experts only variant "acceptDirectly", which I like because it makes it clear that it is esoteric compared to the simply-named operations. If we call the operation "accept", this suggests that the "following" state be named "accepted", which I don't particularly like but don't hate.

@domenic has made two suggestions I like:

Domenic#1:

Domenic#2

Regarding each of Domenic's suggestions, we'd still need a name for the esoteric "acceptDirectly" operation, about which I have no strong opinions beyond the requirements listed above. I find "fulfilDirectly" to be a plausible choice as well.

jakearchibald commented 11 years ago

"distinguishes the normal case from the experts-and-esoteric-needs-only case" "What E got right is that the actual semantics of "resolve" is the useful one"

What's the basis for tagging one normal/useful and the other expert/less useful?

Having a method that fulfils a future when it's passed an Error or false, but rejects it when its passed a rejected future feels like a confusing bit of overloading. It seems like a little corner of the spec you'd need to be an expert to know about and expect.

Surely it'd be easier for devs to understand if we had accept/reject methods that accepted/rejected the future regardless of the passed argument, then another method that was intended to link a future's fate to another. That way we're not having a "expert" and a "magic" method.

erights commented 11 years ago

@jakearchibald The basis is that the semantics you have in mind forces the programmer to distinguish between a promise-for-T, a promise-for-promise-for-T, a promise-for-promise-for-promise-for-T, etc. These are realistic consequences of adopting this semantics as normal, because one part of a program will then often acceptDirectly (or whatever you call it) an X provided by another part; where that other part, not yet ready to produce an actual result, might instead provide as that X a promise for a Y, etc. Long experience with such abstractions[1] shows this is indeed the common case. In a communicating event loop context (i.e., E or JS) where that other part of the program must return something now, returning a promise now is the only way to effectively return something useful later.

[1] E promises starting in 1996 or so, inspired by Joule Channels in 1990 or so and Xanadu promises 1988 or so, both inspired by Concurrent Prolog logic variables with have a longer history but which I first used in 1986 or so. See Chapter 23 of http://erights.org/talks/thesis/markm-thesis.pdf

jakearchibald commented 11 years ago

I'm struggling to see the problem, can you describe the problem case with some JS? On 18 Feb 2013 19:52, "Mark S. Miller" notifications@github.com wrote:

@jakearchibald https://github.com/jakearchibald The basis is that the semantics you have in mind forces the programmer to distinguish between a promise-for-T, a promise-for-promise-for-T, a promise-for-promise-for-promise-for-T, etc. These are realistic consequences of adopting this semantics as normal, because one part of a program will then often acceptDirectly (or whatever you call it) an X provided by another part; where that other part, not yet ready to produce an actual result, might instead provide as that X a promise for a Y, etc. Long experience with such abstractions[1] shows this is indeed the common case. In a communicating event loop context (i.e., E or JS) where that other part of the program must return something now, returning a promise now is the only way to effectively return something useful later.

[1] E promises starting in 1996 or so, inspired by Joule Channels in 1990 or so and Xanadu promises 1988 or so, both inspired by Concurrent Prolog logic variables with have a longer history but which I first used in 1986 or so. See Chapter 23 of http://erights.org/talks/thesis/markm-thesis.pdf

— Reply to this email directly or view it on GitHubhttps://github.com/slightlyoff/DOMFuture/issues/26#issuecomment-13739638.

erights commented 11 years ago

Start with the definition of Q.delay at http://wiki.ecmascript.org/doku.php?id=strawman:concurrency#q.delay

Q.delay = function(millis, answer = undefined) {
  return Q.promise(resolve => {
    setTimeout(() => resolve(answer), millis);
  });
};

If you call it as Q.delay(1000, 3) it returns a promise which will be fulfilled with a 3 in no less than 1000 millis. Likewise, if we call it as Q.delay(1000, foo()) and foo() returns a 3, then it does the same thing. However, let's say foo() is no longer able to return the desired value immediately for whatever reason. We change foo() to return a promise which will be fulfilled with a 3. Synchronous clients of foo() would have to change -- we expect and understand that. But async clients shouldn't need to -- they're already async. As is, Q.delay(1000, foo()) would still return a promise which will be fulfilled with a 3 in no less that 1000 millis. But with the other semantics, it returns a promise for a promise for a 3.

As you compose promise combinators (Q.all, Q.race, Q.join, ...), the client would have to unwrap for each level of composition to finally get to the promised value. That's the real point. The flattening of promise-for-promise-for-T into promise-for-T done by the normal operations enables the easy definition, composition, and use of such promise combinators.

See http://static.googleusercontent.com/external_content/untrusted_dlcp/research.google.com/en/us/pubs/archive/40673.pdf for more JS examples. Try rewriting these using non-flattening composition and you'll see an explosion of verbosity and a severe decrease in understandability.

jakearchibald commented 11 years ago

In the current model, I'd implement delay like this:

function delay(ms, anything) {
  return new DOMFuture(function(r) {
    setTimeout(r.accept.bind(r), ms);
  }).then(function() {
    return anything;
  });
}

Seems ok to me, what am I missing? This keeps the magic in the then return value, without adding it to accept & reject.

erights commented 11 years ago

That works. But you had the first part create and resolve a promise-for-undefined and then tacked on a second part to .then it and return the anything, merely to avoid the hazard that you'd created in the first place with your flattening semantics. "Doctor, it hurts when I do this." "Well, don't do that." Why not avoid creating the problem in the first place, rather than teach delicate patterns to work around it.

A useful invariant, which helps reduce such hazards, is that the following bits of code are equivalent:

var p2 = p1.then(onfulfil, onreject);

var p2 = Q.promise( accept => {
  p1.then( (v) => { accept(onfulfil(v)),
           (e) => { accept(onreject(e)) );
};
jakearchibald commented 11 years ago

You're telling me that making accept & reject accept or reject the Future regardless of arg is a "hazard" & "esoteric" but I'm still not seeing any examples of that.

Rather than working around a hazard, I see my delay example as a straight-forward description of what I'm trying to achieve, where I don't have to consider that .accept may in fact do its opposite depending on the arg passed.

Adding magic into .accept complicates the API, it means it's no longer the opposite of reject (what does that do if it's passed a fulfilled Future?). However, if there's a good use-case, its worth complicating the API. What I've seen so far, is keeping magic out of .accept makes my usage of it, in the delay example, more explicit.

erights commented 11 years ago

@jakearchibald

yes, accept (or resolve or follow or whatever we call it) is not the opposite of reject. If this makes us prefer a name other than "accept", I'm open to that.

reject with a fulfilled future rejects the original future with the fulfilled future as the reason.

domenic commented 11 years ago

Jake, let's look at it another way. Your example, drawn to its logical conclusion, implies that most future-creation should be done by chaining off of a single accepted-future, call it Future.alreadyAccepted. Then you can create new futures, as you did above, via

var acceptedWith5 = Future.alreadyAccepted.then(() => 5);
var followingFuture2 = Future.alreadyAccepted.then(() => future2);
var rejectedWith10 = Future.alreadyAccepted.then(() => throw 10);

However, wouldn't it be nice if we had a more intuitive "dual" to the return/throw operations? Ones that could be used inside of non-future asynchronous situations, like a setTimeout?

Maybe we could call that dual "resolve" and "reject." Note that the first verb is different---"resolve" instead of "accept"---since indeed, returning doesn't just accept, it does something more complicated and of course more useful. (Otherwise we wouldn't have added that semantic to return in the first place!)

This leads us, naturally, to an API like the following:

var acceptedWith5 = new Future(resolve => resolve(5));
var followingFuture2 = new Future(resolve => resolve(future2));
var rejectedWith10 = new Future((resolve, reject) => reject(10));

But also

var acceptedWith5AndADelay = new Future(resolve => setTimeout(() => resolve(5), 100))
var followingFuture2WithADelay = new Future(resolve => setTimeout(() => resolve(future2), 100))
...

Note that in my example, I am keeping "accepted" and "rejected" as opposite states that do not parallel return/throw. (Since, after all, return rejectedFuture will end up rejected, not accepted.)

There will always be a disconnect between the two primary eventual states (fulfilled and rejected in Promises/A+, accepted and rejected in the current futures IDL) and the two primary verbs (resolve and reject in Promises/A+, ??? and reject in futures). One approach is to keep accepted/rejected as the states, and introduce resolve/reject as the verbs; the other is to change the state names to fulfilled/rejected, and let accept/reject be the verbs. (Or, of course, abandon the "accept" word entirely and just use Promises/A+ terminology, i.e. fulfilled/rejected + resolve/reject.)

slightlyoff commented 11 years ago

I'm back to thinking that the right solution here is a magical .resolve() and explicit, non-magical .accept() and .reject().

In this scenario, .resolve() is written as:

resolverInstance.resolve = function(value) {
  if (isThenable(value)) {
    value.then(this.accept, this.reject);
    return;
  }
  if (value instanceof Error) {
    this.reject(value);
  }
  this.accept(value);
}.bind(resolverInstance);
domenic commented 11 years ago

@slightlyoff The Error special-casing breaks the symmetry between resolve and return.

Also, I think you want, both in resolve and in the return algorithm,

value.then(this.resolve, this.reject);

instead of

value.then(this.accept, this.reject);
erights commented 11 years ago

@slightlyoff If by "accept" you now mean what you meant by "acceptDirectly", then your assimilation semantics is wrong. If value is thenable, then it must be called as

value.then(this.resolve, this.reject);

Resolving to an Error is perfectly well defined and must result in a promise fulfilled with that Error. We've been over this.

If value is a promise and you rely only on this way of treating it as a thenable, then you've broken promise pipelining. All messages to the outer promise will queue up locally waiting for the inner promise to resolve/settle, forcing more round trips.

Altogether, I don't get this. Also, I had hoped we were converging. Now I fear we are not. Do you think we're making progress or only thrashing?

jakearchibald commented 11 years ago

I'm warming to the idea of keeping accept/reject as opposites, but having an equivalent to the "return" behaviour, named something that sounds result-ambiguous, resolve sounds too positive but I can't think of anything better, although the difficulty in naming is probably indicative of its complexity.

slightlyoff commented 11 years ago

@domenic : the direct return analog is accept() here. If you want directness, use the Very Fine Low Level API.

domenic commented 11 years ago

@slightlyoff Then you have a problem of mismatch between the verb and the state. In particular, accept(rejectedFuture) will put you in the rejected state, and accept(foreverPendingFuture) will put you in the pending state (forever). IMO accept(x) should be the Very Fine Low Level API that does a verbatim, non-polymorphic transition to the accepted state, with x as the acceptance value.

erights commented 11 years ago

On Tue, Feb 19, 2013 at 7:29 AM, Alex Russell notifications@github.comwrote:

@domenic https://github.com/domenic : the direct return analog is accept() here. If you want directness, use the Very Fine Low Level API.

I don't get the joke. What's the "Very Fine Low Level API"?

— Reply to this email directly or view it on GitHubhttps://github.com/slightlyoff/DOMFuture/issues/26#issuecomment-13778134.

Text by me above is hereby placed in the public domain

Cheers, --MarkM

erights commented 11 years ago

What "accepted" state? I thought we'd agreed to rename this "fulfilled" to avoid precisely the confusion that started this thread.

On Tue, Feb 19, 2013 at 7:44 AM, Domenic Denicola notifications@github.comwrote:

@slightlyoff https://github.com/slightlyoff Then you have a problem of mismatch between the verb and the state. In particular, accept(rejectedFuture) will put you in the rejected state, and accept(foreverPendingFuture) will put you in the pending state (forever). IMO accept(x) should be the Very Fine Low Level API that does a verbatim, non-polymorphic transition to the accepted state, with x as the acceptance value.

— Reply to this email directly or view it on GitHubhttps://github.com/slightlyoff/DOMFuture/issues/26#issuecomment-13779079.

Text by me above is hereby placed in the public domain

Cheers, --MarkM

domenic commented 11 years ago

What "accepted" state? I thought we'd agreed to rename this "fulfilled" to avoid precisely the confusion that started this thread.

Ah, I hadn't seen that agreement, but if so, that would clarify things.

slightlyoff commented 11 years ago

@domenic : I think perhaps you misunderstood my suggestion: accept(rejectedFuture) puts you in the accepted state, whereas resolve(rejectedFuture) would put you in the rejected state. So I was suggesting exactly what you want here = )

slightlyoff commented 11 years ago

After more discussion with @domenic yesterday, I (and @arv) are willing to go for resolve() doing forwarding but not Error detection. In this world, resolve is written as:

resolverInstance.resolve = function(value) {
  if (isThenable(value)) {
    value.then(this.resolve, this.reject);
    return;
  }
  this.accept(value);
}.bind(resolverInstance);

We have an open discussion to have about how to implement isThenable() in sane way and spec it, but will defer to a different thread.

Sooo...are we ok with the names accept() and resolve() if they have this behavior? @erights? @domenic?

domenic commented 11 years ago

Um,

value.then(this.accept, this.reject);

should be

value.then(this.resolve, this.reject);

? Any reason why not?

slightlyoff commented 11 years ago

Ah, true.

/me edits

...fixed

domenic commented 11 years ago

In that case, all is well.

erights commented 11 years ago

The names of the operations must be co-designed with the names of the states. @slightlyoff , given the names "accept" and "resolve" with approximately the meaning you show, what do you propose for the names of the states? (I think I know the answer, but I'd like to check such assumptions before agreeing.)

erights commented 11 years ago

@slightlyoff, you closed this issue before answering my question about the state names.

slightlyoff commented 11 years ago

The states are: pending, accepted, and rejected. A future which is subject to forwarding is in the pending state. It is possible, however, to distinguish this state from an entirely un-resolved future; i.e., one for which not even resolve() has been called. To enable this, I've added an isResolved boolean to the resolver.

So, to recap: resolution vs accept/reject is something that only resolvers can see and only resolvers can influence. Once a final value is given, that and only that is visible via the states and properties. While in the forwarding state or in any other unfulfilled state, the state is pending.

Is there something objectionable about that which I missed?

erights commented 11 years ago

Is there something objectionable about that which I missed?

Not that I see. Given your other terminology choices, that is what I was hoping for. +1. I especially like that you can't tell the difference between pending and resolved-to-pending (nee forwarding-to-pending) from the promise end, but rather only the used-up resolver end.

Thanks for seeing this through to a mutually satisfactory resolution!

erights commented 11 years ago

As far as I am concerned, you may now close this issue.

erights commented 11 years ago

We should also adopt the other aspect of @domenic's suggestion that goes along with this repurposing of the "resolved" state: The or-state of being "accepted" or "rejected" is "settled". Experience shows that we do need a name for this, and "settled" seems fine.

@slightlyoff On rereading "to recap: resolution vs accept/reject is something that only resolvers can see", I'm not sure we're in quite as much agreement as we thought at first. Do you agree with the following statements:

If promise p is "accepted" or "rejected", then it must be "resolved". If promise p1 is resolved to a promise p2 which is pending, then p1 is both pending and resolved. If p2 then becomes accepted or rejected, then p1 likewise becomes accepted or rejected. In this case, p1 also remains resolved, and p2 becomes resolved.

Perhaps not only the API but also our terminology should speak of "resolved" purely as part of the resolver's state, rather than an aspect of the promise's state?

Do these sound right to you?

@domenic, @kriskowal, do you (still?) find these name choices fine for Q?

domenic commented 11 years ago

@erights I don't really see the potential disagreement. I agree with all your statements but think "resolution vs accept/reject is something that only resolvers can see" is still correct. It could be phrased, using the "settled" terminology, as "a promise being resolved vs being settled is something that only the promise's resolver can see".

do you (still?) find these name choices fine for Q?

I am glad we settled on a shared---and sane!---definition for "resolved," but I would guess that Q and the rest of the promise libraries will be sticking with "fulfilled." (@kriskowal, of course, has the final say in Q.) It will just be the DOMFuture promise library's choice to use "accept" and "accepted" instead of the commonly-used and understood terminology, and hopefully people won't have to deal with that too much.

Indeed, the only place consumers would need to worry about learning this new vocabulary is if they manually check the state property, which they shouldn't be doing often. Tutorials and articles can still use "fulfilled" when talking about the DOMFuture implementation of promises. (Again, as long as they don't talk about the state property, in which case they can mention that you check if a DOMFuture promise is fulfilled by checking if domFuture.state === "accepted".)

slightlyoff commented 11 years ago

I agree with: "a promise being resolved vs being settled is something that only the promise's resolver can see".

We can open a new ticket for "accepted" vs. "settled" in .state.