mdn / content

The content behind MDN Web Docs
https://developer.mozilla.org
Other
9.2k stars 22.48k forks source link

How come callbacks don't have guarantees like Promises? #22095

Closed AnupamKhosla closed 2 years ago

AnupamKhosla commented 2 years ago

MDN URL

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises

What specific section or headline is this issue about?

Guarantees

What information was incorrect, unhelpful, or incomplete?

I am asking more of a question, the documentation mentions three Guarantees I have seen the first one in action, e.g. this SO post explains that, but the second and third gaurantee don't make sense,

These callbacks will be invoked even if they were added after the success or failure of the asynchronous operation that the promise represents.

Well how does a callback doesn't have this guarantee?

Multiple callbacks may be added by calling then() several times. They will be invoked one after another, in the order in which they were inserted.

The article itself gives examples of multiple callbacks via nesting, which execute one after the other.

What did you expect to see?

I expect the second and third guarantee to be valid for old-fashioned callbacks as well.

Do you have any supporting links, references, or citations?

No.

Do you have anything more you want to share?

No.

MDN metadata

Page report details * Folder: `en-us/web/javascript/guide/using_promises` * MDN URL: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises * GitHub URL: https://github.com/mdn/content/blob/main/files/en-us/web/javascript/guide/using_promises/index.md * Last commit: https://github.com/mdn/content/commit/eada29e0774d505becb3a725001d372f0dbdc73d * Document last modified: 2022-09-13T08:52:08.000Z
Josh-Cena commented 2 years ago

Promise is a language-specified feature, so you generally have more well-behaving semantics than a random API from a random library. It's not to say callbacks can't have these guarantees—it's that they don't necessarily do. Promises set stronger developer contracts than many callback-based APIs at the time (which was more than 10 years ago).

Callbacks added with then() will never be invoked before the completion of the current run of the JavaScript event loop.

Consider this:

function doSomething(cb) {
  if (Math.random() > 0.5) {
    cb();
  } else {
    setTimeout(() => cb(), 1000);
  }
}

This function is a notorious example of the Zalgo, because the callback may be synchronous, and may be asynchronous—you may never know. So the following is very hard to reason about:

let x = 1;
doSomething(() => {
  x = 2;
});
console.log(x); // 1 or 2? We may never know...

This doesn't happen with promises, since the call order is always deterministic, and what should be async stays async.

These callbacks will be invoked even if they were added after the success or failure of the asynchronous operation that the promise represents.

Again, not to say callbacks can't have this guarantee—just that they don't need to.

Multiple callbacks may be added by calling then() several times. They will be invoked one after another, in the order in which they were inserted.

Callback nesting means the first handler has to know the existence of the second handler. This is not a problem if the entire code is written by you; but in case you are passing callbacks defined elsewhere, it's more convenient if you have a fluent-API-like interface:

doSomething().then(handleA).then(handleB).then(handleC);

doSomething((a) => {
  handleA(a, (b) => {
    handleB(b, (c) => {
      handleC(c);
    });
  });
});

Not to mention the ugly callback hell this creates and how intractable error handling would be. Hence why this paragraph is directly followed by chaining.

AnupamKhosla commented 2 years ago

@Josh-Cena Great example with Math.random to show the importance of how Promise gives the certainty for the order of execution of things. But I have questions regarding guarantee2, guarantee3 and your last example.

Firstly you say, "not to say callbacks can't have this guarantee—just that they don't need to" -- My point is callbacks always have this guarantee. MDN should choose better wording for Unlike old-fashioned... I think the old-fashioned callback does come with this guarantee. E.g. guarantee2:
adding a callback after the success/failure of an async operation:

let fooComplete = false
function foo(cb) {  
  if( cb != undefined && fooComplete != false) {
    cb();
  }
  //do task1 async work and when asyn work succeeds switch flag
  document.addEventListener("MyAsyncFinish", function(){
    fooComplete = true;
    if(cb != undefined) {
      cb();
    }
  });
}
foo(); //cb will be called later  
// async task succeeds say in 2secs  
//later in the code after some timeout says5 secs  
//...  
foo(cb); //cb is guaranteed to execute after first task

There is no way the callback won't execute after the success of the async work.

Then you mention ...but in case you are passing callbacks defined elsewhere, what's wrong with that

foo(cb_from_lib) {
  //bla bla  
 cb_from_lib();
}

Do the then functions provide more readable and managable syntax/code, yes!. But this is not what MDN is saying, they are saying the old-fashioned callbacks don't have this guarantee3. I don't see how.

doSomething((a) => {
  handleA(a, (b) => {
    handleB(b, (c) => {
      handleC(c);
    });
  });
});

handleA --> handleB --> handleC the order is guaranteed to be followed, contrary to MDN's claim.

Josh-Cena commented 2 years ago

My point is callbacks always have this guarantee. MDN should choose better wording for Unlike old-fashioned... I think the old-fashioned callback does come with this guarantee.

Again, my point is not that you can have well-behaving callback-based APIs (which all your examples here are). My point is that it's entirely possible for you to design messed-up callback APIs. This means users have to do the additional verification work that the callback always behaves sanely. For example, let me give you a bad API design:

let done = false;

function doSomething(cb) {
  if (!done) {
    doSomethingImpl((val) => {
      done = true;
      cb(val);
    });
  } else {
    console.log("Why do you want to do it again?");
  }
}

doSomething((val) => {
  doSomething(() => {});
});

(This is not an entirely adequate example—in typical callbacks, you don't have the ability to attach extra callbacks to a running task at all; another call to doSomething should initiate a new task. But let's assume that it does happen.)

Would someone in their right mind do this? Maybe not. But can you be certain that it never happens? You cannot, without (a) trusting the library author (b) reading their source (c) reading their documentation (and assuming they bother to document every aspect of the behavior).

In promises, this can never happen. As long as the library returns you a promise instance, you know with certainty what happens when you register a callback. This is because the job of maintaining the callback queue and deciding when to call them is delegated to the promise implementation, rather than manually implemented by the author.

Instead of telling you how bad callback APIs are, this section is trying to tell you how good promise is. The reason is, the developer of the API does not care when the callbacks are called—all the plumbing details of when, whether, and how callbacks are called are maintained by the promise itself, and both the user and developer automatically gets strong semantic guarantees.

Josh-Cena commented 2 years ago

I'll see what I can do to rewrite this page—the page structure doesn't look that clean in general.