Tuesday, 20 October 2020

Promises in JavaScript - Understanding background concept

Promises were introduced in ES2015, considered as one of the biggest changes made in JavaScript landscape. They abolish problems with callbacks and reduces complexity of code. 

It's probably perfect to say that promises make asynchronous programming in synchronous manner!

By the end of this article, you will be able to understand : 

  • Why we need promises in JavaScript ? 
  • What promises are - (usage and implementation)
  • Reimplement common callback methods using promises.

If you have non-programming background, you may not know the difference between synchronous and asynchronous programming. 
So, let's understand it. 

Synchronous Programming /Code :

  •   Runs sequentially (line-by-line).
  •   Next statement won't run until the previous one finish. 
      Example :
            Note : this example assumes you are running node. 
         // readfile_sync.js

"use strict";

// This example uses Node, and so won't run in the browser. 
const filename = 'text.txt', 
       fs        = require('fs');

console.log('Reading file . . . ');

// readFileSync BLOCKS execution until it returns. 
//   The program will wait to execute anything else until this operation finishes. 
const file = fs.readFileSync(`${__dirname}/${filename}`); 

// This will ALWAYS print after readFileSync returns. . . 
console.log('Done reading file.');

// . . . And this will ALWAYS print the contents of 'file'.
console.log(`Contents: ${file.toString()}`); 

Asynchronous Programming / Code: 

  •  Allows next statement to execute even if previous one has not finished. 
    Example :
          // readfile_async.js

"use strict";

// This example uses Node, so it won't run in the browser.
const filename      = 'text.txt', 
        fs            = require('fs'),
        getContents = function printContent (file) {
        try {
          return file.toString();
        } catch (TypeError) {
          return file; 
        } 
      }

console.log('Reading file . . . ');
console.log("=".repeat(76));

// readFile executes ASYNCHRONOUSLY. 
//   The program will continue to execute past LINE A while 
//   readFile does its business. We'll talk about callbacks in detail
//   soon -- for now, just pay mind to the the order of the log
//   statements.
let file;
fs.readFile(`${__dirname}/${filename}`, function (err, contents) {
  file = contents;
  console.log( `Uh, actually, now I'm done. Contents are: ${ getContents(file) }`);
}); // LINE A

// These will ALWAYS print BEFORE the file read is complete.

// Well, that's both misleading and useless.
console.log(`Done reading file. Contents are: ${getContents(file)}`); 
console.log("=".repeat(76));

The major convenience of synchronous code is that it is easy to read and execute from top to bottom. 

The major drawback is it is slow as it execute line by line and often debilitatingly too. 

That's why JavaScript is asynchronous at the core. 


Challenges of asynchronicity

Asynchronicity gives speed but at the cost of linearity. We don't know how long it will take to complete previous task. In above example, we have set Timeout function. But every time we don't know how much time it will take to complete execution. 


Callbacks and Fallbacks 

Callbacks are one solution to this problem. Callbacks are nothing but passing a function as a parameter of another function. This function is executed right after a task is completed. 

For example, 

"use strict";

const filename = 'throwaway.txt',
      fs       = require('fs');

let file, useless;

useless = fs.readFile(`${__dirname}/${filename}`, function callback (error, contents) {
  file = contents;
  console.log( `Got it. Contents are: ${contents}`);
  console.log( `. . . But useless is still ${useless}.` );
});

// Thanks to Rava for catching an error in this line.
console.log(`File is ${useless}, but that'll change soon.`);                                                          
As readFile is asynchronous function, it returns immediately to continue execute. So it doesn't get enough time to perform I/O, it returns undefined. We execute this as much as we can until readFile finishes. 

The question is, how do we know when the read is complete?

Actually, we can't. But readFile knows. We have passed two arguments in readFile( ). 

1. A filename 
2. A function (callback function)  which will be executed right after read is finished. 

Thus, we don't know when the file contents are ready. So, we hand over it to callback.  

But callbacks don't always works. Major problems with callbacks are: 

1. Inversion control 
2. Complicated error handling


Inversion of control : 

This is the problem of trust. 

When we use callback, we trust that readFile will call it. But there is actually no guarantee that it will. Also, there is no assurance that if it is called, it will be with right parameter, in the right orders and right number of times. 

Thus, application handing control to a third party should be risky and it has been a source of many a hard-to-squash heisenbug in previous years. 

Complicated error handling 

We can use try/catch/finally to handle errors. 

"use strict";

// This example uses Node, and so won't run in the browser. 
const filename = 'throwaway.txt', 
        fs       = require('fs');

console.log('Reading file . . . ');

let file;
try {
  // Wrong filename. D'oh!
  fs.readFile(`${__dirname}/${filename + 'a'}`, function (err, contents) {
    file = contents;
  });

  // This shouldn't run if file is undefined
  console.log( `Got it. Contents are: '${file}'` );
} catch (err) {
  // In this case, catch should run, but it never will.
  //   This is because readFile passes errors to the callback -- it does /not/
  //   throw them.
  console.log( `There was a/n ${err}: file is ${file}` );
}
This doesn't work as expected. Because try block will be executed successfully (with undefined) always. 

The only way to catch the error is pass them to callback and you handle by yourself. 

"use strict";

// This example uses Node, and so won't run in the browser. 
const filename = 'throwaway.txt',
        fs       = require('fs');

console.log('Reading file . . . ');

fs.readFile(`${__dirname}/${filename + 'a'}`, function (err, contents) {
  if (err) { // catch
    console.log( `There was a/n ${err}.` );
  } else   { // try
    console.log( `Got it. File contents are: '${file}'`);
  }
});
This option is quite moderate, but when program is large, it becomes
unwieldy to handle. 

Promises address both of these problems by uninverting control and synchronizing asynchronous code. 


Promises 

Imagine you have just ordered your favorite books, in exchange of hand-earned cash, they send a receipt that you will receive a new stacks of books next Monday. Right now, you don't have books but you will have because they promised to send it. 

Now, you can set your time everyday to read and notify your boss that you won't be available for a week because you're too busy for reading. 

Undoubtedly, they may not fill order for whatever reason after few days. Now, you'll erase the reading time and tell your boss that you'll be reporting office from next week. 

A promise is like that receipt. It is an object which has a value but that value is not ready yet, but will be ready in future. 

Promises handle the interrupted control flow internally, and uses special keyword 'catch' to handle errors. 

When promise return value, you decide what to do with it. Thus, fixing the inversion of control problem. 

Basic Usage of Promises 
1. Create promise with constructor. 
2. Handle success with resolve. 
3. Handle error with reject. 
4.  Set control flow with then() and catch()


Create Promises

'use strict';

const fs = require('fs');

const text = 
  new Promise(function (resolve, reject) {
      // Does nothing
  })

resolve - is a function, represents what we want to do when we get expected value. [ resolve(val) ]

reject - is a function, represents what we want to do when we get error. [ reject(err) ]

Now, our task is, 

1. read a file. 
2. If successful, resolve content
3. Else, reject with error. 

// constructor.js

const text = 
  new Promise(function (resolve, reject) {
    // Normal fs.readFile call, but inside Promise constructor . . . 
    fs.readFile('text.txt', function (err, text) {
      // . . . Call reject if there's an error . . . 
      if (err) 
        reject(err);
      // . . . And call resolve otherwise.
      else
    // We need toString() because fs.readFile returns a buffer.
        resolve(text.toString());
    })
  })
Now, we've technically done. This code does exact we want to do. But when you run a code, you'll come to know that it executes without printing result or error!

It is so because we wrote resolve and reject method, but didn't actually pass them to Promise. For that we need : promise-based control-flow: then. 

Then() is a method which accepts two arguments. Resolve and reject in that order. 

// constructor.js

const text = 
  new Promise(function (resolve, reject) {
    fs.readFile('text.txt', function (err, text) {
      if (err) 
        reject(err);
      else
        resolve(text.toString());
    })
  })
  .then(resolve, reject);
Notice that then always returns a promise object. You can chain several then calls for plenty of asynchronous operations. 

Promises are predominant in async programming. They can be complicated at first, but that's only because of unfamiliarity. Once you start using it, it'll become as simple as if...else statements!

I tried to describe background concept behind promises. Hope it was helpful to you. 

Thanks!!


No comments:

Post a Comment