scripting / Scripting-News

I'm starting to use GitHub for work on my blog. Why not? It's got good communication and collaboration tools. Why not hook it up to a blog?
121 stars 10 forks source link

JavaScript: promises vs callbacks #177

Open scripting opened 4 years ago

scripting commented 4 years ago

What do promises do/make possible that callbacks don't?

i've been writing large pieces of code in JavaScript for about seven years, all flow is via callbacks because:

  1. That's all that existed when I started.
  2. I like them, I think in terms of them.
  3. My editor is proficient at managing them.
  4. All this proficiency didn't come cheaply. A lot of time went into it.

I'm a big believer in this: one way of doing something is better than two, no matter how much better the second way is. Why? Because I'm going to end up supporting both.

Here's some space, please help me understand why you think promises is a game-changer. The more succinct the answer the better. Thanks.

andrewshell commented 4 years ago

Much of what you can do with promises you can do with callbacks. In fact the basic syntax of promises basically is callbacks. However async/await which uses promises under the hood is a killer feature which makes code much more readable (in my opinion). There are also methods to convert callback style asynchronous methods to promise style and vice versa.

For instance, if you take a look here:

https://github.com/andrewshell/rsscloud-server/blob/2.x/services/please-notify.js#L89-L113

Every function call that is prefixed by await is an asynchronous method that returns a promise. It then treats it like it's synchronous. In that case none of those methods have return values, but this example using the mongodb library (using promises) does:

https://github.com/andrewshell/rsscloud-server/blob/2.x/services/please-notify.js#L33-L41

I refrained from doing a full tutorial on promises and async/await because if you're interested you can google for tutorials, or I can hop on a zoom call and show you more. I just think this is the killer feature. I've seen you ask for ways to do asynchronous things synchronously because the syntax is cleaner. This is the solution.

There is also a built in method to convert a function that uses callbacks to one that returns a promise (so it can use async/await):

https://nodejs.org/dist/latest-v12.x/docs/api/util.html#util_util_promisify_original

scripting commented 4 years ago

Andrew, thanks for trying to answer the question, but after reading it -- the only answer I see is that it's much more readable. Is that it?

andrewshell commented 4 years ago

Basically :-)

scripting commented 4 years ago

Here's an example of a feature in a new version of JS that I have incorporated into my work, even though it means that my older code is out of synch with the newer code.

The feature is const. const tells the reader that this is a value that doesn't change.

It tells you something about the code at no cost to performance, size and it enhances readability.

jdelic commented 4 years ago

The primary tangible benefit is that in multiple sequential asynchronous operations, callbacks nest, where promises go in sequence. Promises also provide one stack trace for exceptions to bubble along.

doOperation1((result) => {
    // do something with result
    doOperation2(result, (result2) => {
        // do something with result2
        doOperation3(result2, (result3) => {
                // ...
            }, (err3) => {
                // handle doOperation3 error
            }),
        (err2) => {
            // handle doOperation2 error
        }),
    (err) => {
        // handle doOperation error
    }
);

Compared to

doOperation1()
    .then((result) => {
        doOperation2(result);
    })
    .then((result) => {
        doOperation3(result);
    })
    .catch((error) => {
        // any thrown exception of doOperation1, doOperation2, doOperation3 ends up here
        // we could add intermediate .catch handlers to handle errors lower on the stack
    });

Where @andrewshell is a bit imprecise is that async and await are built around generators and Promises, but he's correct in that these concepts make large-scale asynchronous programs much more readable and easier to reason about as they also provide syntactical sugar for code to 'yield' (as in "cooperative multitasking") until an awaited Promise resolves.

scripting commented 4 years ago

Another feature I use, even though on second thought it might not be that new is conditional assignment. It takes four lines of code and shrinks it to one. And it's four awkward lines of code, and if, then, else.

Previously you'd write:

if (i < 0) {
   i = 0;
   }
else {
   i = i * 2;
   }

The same idea can be communicated this way:

i = (i < 0) ? 0 : i * 2;

It isn't any harder to read, and it takes up less space. I'm sure the code that's generated is exactly the same.

scripting commented 4 years ago

Thing is, I can't come up with this kind of write-up for promises. It just seems to be another way to do what callbacks do without a benefit.

scripting commented 4 years ago

@jdelic -- I still don't see it. I find callbacks readable, after years of building with them, and coming to peace with the concept.

danmactough commented 4 years ago

What do promises do/make possible that callbacks don't?

Very little.

For "do", every time you create a promise, you have a "thing" (the promise) that you can do stuff with/to. You can pass it around, collect a bunch of them and iterate over them, stuff like that. Callbacks don't do that. Functions that take a callback return undefined. This is actually relevant to your i = (i < 0) ? 0 : i * 2; example:

function doSomething (i) {
    return new Promise(function (resolve, reject) {
        // do something async with i
        resolve(i);
    });
}

const p = (i < 0) ? doSomething() : Promise.reject(new Error("i is not less than 0"));

For "make possible", promises make it possible to use async/await syntax in your code, which, as others have mentioned, many people find more readable. Callbacks do not make that possible. I think this is what most people would call the "game-changer," but to me promises and async/await is more about the future of Javascript. Meaning: because promises are now a core part of the language, any asynchronous features that get added to Javascript in the future will probably be built on promises (see for example Fetch). And so to me, it seems inevitable that your code will need to interop with promises at some point. And as you say, one way of doing something is better than two -- especially in your own code. So, at that point, what do you do?

tedchoward commented 4 years ago

I didn't like Promises when I was first exposed to them either. It took me a while to grok them, and then even after that, I didn't see the benefit over callbacks either.

Then I ran into a situation that was solvable with Promises that would have been nearly impossible / really difficult with callbacks.

Basically, I call one service and get a list of ids back as the response. Then I iterate over that list calling a different service and passing each id in as a path parameter. Then want to "return" the results as an array.

Here's a potential solution with callbacks:

function getRecords(cb) {
    const results = [];

    request('http://users.service/', (err, users) => {
        if (err) {
            cb(err);
            return;
            }

        const userIds = users.map(u => u.id);
        let requestsCount = userIds.length;

        for (const userId of userIds) {
            request('http://other.service/users/' + userId, (err2, profile) => {
                if (err2) {
                    // TODO: not entirely sure how to handle errors here
                    // because of the loop
                    }

                results.push(profile);
                requestsCount -= 1;

                if (requestsCount == 0) {
                    cb(null, results);
                    }
                });
            }
        });
    }

Here's the solution using Promises:

function getRecords() {
    return requestPromise('http://users.service/users').then(users => {
        const userIds = users.map(u => u.id);
        const profilePromises = userIds.map(userId => requestPromise('http://profile.service/users' + userId));
        return Promise.all(profilePromises);
        });
    }
scripting commented 4 years ago

@tedchoward -- I have to solve that problem sometimes, it is a pain in the ass, here's an example.

function loadFromConfigList (callback) {
    var logarray = new Array ();
    function loadone (ix) {
        if (ix >= config.locations.length) {
            callback (logarray);
            }
        else {
            var loc = config.locations [ix], logtext;
            folderloader.load (loc.s3path, loc.folder, function (logtext) {
                if (logtext.length == 0) {
                    logtext = "No changes.";
                    }
                logarray.push (logtext);
                loadone (ix + 1);
                });
            }
        }
    loadone (0);
    }

I groan a little when I see it, but I write the code, and it works.

I've encountered hairier situations. Like handling include types when reading an OPML file. That practically made me blind. I'd be curious how it would be done with promises.

I'll see if I can find the code. ;-)

scripting commented 4 years ago

Here t'is..

https://github.com/scripting/opml/blob/master/daveopml.js

scripting commented 4 years ago

@danmactough -- my philosophy is this "if I have to then I will."

No matter how bad the other guy's API is, if I have to access the functionality, I do what I have to do. But not until I have to. ;-)

I wrote 6502 assembler to make my UCSD Pascal app on the Apple II perform acceptably. If I can do that, I can interface with promises.

I wish we could get Dropbox working on Linux instead. ;-)

scripting commented 4 years ago

I've written a summary of this discussion so far, here.

http://scripting.com/2020/06/15.html#a132940

I'm not going to paste the text in because I might edit it and I don't want to maintain the same text in two places.

LeadDreamer commented 4 years ago

Really, all promises give you (and I use them a lot) is a less convoluted code flow, which can reduce errors due to deep callback stacks, while creating errors due to mixing "Promise"-returning functions with value returning functions ( such as array function chaining and/or JQuery function-chaining). Promise-based can still lead to convoluted deep structures. You really also have to use Try-Catch blocks as well to get them under control.

The "newer" async/await can unloop the promises to a large degree, but they really are "just" promises "under the hood", which are just callbacks "under the hood". These create their own limitation, as you have to "remember" which values are static and which are pending while you read the code. Try-Catch applies here as well.

Oddly, a limitation of async/await kind of makes it easier to keep track of them: they can't be used in top-level code - i.e. places where you "obviously" need straight synchronous, flow-though code. You have to put the async function in some kind of function then declare an async function block within it. At least it makes it clear they're something different from normal flow - a chain of "awaits" can mask that.

Both Promises and Async/Await allow for easier creation of "effectively" multi-threaded code, leaving behind a trail of things pending for the "Event Loop". Did I mention it helps to actually know the Javascript Event Loop? They also can be severe memory hogs and memory leaks if you create a lot of them OR don't close them out properly...

If you are writing code that processes large-ish amounts of information, without concern for the "poor little human waiting to see something happening", then you probably don't really "need" Promises or Async-Await (other than APIs that simply only work that way) - they were created mostly to keep the browser window looking "active", and/or allow a server to process multiple user sessions "simultaneously".

scripting commented 4 years ago

@LeadDreamer -- these claims are impossible to evaluate without code examples and more specifics. I don't find callbacks convoluted or hard to maintain. So I'm not looking for a solution to that because for me it isn't a problem. But if I were, how is it, with an example, that promises are easier for a human mind to comprehend?

LeadDreamer commented 4 years ago

I'll have to yank up some of my promise chains (including Promise.all around an array of parallel promises) and re-write them as callbacks. Because I use React, and process a LOT of arrays of records fetched asynchronously from various "tables" (collections) in a Google Firestore, which then have to go through a series of map/reduce/forEach loops, I've fallen into the habit of Promises (currently cleaning up to async/await). OTH, if it ain't a problem, don't fix it.

scripting commented 4 years ago

@LeadDreamer -- Actually we already have an example that loops through an array making sequential callback-processed requests. You could convert that code to promises. It would be interesting to see what you come up with. 

scripting commented 4 years ago

BTW, I just recoded that awkward routine in the version of JavaScript that I wish they had created.

function loadFromConfigList () {
    var logarray = new Array ();
    config.locations.forEach (function (loc) {
        var logtext = folderloader.load (loc.s3path, loc.folder);
        if (logtext.length == 0) {
            logtext = "No changes.";
            }
        logarray.push (logtext);
        });
    return (logarray);
    }

Caveat: I converted this without running it. So there might be mistakes. I don't have a version of loadFromConfigList that runs synchronously. ;-)

LeadDreamer commented 4 years ago

Have to say, I'm not sure your original actually works as you think it does - if the folderloader.load() call is asynchronous, nothing I see in your code actually waits for each and/or all the calls to be resolved. But in a promise structure, I would render it something like this:

  /**
   * @module loadfromConfigList
   * @param config assumes config.locations is an array of path entry objects,
   *  each with at least the properties s3Path and folder
   * 
   * Assume folderloader has a method called "load" that returns an
   * asynchronous promise that resolves with logtext.
   * From context, We assume "load" has side-effects elsewhere (likely
   * loading configuration settings)
   * This functions *also* assumes that the *order* of loading does not matter.
   */
  function loadFromConfigList (config) {
    /* Promise.all expects an array of promises; it waits for all entries
     * to resolve, then returns the resulting array
     */
    /* .map method returns an array of the results of a function run for each
     *  entry.  The various calls are "in parallel"
     */
    return Promise.all(config.locations.map(function (loc) {
      return folderloader.load(loc.s3path, loc.folder)
      .then(function (logtext) {
        /* we return a "promise" with the result to allow the above
          * Promise.All to resolve
          */
        Promise.resolve(logtext.length ? logtext : "No changes.")
      })
    })
    );
  }

Without the comments, more like:

 function loadFromConfigList (config) {
    return Promise.all(config.locations.map(function (loc) {
      return folderloader.load(loc.s3path, loc.folder)
      .then(function (logtext) {
        Promise.resolve(logtext.length ? logtext : "No changes.")
      })
    })
    );
  }
LeadDreamer commented 4 years ago

(note below aync functions always return results wrapped in a Promise, so we don't need the last Promise.resolve we had above)

  /* same function, but ES6 arrow notation and async/await
    */
  function loadFromConfigList (config) {
    return Promise.all(config.locations.map(async loc => {
      const logtext = await folderloader.load (loc.s3path, loc.folder);
      return logtext.length ? logtext : "No changes.";
      })
    );
  }
LeadDreamer commented 4 years ago

did find this, btw: https://www.npmjs.com/package/sync-request