- From: Mark S. Miller <erights@google.com>
- Date: Fri, 10 May 2013 05:52:32 -0700
- To: Jonas Sicking <jonas@sicking.cc>
- Cc: "Tab Atkins, Jr." <jackalmage@gmail.com>, Domenic Denicola <domenic@domenicdenicola.com>, "public-script-coord@w3.org" <public-script-coord@w3.org>
- Message-ID: <CABHxS9jAV8DPVHYHnA6VOj4eLOEN=KyKLFru-anYGmiEjWxy+A@mail.gmail.com>
I think the key exchange in these threads was On Fri, May 3, 2013 at 4:17 PM, Jonas Sicking <jonas@sicking.cc> wrote: [...] > I.e. permitting nested promises creates a more complex model and with > that you always get more confusion and more questions. > On Sat, May 4, 2013 at 1:48 AM, Claus Reinke <claus.reinke@talk21.com> wrote: [...] > From the perspective of a-promise-is-just-like-other-**wrapper-classes, > auto-flattening promises creates a more complex model Together with each of their explanations about what they meant. They are both right. Lifting and auto-lifting do not mix. Q Promises give us autolifting with no lifting. Monadic promises would give us lifting but no autolifting. Having both in one system creates a mess which will lead programmers into the particular pattern of bugs Jonas warns about in his message. For clarity I define the following APIs so I can define the three architectural choices in terms of the subset of these APIs they contain. Obviously, I do not intend to start a bikeshed yet on the particular names chosen for these operations. Let's stay focused on semantics, not terminology. An upper case type variable, e.g. T, is fully parametric. It may be a promise or non-promise. A lower case type variable, e.g. t, is constrained to be a non-promise. If you wish to think in conventional type terms, consider Any the top type immediately split into Promise and non-promise. Thus type parameter t is implicitly constrained to be a subtype of non-promise. Ref<T> is the union type of T and Promise<T>. Q.fulfill(T) -> Promise<T> // the unconditional lift operator Q(Ref<t>) -> Promise<t> // the autolift operator p.map: Promise<T> -> (T -> U) -> Promise<U> p.flatMap: Promise<T> -> (T -> Promise<U>) -> Promise<U> // If the onSuccess function returns a non-promise, this would throw an Error, // so this type description remains accurate for the cases which succeed. p.then: Promise<T> -> (T -> Ref<u>) -> Promise<u> // Ignoring the onFailure callback * A Monadic promise system would have Q.fulfill, p.map, and p.flatMap. * A Q-like promise system would have Q, and p.then * The dominant non-Q-like proposal being debated in these threads has Q.fulfill, Q, and p.then. This note explains why I believe the last is much worse than either of the other two choices. As Jonas points out, generic code, to be useful in this mixed system, has to be careful and reliable about how much it wraps or unwraps the payloads it handles generically. Had programmers been armed with .map and .flatMap, they could succeed reliably. Arming them only with .then will lead to the astray. As an example, let's start with a variant of Sam's async table abstraction. The parts in angle brackets or appearing as type declarations only documents what we imagine the programmer may be thinking, to be erased to get the real code they wrote. In this abstraction, only promises for keys are provided. The get operation immediately returns a promise for the value that will have been set. (An interesting variant is a table that works even when the get arrives first. But we can ignore this wrinkle here.) class AsyncTable<T,U> { constructor() { this.m = Map<T,U>(); // encapsulation doesn't matter for this example } set(keyP :Promise<T>, val :U) { keyP.then(key => { this.m.set(key, val) }); } get(keyP :Promise<T>) :Promise<U> { return keyP.then(key => this.m.get(key)); } } When U is a non-promise, the code above works as intended. The .then in the .get method was written to act as .map. When tested with U's that are not promises, it works fine. But when U is actually Promise<V>, the .get method above returns Promise<V> rather than Promise<U>. As far as the author is concerned, the .then is functioning as a broken .map in this case, with the signature p.then: Promise<T> -> (T -> U) -> Promise<V> // WTF? This same abstraction in a monadic promise system would be written class AsyncTable<T,U> { constructor() { this.m = Map<T,U>(); // encapsulation doesn't matter for this example } set(keyP :Promise<T>, val :U) { keyP.map(key => { this.m.set(key, val) }); } get(keyP :Promise<T>) :Promise<U> { return keyP.map(key => this.m.get(key)); } } This works reliably. This same abstraction in a Q-like promise system would be written class AsyncTable<t,u> { constructor() { this.m = Map<t,u>(); // encapsulation doesn't matter for this example } set(keyP :Promise<t>, val :u) { keyP.then(key => { this.m.set(key, val) }); } get(keyP :Promise<t>) :Promise<u> { return keyP.then(key => this.m.get(key)); } } This works reliably. After erasure, note that this is exactly the same as the first example. The difference is that the code now correctly implements the annotated types representing the programmer's intention. In a system with autolifting, you can't get full parametricity of promises simply by adding an unconditional lift. You have to remove, or at least strongly discourage, autolifting. Or you have to take pains to carefully work around it. The main failure mode of standards bodies is to resolve a conflict by adding the union of the advocated features. Here, this works even worse than it usually does. The coherence of lifting depends on the absence of autolifting, *and vice versa*. We need to make a choice. -- Cheers, --MarkM
Received on Friday, 10 May 2013 12:52:59 UTC