Understanding the Promises / A + Specification

Promises / A + is one of the smallest specifications. Therefore, introducing it is the best way to understand it. The following Forbes Lindesay answer tells us about the implementation process for the Promises / A + specification, A simple implementation of the Javascript implementation . However, when I tested , its results were not satisfactory:

✔ 109 tests passed ✘ 769 tests failed 

Obviously, the Promises / A + specification is not as simple as it seems. How would you implement the specification and explain your code to a beginner? Forbes Lindesay does an excellent job explaining its code, but unfortunately its implementation is incorrect.

+2
source share
1 answer

What is a promise?

A promise is thenable whose behavior complies with the Promises / A + specification.

A thenable is any object or function that has a then method.

What promise looks like here:

 var promise = { ... then: function (onFulfilled, onRejected) { ... }, ... }; 

This is the only thing we know about the promise from the very beginning (excluding its behavior).

Understanding the Promises / A + Specification

The Promises / A + specification is divided into 3 main parts:

  • Promise Terms
  • then method
  • Promise Resolution Procedure.

The specification does not mention how to create, execute, or reject promises.

Therefore, we will start by creating these functions:

 function deferred() { ... } // returns an object { promise, resolve, reject } function fulfill(promise, value) { ... } // fulfills promise with value function reject(promise, reason) { ... } // rejects promise with reason 

Although there is no standard way to create a promise, tests require that we still show the deferred function. Therefore, we will use deferred only to create new promises:

  • deferred() : creates an object consisting of { promise, resolve, reject } :

    • promise is a promise that is currently pending.
    • resolve(value) resolves a promise using value .
    • reject(reason) transfers the promise from the pending state to the rejected state with the reason for rejecting reason .

A partial implementation of the deferred function is executed here:

 function deferred() { var call = true; var promise = { then: undefined, ... }; return { promise: promise, resolve: function (value) { if (call) { call = false; resolve(promise, value); } }, reject: function (reason) { if (call) { call = false; reject(promise, reason); } } }; } 

NB

  • The promise object has a then property, which is currently undefined . We still need to decide what the then function should be and what other properties the promise object should have (i.e., the shape of the promise object). This decision will also affect the implementation of the fulfill and reject functions.
  • The functions resolve(promise, value) and reject(promise, value) must be called only once, and if we call them, we cannot call another. Therefore, we close them by closing and guarantee that they will be called only once between them.
  • We introduced a new function in the definition of deferred , the procedure for resolving promises resolve(promise, value) . The specification designates this function as [[Resolve]](promise, x) . The implementation of this function is completely dictated by the specification. Therefore, we implement it as follows.
 function resolve(promise, x) { // 2.3.1. If promise and x refer to the same object, // reject promise with a TypeError as the reason. if (x === promise) return reject(promise, new TypeError("Self resolve")); // 2.3.4. If x is not an object or function, fulfill promise with x. var type = typeof x; if (type !== "object" && type !== "function" || x === null) return fulfill(promise, x); // 2.3.3.1. Let then be x.then. // 2.3.3.2. If retrieving the property x.then results in a thrown exception e, // reject promise with e as the reason. try { var then = x.then; } catch (e) { return reject(promise, e); } // 2.3.3.4. If then is not a function, fulfill promise with x. if (typeof then !== "function") return fulfill(promise, x); // 2.3.3.3. If then is a function, call it with x as this, first argument // resolvePromise, and second argument rejectPromise, where: // 2.3.3.3.1. If/when resolvePromise is called with a value y, // run [[Resolve]](promise, y). // 2.3.3.3.2. If/when rejectPromise is called with a reason r, // reject promise with r. // 2.3.3.3.3. If both resolvePromise and rejectPromise are called, // or multiple calls to the same argument are made, // the first call takes precedence, and any further calls are ignored. // 2.3.3.3.4. If calling then throws an exception e, // 2.3.3.3.4.1. If resolvePromise or rejectPromise have been called, ignore it. // 2.3.3.3.4.2. Otherwise, reject promise with e as the reason. promise = deferred(promise); try { then.call(x, promise.resolve, promise.reject); } catch (e) { promise.reject(e); } } 

NB

  • We omitted section 2.3.2 because it is an optimization that depends on the shape of the promise object. We will return to this section at the end.
  • As you can see above, the description of section 2.3.3.3 is much larger than the actual code. This is because of the smart hack promise = deferred(promise) , which allows us to reuse the logic of the deferred function. This ensures that promise.resolve and promise.reject can only be called once between them. We just need to make a small change to the deferred function to make this hack work.
 function deferred(promise) { var call = true; promise = promise || { then: undefined, ... }; return /* the same object as before */ } 

Promise Status and then Method

We have delayed the problem of solving the form of the promise object for so long, but we cannot postpone it further, because it depends on functions performed by both fulfill and reject functions. It's time to read what the spec has to say about promise states:

A promise must be in one of three states: expected, fulfilled, or rejected.

  • Pending promise:
    • can go into either completed or rejected state.
  • When fulfilled, the promise:
    • should not go into another state.
    • should have a value that should not change.
  • When rejecting a promise:
    • should not go into another state.
    • must have a reason that should not change.

Here, “should not change” means immutable identity (ie === ), but does not mean profound immutability.

How do we know the state of the present promise? We could do something like this:

 var PENDING = 0; var FULFILLED = 1; var REJECTED = 2; var promise = { then: function (onFulfilled, onRejected) { ... }, state: PENDING | FULFILLED | REJECTED, // vertical bar is not bitwise or ... }; 

However, there is a better alternative. Since the state of a promise can be observed only through it then (i.e., depending on the state of the promise, the then method behaves differently), we can create three specialized then functions that correspond to three states:

 var promise = { then: pending | fulfilled | rejected, ... }; function pending(onFulfilled, onRejected) { ... } function fulfilled(onFulfilled, onRejected) { ... } function rejected(onFulfilled, onRejected) { ... } 

In addition, we need another property to hold promise data. When a promise is waiting for data, it is the onFulfilled and onRejected . When a promise is fulfilled, the data is the magnitude of the promise. When a promise is rejected, data is the reason for the promise.

When we create a new promise, the initial state is pending, and the original data is an empty queue. Therefore, we can complete the deferred function as follows:

 function deferred(promise) { var call = true; promise = promise || { then: pending, data: [] }; return /* the same object as before */ } 

In addition, now that we know the shape of the promise object, we can finally implement the fulfill and reject functions:

 function fulfill(promise, value) { setTimeout(send, 0, promise.data, "onFulfilled", value); promise.then = fulfilled; promise.data = value; } function reject(promise, reason) { setTimeout(send, 0, promise.data, "onRejected", reason); promise.then = rejected; promise.data = reason; } function send(queue, callback, data) { for (var item of queue) item[callback](data); } 

We need to use setTimeout because, according to section 2.2.4 of the onFulfilled or onRejected you should not call it until the execution context stack contains only platform code.

Then we need to implement the functions pending , fulfilled and rejected . We'll start with the pending function, which pushes the onFulfilled and onRejected into the queue and returns a new promise:

 function pending(onFulfilled, onRejected) { var future = deferred(); this.data.push({ onFulfilled: typeof onFulfilled === "function" ? compose(future, onFulfilled) : future.resolve, onRejected: typeof onRejected === "function" ? compose(future, onRejected) : future.reject }); return future.promise; } function compose(future, fun) { return function (data) { try { future.resolve(fun(data)); } catch (reason) { future.reject(reason); } }; } 

We need to check whether onFulfilled and onRejected functions, since according to section 2.2.1 the specifications are optional arguments. If onFulfilled and onRejected are indicated, they are composed with a deferred value according to section 2.2.7.1 and section 2.2.7.2 of the specification. Otherwise, they are shorted in accordance with section 2.2.7.3 and section 2.2.7.4 of the specification.

Finally, we implement the fulfilled and rejected functions as follows:

 function fulfilled(onFulfilled, onRejected) { return bind(this, onFulfilled); } function rejected(onFulfilled, onRejected) { return bind(this, onRejected); } function bind(promise, fun) { if (typeof fun !== "function") return promise; var future = deferred(); setTimeout(compose(future, fun), 0, promise.data); return future.promise; } 

Interestingly, promises are monads , as seen in the aptly named bind function above. At the same time, our implementation of the Promises / A + specification is complete.

unit functions

If promises are monads, then they must also have a unit function. Surprisingly, promises have two single functions: one for an allowed promise and one for a rejected promise. Since tests require them anyway, we will add them to the export interface:

 exports.resolved = function (data) { return { then: fulfilled, data: data }; }; exports.rejected = function (data) { return { then: rejected, data: data }; }; exports.deferred = deferred; 

resolve optimization

Section 2.3.2 of the specification describes the optimization for the resolve(promise, x) function when x is defined as a promise. Here's the optimized resolve function:

 function resolve(promise, x) { if (x === promise) return reject(promise, new TypeError("Self resolve")); var type = typeof x; if (type !== "object" && type !== "function" || x === null) return fulfill(promise, x); try { var then = x.then; } catch (e) { return reject(promise, e); } if (typeof then !== "function") return fulfill(promise, x); // 2.3.2.1. If x is pending, promise must remain pending until x is // fulfilled or rejected. if (then === pending) return void x.data.push({ onFulfilled: function (value) { resolve(promise, value); }, onRejected: function (reason) { reject(promise, reason); } }); // 2.3.2.2. If/when x is fulfilled, fulfill promise with the same value. if (then === fulfilled) return resolve(promise, x.data); // 2.3.2.3. If/when x is rejected, reject promise with the same reason. if (then === rejected) return reject(promise, x.data); promise = deferred(promise); try { then.call(x, promise.resolve, promise.reject); } catch (e) { promise.reject(e); } } 

Please note that although the specification states “If / when x is fulfilled, fulfill the promise with the same value”, we should use resolve instead of fulfill . I am not sure if this is a mistake in the specification.

Putting it all together

The code is available as a gist . You can simply download it and run the test package:

 $ npm install promises-aplus-tests -g $ promises-aplus-tests promise.js 

Needless to say, all tests pass.

+8
source

All Articles