Original: https://staltz.com/promises-are-not-neutral-enough.html

Staltz is the core developer of CycleJS and Callbag. Mr. He has provided a comprehensive rebuttal to this article, which is provided here.


The problems that Promise created affected the entire JS ecosystem! This article will address some of these issues.

This might make you think I’m in a bad mood over Promise, swearing at my computer and then planning to vent online. Actually, no, I wrote this post just after I made my coffee this morning when someone asked me on Twitter what I thought of Promise. I was drinking coffee and thinking, and then I sent him a few tweets back. Some people replied that it would be better to write a blog, which led to this article.

The main purpose of a Promise is to indicate a value that you will eventually get (hereafter referred to as the final value). This value might be available in the next Event loop, or a few minutes later. There are many other primitives for the same purpose, such as callbacks, tasks in C#, futures in Scala, observables in RxJS, and so on. Promise in JS is just one of these primitives.

While these primitives all do this, JS’s Promise was an opinionated solution that created a lot of strange problems. These issues in turn cause JS syntax and other issues in the ecosystem. I think Promise is not neutral enough, and its opinionated behavior is shown in the following four places:

  • Execute immediately instead of later
  • Do not interrupt
  • Unable to synchronize execution
  • Then () is actually a mix of map() and flatMap()

Execute immediately, not later

When you create a Promise instance, the task is already executed, as in the following code:

console.log('before');
const promise = new Promise(function fn(resolve, reject) {
  console.log('hello');
  // ...
});
console.log('after');
Copy the code

You’ll see before, Hello, and after in the console. This is because the function fn that you passed to the Promise is executed immediately. You can see it a little bit better if I twist fn out by itself:

function fn(resolve, reject) {
  console.log('hello');
  // ...
}

console.log('before'); const promise = new Promise(fn); // fn is executed immediately! console.log('after');
Copy the code

So Promise will execute its task immediately. Note that in the code above, we haven’t even used this Promise instance, that is, promise.then() or the rest of the Promise API. Simply creating a Promise instance executes the tasks in the Promise immediately.

And it’s important to understand that because

  1. Sometimes you don’t want the Promise to start right away
  2. Sometimes you want a reusable asynchronous task, but promises only perform the task once, so once a Promise instance is created, you can’t reuse it.

The usual way to solve this problem is to write the Promise instantiation process in a function:

function fn(resolve, reject) {
  console.log('hello');
  // ...
}

console.log('before'); const promiseGetter = () => new Promise(fn); // fn does not immediately execute console.log('after');
Copy the code

Since the function can be called later, a “function that returns a Promise instance” (hereafter referred to as the Promise Getter) solves our problem. But here’s another problem. We can’t simply connect these Promise getters with.then(). To solve this problem, people usually write a.then() method to the Promise Getter, but this is to solve the Promise reusability and chained invocation problems. For example:

// getUserAge is a Promise Getterfunction getUserAge() {// fetch is a Promise Getterreturn fetch('https://my.api.lol/user/295712')
    .then(res => res.json())
    .then(user => user.age);
}
Copy the code

So Promise getters are actually better for combination and reuse. This is because the Promise Getter can be deferred. If promises were designed to be deferred from the start, we wouldn’t have to go through all this trouble:

const getUserAge = betterFetch('https://my.api.lol/user/295712')
  .then(res => res.json())
  .then(user => user.age);
Copy the code

The fetch task has not yet started after the above code has finished executing.

We can call getUserage.run (cb) to get the task executed. If you call getUserAge.run multiple times, multiple tasks will be executed and you’ll end up with multiple final values. Good job! That way we can reuse promises and do chain calls. (This is for Promise getters, because Promise getters can be reused, but not chained.)

Deferred execution is more general than immediate execution because immediate execution cannot be called repeatedly, whereas delayed execution can be called multiple times. Delayed execution puts no limit on the number of calls.

So I think immediate execution is more opinionated than delayed execution. Tasks in C# are similar to promises, except that C# tasks are deferred and have a.start() method, which Promise doesn’t.

For example, Promise is both a recipe and a product. It’s not scientific that you have to eat the recipe when you eat it.

Do not interrupt

Once you create an instance of a Promise, the Promise’s tasks are immediately executed, and worse, you can’t stop them from being executed. So do you still want to create a Promise instance? This is a road of no return.

I think the “uninterruptible” nature of Promise has a lot to do with its “execute now” nature. Here’s a good example:

var promiseA = someAsyncFn(); var promiseB = promiseA.then(/* ... * /);Copy the code

Suppose we can interrupt tasks using promiseb.cancel (), should the promiseA task be interrupted? If you think you can interrupt, consider the following example:

var promiseA = someAsyncFn(); var promiseB = promiseA.then(/* ... * /); var promiseC = promiseA.then(/* ... * /);Copy the code

If the task is interrupted with promiseb.cancel (), the task should not be interrupted because promiseC relies on promiseA.

It is the “execute now” mechanism that complicates the upward propagation of Promise task interrupts. One possible solution is reference counting, but this solution has many boundary cases and even bugs.

If the Promise is deferred and the.run method is provided, then things become simple:

var execution = promise.run(); // Execution. Cancel () after some time;Copy the code

The execution returned by promise.run() is a traceback chain of tasks, each of which creates its own execution. If we call executionc.cancel (), then executiona.Cancel () is automatically called, and executionB has an executionA of its own, Has nothing to do with executionC’s executionA. So it’s possible to have multiple A tasks running at the same time, and that’s not A problem.

If you want to avoid multiple A tasks executing, you can add A shared method to the A task, which means we can “selectively use” reference counting instead of “forcing” reference counting. Note the difference between “selective use” and “coercive use.” If an action is “selective use,” it is neutral; If a behavior is “forced”, it is opinionated.

Going back to that weird recipe example, let’s say you order a dish at a restaurant, but a minute later you don’t want to eat it again. Promise does this by shovelling it down your throat, whether you want it or not. Because Promise thought you had to eat when you ordered.

Unable to synchronize execution

Promise’s design strategy allows the earliest resolve time to be before the next Event loop phase, to facilitate race issues when multiple Promise instances are created at the same time.

console.log('before');
Promise.resolve(42).then(x => console.log(x));
console.log('after');
Copy the code

The code above prints ‘before’, ‘after’ and 42. No matter how you construct the Promise instance, you can’t make the function in then print 42 before ‘after’.

As a result, you can write sync code as promises, but there’s no way to change promises to sync code. This is an artificial limit, and if you look at callbacks there is no such limit, we can either write the synchronized code as a callback, or we can change the callback to the synchronized code. Take forEach for example:

console.log('before');
[42].forEach(x => console.log(x));
console.log('after');
Copy the code

This code prints ‘before’ 42 and ‘after’ all at once.

Since we can’t rewrite promises into synchronous code, once we use a Promise in our code, it makes all the code around it Promise based code, even if it doesn’t make sense.

I understand that asynchronous code makes the code around it asynchronous, but Promise forces the code around synchronous code to be asynchronous as well. Here’s another opinionated Promise. A neutral scheme should not enforce synchronous or asynchronous data transfer.

I think of promises as lossy abstraction, like lossy compression, when you put something in a Promise and then take it out of a Promise, it’s not the same thing it was before.

Imagine ordering a burger at a fast food chain. The waiter immediately brings out a ready-made burger and hands it to you, only to reach over and find that the server is holding on so tightly that it won’t give it to you. He just looks at you and starts counting down to three seconds before he lets go. You grab your burger and walk out of the diner to get away from this weird place. I don’t know. They just want you to wait before you get your food, just in case.

Then () is actually a mix of map() and flatMap()

When passing a callback to THEN, your callback function can return either a regular value or a Promise instance. Interestingly, the effect is exactly the same.

Promise.resolve(42).then(x => x / 10); Promise.resolve(42). Then (x => promise.resolve (x / 10));Copy the code

To prevent a Promise from repeating a Promise, then(T => U) is converted to a Promise instance if it returns a normal value: Then (T => Promise): Promise.

In a way, it helps you to do that, because if you don’t know the details, it will take care of itself. Assuming that Promise could have provided map, Flatten, and flatMap methods, we had to use the THEN method to handle all the requirements. Did you see the Promise limits? I was limited to using THEN, a simplified version of the API that did some automatic conversions, and it was impossible for me to do much more control than I wanted.

A long time ago, when Promises were first introduced into the JS community, some people toyed with the idea of adding map and flatMap methods to promises, as you can see in this discussion. But those involved in the syntax argue against them, citing category theory and functional programming.

I don’t want to talk too much about functional programming in this article, but let me just say that it’s almost impossible to create a neutral programming primitive without following the math. Math is not a subject that has nothing to do with actual programming, and the concepts in math have real meaning, so if you don’t want to create something that contradicts itself, maybe you should learn more about math.

The main focus of this discussion is why you can’t make promises with methods like Map, flatMap, and concat. A lot of other primitives have these methods, like arrays, and if you’ve ever used ImmutableJS you’ll see that it has them too. Map, flatMap, and concat really work.

Imagine how cool it would be to write code that calls map, flatMap, and concat, regardless of what primitives they are. As long as the input source has these methods. This makes testing easier because I can mock the array directly without having to construct some HTTP requests. If you use ImmutableJS in your code or asynchronous apis in your production environment, you can just simulate with arrays in your test environment. The term “generic” in functional programming, “Type class programming,” monad, etc., has a similar meaning in that we can give different primitives the same batch of method names. It would be annoying if the method name of one primitive was concat and the method name of the other primitive was concatenate, but essentially they did almost the same thing.

So why not think of promises as more like arrays, with concat, map, etc. Promises can basically be mapped, so add a map method to promises; Promises can basically be chained, so add the flatMap method to the Promise.

Unfortunately, that wasn’t the case. Promise squeezed map and flatMap into THEN and added some automatic conversion logic. This was done only because map and flapMap looked similar, and they thought it was unnecessary to write two methods.

conclusion

Well, promises work, you can use promises to get your business done and everything works fine. There’s no need to panic. Promise just seems a little weird and unfortunately opinionated. They impose rules on promises that sometimes make no sense. This is not a problem because we can easily get around the rules.

Promises are hard to reuse, but we can do that with extra functions; The Promise can’t be broken. It doesn’t matter. We can continue tasks that should have been broken, just waste some resources.

It’s annoying that we always have to tinker with Promise; It’s annoying that all the new apis are based on promises and we even invented a syntactic sugar for promises: async/await.

So we’ll all have to live with Promise’s quirks for the next few years. If we had incorporated delayed execution into promises from the start, promises might have been a different story.

What if Promise was originally designed to think mathematically? Here I give two examples: Fun-Task and Avenir, both of which are lazy and have a lot in common, but the main differences are in naming and method accessibility. Both of these libraries are less opinionated than Promise because they:

  1. Delay the
  2. Allow synchronization
  3. Allow the interrupt

Promise was invented, not discovered. The best primitives are found, and because these primitives are neutral, we cannot refute them. For example, the circle is such a mathematical concept that it cannot be refuted, so it is said that man discovered the circle, not invented it. Since the circle is neutral, no subjective restrictions are imposed, so you have no way to disprove a circle. And circles are everywhere.