Principles of functional programming in JavaScript

Original author: TK
  • Transfer
The author of the material, the translation of which we publish today, says that he, after a long time engaged in object-oriented programming, thought about the complexity of systems. According to John Oosterhout , complexity (complexity) is all that makes it harder to understand or modify software. The author of this article, after performing some research, discovered the concepts of functional programming, like immutable and pure functions. The use of such concepts allows you to create functions that do not have side effects. Using these functions simplifies system support and gives the programmer some other benefits .

image

Here we talk about functional programming and some of its important principles. All this will be illustrated with a variety of JavaScript code examples.

What is functional programming?


You can read about functional programming in Wikipedia . Namely, we are talking about the fact that functional programming is a programming paradigm in which the calculation process is interpreted as calculating the values ​​of functions in the mathematical understanding of the latter. Functional programming involves doing a calculation of the results of functions from the source data and the results of other functions, and does not imply the explicit storage of the program state. Accordingly, it does not imply the variability of this state.

Now we, on examples, we will sort some ideas of functional programming.

Pure functions


Pure functions are the first fundamental concept to be studied in order to understand the essence of functional programming.

What is a “pure function”? What makes the function “clean”? The net function must meet the following requirements:

  • It always returns, when passing the same arguments to it, the same result (such functions are also called deterministic).
  • This feature has no side effects.

Consider the first property of pure functions, namely the fact that they, when passing the same arguments to them, always return the same result.

▍ Function Arguments and Return Values


Imagine that we need to create a function that calculates the area of ​​a circle. A function that is not clean would accept, as a parameter, the radius of the circle ( radius), after which it would return the value of the calculation of the expression radius * radius * PI:

const PI = 3.14;
functioncalculateArea(radius) {
  return radius * radius * PI;
}
calculateArea(10); // возвращает 314

Why this function can not be called clean? The fact is that it uses a global constant that is not passed to it as an argument.

Now imagine that some mathematicians came to the conclusion that the value of a constant PIshould be a number 42, because of which the value of this constant was changed.

Now a function that is not clean 10returns the value by passing the same input value to it, a number 10 * 10 * 42 = 4200. It turns out that the use of the parameter value here, as in the previous example radius, leads to the return of another result by the function. Fix this:

const PI = 3.14;
functioncalculateArea(radius, pi) {
  return radius * radius * pi;
}
calculateArea(10, PI); // возвращает 314

Now, by calling this function, we will always pass an argument to it pi. As a result, the function will only work with what is passed to it when it is called, without referring to global entities. If we analyze the behavior of this function, we can come to the following conclusions:

  • If functions pass an argument radiusequal to 10, and an argument piequal 3.14, it will always return the same result - 314.
  • When you call it with an argument radiusequal 10and an argument piequal 42, it will always return 4200.

Reading files


If our function reads files, it will not be clean. The fact is that the contents of files can vary.

functioncharactersCounter(text) {
  return`Character count: ${text.length}`;
}
functionanalyzeFile(filename) {
  let fileContent = open(filename);
  return charactersCounter(fileContent);
}

Random Number Generation


Any function that relies on a random number generator cannot be clean.

functionyearEndEvaluation() {
  if (Math.random() > 0.5) {
    return"You get a raise!";
  } else {
    return"Better luck next year!";
  }
}

Now let's talk about side effects.

▍Side Effects


An example of a side effect that can occur when a function is called is the modification of global variables or arguments passed to functions by reference.

Suppose we need to create a function that takes an integer and increments that number by 1. Here’s how the implementation of such an idea might look like:

let counter = 1;
functionincreaseCounter(value) {
  counter = value + 1;
}
increaseCounter(counter);
console.log(counter); // 2

There is a global variable counter. Our function, which is not pure, takes this value as an argument and rewrites it, adding one to its previous value.

The global variable is changing; this is not welcome in functional programming.

In our case, the value of the global variable is modified. How to make the function increaseCounter()clean under these conditions ? In fact, it is very simple:

let counter = 1;
functionincreaseCounter(value) {
  return value + 1;
}
increaseCounter(counter); // 2console.log(counter); // 1

As you can see, the function returns 2, but the value of the global variable counterdoes not change. Here we can conclude that the function returns the value passed to it, incremented by 1, while not changing anything.

If you follow the above two rules for writing pure functions, this will make it easier to navigate in programs created using such functions. It turns out that each function will be isolated and will not affect the external parts of the program.

Pure functions are stable, uniform and predictable. Obtaining the same input data, such functions always return the same result. This saves the programmer from trying to take into account the possibility of situations in which the transfer of the function of the same parameters leads to different results, since this is simply not possible when using pure functions.

▍ Strengths of pure functions


Among the strengths of pure functions can be noted the fact that the code written with their use, it is easier to test. In particular, it is not necessary to create some objects-stubs. This allows unit testing of pure functions in various contexts:

  • If the parameter A is passed to the function, the return value is expected B.
  • If the parameter C is passed to the function, the return value is expected D.

As a simple example of this idea, we can give a function that accepts an array of numbers, and it is expected that it will increase by one each number of this array by returning a new array with the results:

let list = [1, 2, 3, 4, 5];
functionincrementNumbers(list) {
  return list.map(number => number + 1);
}

Here we pass an array of numbers to the function, after which we use the array method map(), which allows us to modify each element of the array and forms a new array returned by the function. Call the function, passing it an array list:

incrementNumbers(list); // возвращает [2, 3, 4, 5, 6]

From this function, it is expected that by adopting an array of the form [1, 2, 3, 4, 5], it will return a new array [2, 3, 4, 5, 6]. This is how it works.

Immunity


Immunity of a certain entity can be described as the fact that over time it does not change, or as the impossibility of changes to this entity.

If an immutable object is attempted to be changed, it will not succeed. Instead, you will need to create a new object containing new values.

For example, JavaScript often uses a loop for. During his work, as shown below, mutable variables are used:

var values = [1, 2, 3, 4, 5];
var sumOfValues = 0;
for (var i = 0; i < values.length; i++) {
  sumOfValues += values[i];
}
sumOfValues // 15

At each iteration of the loop, the value of the variable iand the value of the global variable change (it can be considered the state of the program) sumOfValues. How to maintain the immutability of entities in such a situation? The answer lies in the use of recursion.

let list = [1, 2, 3, 4, 5];
let accumulator = 0;
functionsum(list, accumulator) {
  if (list.length == 0) {
    return accumulator;
  }
  return sum(list.slice(1), accumulator + list[0]);
}
sum(list, accumulator); // 15
list; // [1, 2, 3, 4, 5]
accumulator; // 0

There is a function sum()that accepts an array of numbers. This function calls itself until the array is empty (this is the base case of our recursive algorithm ). At each such “iteration” we add the value of one of the elements of the array to the parameter of the function accumulator, without affecting the global variable accumulator. When this global variables listand accumulatorremain unchanged before and after the function call are stored in the same value.

It should be noted that to implement this algorithm, you can use the method of arrays reduce. We will discuss this below.

In programming, a task is distributed, when necessary, on the basis of a certain object template, to create its final representation. Imagine that we have a string that needs to be converted into a form suitable for use as part of a URL leading to a resource.

If we solve this problem using Ruby and using OOP principles, we will first create a class, say, naming it UrlSlugify, and then create a method of this class slugify!, which is used to convert the string.

classUrlSlugify
  attr_reader :text
  definitialize(text)
    @text= text
  end
  def slugify!
    text.downcase!
    text.strip!
    text.gsub!(' ', '-')
  end
end
UrlSlugify.new(' I will be a url slug   ').slugify! # "i-will-be-a-url-slug"

We have implemented the algorithm, and this is great. Here we see an imperative approach to programming, when we, processing a line, paint each step of its transformation. Namely - first we bring its characters to lower case, then we remove unnecessary spaces, and, finally, we change the remaining spaces on the dash.

However, during such a transformation, a program state mutation occurs.

You can cope with the problem of mutation by performing a composition of functions or a combination of function calls into a chain. In other words, the result returned by the function will be used as input for the next function, and so for all functions that are chained together. In this case, the original string will not change.

let string = " I will be a url slug   ";
functionslugify(string) {
  return string.toLowerCase()
    .trim()
    .split(" ")
    .join("-");
}
slugify(string); // i-will-be-a-url-slug

Here we use the following functions, which are represented in JavaScript by standard methods of strings and arrays:

  • toLowerCase: converts the string characters to lower case.
  • trim: removes whitespace from the beginning and end of the line.
  • split: breaks a string into parts, placing words, separated by spaces, in an array.
  • join: forms, on the basis of an array of words, a string whose words are separated by a dash.

These four functions allow you to create a function for converting a string that does not change the string itself.

Referential transparency


Create a function square()that returns the result of multiplying a number by the same number:

functionsquare(n) {
  return n * n;
}

This is a pure function that always, for the same input value, will return the same output value.

square(2); // 4
square(2); // 4
square(2); // 4// ...

For example, no matter how much a number is passed to it 2, this function will always return a number 4. As a result, it turns out that the type call square(2)can be replaced by a number 4. This means that our function has the property of referential transparency.

In general, it can be said that if a function invariably returns the same result for the same input values ​​passed to it, it has a reference transparency.

▍Pure functions + immunity data = referential transparency


By embracing the idea put forth in the heading of this section, you can memoize functions. Suppose we have this function:

functionsum(a, b) {
  return a + b;
}

We call it like this:

sum(3, sum(5, 8));

The call sum(5, 8)always gives 13. Therefore, the above call can be rewritten as:

sum(3, 13);

This expression, in turn, always gives 16. As a result, it can be replaced by a numeric constant and memoized it.

Functions as first class objects


The idea of ​​perceiving functions as first-class objects is that such functions can be viewed as values ​​and work with them as data. In this case, the following features of functions can be distinguished:

  • References to functions can be stored in constants and variables and through them refer to functions.
  • Functions can be passed to other functions as parameters.
  • Functions can be returned from other functions.

That is, it is a matter of treating functions as values ​​and treating them as data. With this approach, you can combine various functions in the process of creating new functions that implement new features.

Imagine that we have a function that adds the two numeric values ​​passed to it, and then multiplies them by 2and returns what it did:

functiondoubleSum(a, b) {
  return (a + b) * 2;
}

Now we will write a function that subtracts the second from the first numeric value passed to it, multiplies what happened, by 2, and returns the calculated value:

functiondoubleSubtraction(a, b) {
  return (a - b) * 2;
}

These functions have a similar logic, they differ only in what kind of operations they perform with the numbers transferred to them. If we can consider functions as values ​​and pass them as arguments to other functions, this means that we can create a function that accepts and uses another function that describes the features of the performed calculations. These considerations allow us to go to the following constructions:

functionsum(a, b) {
  return a + b;
}
functionsubtraction(a, b) {
  return a - b;
}
functiondoubleOperator(f, a, b) {
  return f(a, b) * 2;
}
doubleOperator(sum, 3, 1); // 8
doubleOperator(subtraction, 3, 1); // 4

As you can see, now the function doubleOperator()has a parameter f, and the function it represents is used to process the parameters aand b. Functions sum()and functions substraction()that are passed doubleOperator(), in fact, allow you to control the behavior of the function doubleOperator(), changing it in accordance with the logic implemented in them.

Higher order functions


Speaking of higher order functions, we mean functions that are characterized by at least one of the following features:

  • The function takes another function as an argument (there may be several such functions).
  • The function returns another function as the result of its work.

You may already be familiar with the standard JS array methods filter(), map()and reduce(). Let's talk about them.

Массив Array filtering and filter () method


Suppose we have a certain collection of elements, which we want to filter by some attribute of the elements of this collection and form a new collection. The function filter()expects to receive some criterion for evaluating the elements, on the basis of which it determines whether it is necessary or not necessary to include an element in the resulting collection. This criterion is set by the function passed to it, which returns trueif the function is filter()to include an element in the final collection, and otherwise returns false.

Imagine that we have an array of integers and we want to filter it by getting a new array that contains only even numbers from the original array.

Imperative approach


When applying the imperative approach to solving this problem with JavaScript, we need to implement the following sequence of actions:

  • Create an empty array for new items (let's call it evenNumbers).
  • Enumerate the initial array of integers (let's call it numbers).
  • Put the even numbers found in the array numbersinto the array evenNumbers.

Here is the implementation of this algorithm:

var numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
var evenNumbers = [];
for (var i = 0; i < numbers.length; i++) {
  if (numbers[i] % 2 == 0) {
    evenNumbers.push(numbers[i]);
  }
}
console.log(evenNumbers); // (6) [0, 2, 4, 6, 8, 10]

In addition, we can write a function (let's call it even()), which, if the number is even, returns true, and if odd , returns the function falseto the array method filter(), which, having checked with its help each element of the array, will form a new array containing only even numbers:

functioneven(number) {
  return number % 2 == 0;
}
let listOfNumbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
listOfNumbers.filter(even); // [0, 2, 4, 6, 8, 10]

Here, by the way, is the solution of one interesting problem related to filtering an array , which I performed while working on functional programming tasks on Hacker Rank . By the condition of the problem, it was necessary to filter an array of integers, displaying only those elements that are less than a specified value x.

The imperative solution of this JavaScript task might look like this:

var filterArray = function(x, coll) {
  var resultArray = [];
  for (var i = 0; i < coll.length; i++) {
    if (coll[i] < x) {
      resultArray.push(coll[i]);
    }
  }
  return resultArray;
}
console.log(filterArray(3, [10, 9, 8, 2, 7, 5, 1, 3, 0])); // (3) [2, 1, 0]

The essence of the imperative approach is that we describe the sequence of actions performed by the function. Namely, we describe iterating over an array, comparing the current element of an array with xand placing this element in an array resultArrayif it passes the test.

Declarative approach


How to go to the declarative approach to solving this problem and the appropriate use of the method filter(), which is a function of a higher order? For example, it might look like this:

functionsmaller(number) {
  return number < this;
}
functionfilterArray(x, listOfNumbers) {
  return listOfNumbers.filter(smaller, x);
}
let numbers = [10, 9, 8, 2, 7, 5, 1, 3, 0];
filterArray(3, numbers); // [2, 1, 0]

Perhaps in this example, the use of a keyword thisin a function may seem unusual to you smaller(), but there is nothing complicated here. The keyword thisis the second argument of the method filter(). In our example, this is the number 3represented by the xfunction parameter filterArray(). It indicates this number this.

The same approach can be used even if entities that have a rather complex structure, for example, objects, are stored in an array. Suppose we have an array storing objects containing the names of people represented by the property nameand information about the age of these people represented by the property age. This is what an array looks like:

let people = [
  { name: "TK", age: 26 },
  { name: "Kaio", age: 10 },
  { name: "Kazumi", age: 30 }
];

We want to filter this array by selecting from it only those objects that represent people whose age has exceeded a 21year. Here's how to solve this problem:

functionolderThan21(person) {
  return person.age > 21;
}
functionoverAge(people) {
  return people.filter(olderThan21);
}
overAge(people); // [{ name: 'TK', age: 26 }, { name: 'Kazumi', age: 30 }]

Here we have an array with objects representing people. We check the elements of this array with a function olderThan21(). In this case, we, when checking, refer to the property of ageeach element, checking whether the value exceeds this property 21. We pass this function to the method filter()that filters the array.

▍Processing of array elements and map () method


The method is map()used to convert elements of arrays. It applies to each element of the array the function transferred to it, after which it builds a new array consisting of modified elements.

Let's continue the experiments with the array you already know people. Now we are not going to filter this array based on the property of objects age. We need to build on its basis a list of strings of the form TK is 26 years old. The lines into which the elements are converted, with this approach, will be built on a pattern p.name is p.age years old, where p.nameand p.ageare the values ​​of the corresponding properties of the elements of the array people.

The imperative approach to solving this JavaScript task is as follows:

var people = [
  { name: "TK", age: 26 },
  { name: "Kaio", age: 10 },
  { name: "Kazumi", age: 30 }
];
var peopleSentences = [];
for (var i = 0; i < people.length; i++) {
  var sentence = people[i].name + " is " + people[i].age + " years old";
  peopleSentences.push(sentence);
}
console.log(peopleSentences); // ['TK is 26 years old', 'Kaio is 10 years old', 'Kazumi is 30 years old']

If you resort to the declarative approach, you get the following:

functionmakeSentence(person) {
  return`${person.name} is ${person.age} years old`;
}
functionpeopleSentences(people) {
  return people.map(makeSentence);
}
peopleSentences(people); // ['TK is 26 years old', 'Kaio is 10 years old', 'Kazumi is 30 years old']

As a matter of fact, the basic idea here is that you need to do something with each element of the original array, after which it is placed in a new array.

Here is another task with Hacker Rank, which is dedicated to updating the list . Namely, it is about changing the values ​​of the elements of an existing numeric array to their absolute values. So, for example, when processing an array, [1, 2, 3, -4, 5]it will take on the form [1, 2, 3, 4, 5]as the absolute value -4equals 4.

Here is an example of a simple solution to this problem, when we iterate over an array and change the values ​​of its elements to their absolute values.

var values = [1, 2, 3, -4, 5];
for (var i = 0; i < values.length; i++) {
  values[i] = Math.abs(values[i]);
}
console.log(values); // [1, 2, 3, 4, 5]

Here the method is used to convert the values ​​of the elements of the array Math.abs(), the modified elements are written to the same place where they were before the conversion.

This solution is not an example of a functional programming approach.

The first thing to remember in connection with this decision is that we spoke above about the importance of immunity. This concept makes the performance of functions predictable and leads to stable operation of functions. With this approach, solving our problem, you need to create a new array containing the absolute values ​​of the elements of the original array.

The second question to ask yourself in this situation concerns the array method map(). Why not use it?

Armed with these ideas, I decided to experiment with the methodabs(), take a look at how it handles different numbers.

Math.abs(-1); // 1Math.abs(1); // 1Math.abs(-2); // 2Math.abs(2); // 2

As you can see, it returns positive numbers that represent the absolute value of the numbers passed to it.

Once we have understood the principle of converting a number to its absolute value, we can use Math.abs()an array method as an argument map(). Remember that higher order functions can take on other functions and use them? The method map()is just such a function. Here's how the solution to our problem will look like now:

let values = [1, 2, 3, -4, 5];
functionupdateListMap(values) {
  return values.map(Math.abs);
}
updateListMap(values); // [1, 2, 3, 4, 5]

I am sure no one would argue with the fact that, in comparison with the previous version, it turned out to be much simpler, predictable and understandable.

▍ Array conversion and the reduce () method


The method is reduce()based on the idea of ​​transforming an array to a single value by combining its elements using a certain function.

A common example of this method is finding the total amount for an order. Imagine that we are talking about an online store. Buyer adds to the basket items Product 1, Product 2, Product 3and Product 4. After that we need to find the total value of these goods.

If you use the imperative approach for solving this task, then you need to sort out the list of goods from the basket and add up their costs. For example, it might look like this:

var orders = [
  { productTitle: "Product 1", amount: 10 },
  { productTitle: "Product 2", amount: 30 },
  { productTitle: "Product 3", amount: 20 },
  { productTitle: "Product 4", amount: 60 }
];
var totalAmount = 0;
for (var i = 0; i < orders.length; i++) {
  totalAmount += orders[i].amount;
}
console.log(totalAmount); // 120

If we use the array method to solve this problem reduce(), we can create a function ( sumAmount()) used to calculate the sum of the array elements, and then pass it to the method reduce():

let shoppingCart = [
  { productTitle: "Product 1", amount: 10 },
  { productTitle: "Product 2", amount: 30 },
  { productTitle: "Product 3", amount: 20 },
  { productTitle: "Product 4", amount: 60 }
];
const sumAmount = (currentTotalAmount, order) => currentTotalAmount + order.amount;
functiongetTotalAmount(shoppingCart) {
  return shoppingCart.reduce(sumAmount, 0);
}
getTotalAmount(shoppingCart); // 120

Here there is an array shoppingCartrepresenting the shopping cart, a function sumAmount()that takes the elements of the array (objects order, while we are interested in their properties amount), and the current calculated value of the sum of their values ​​- currentTotalAmount.

When calling a method reduce()executed in a function getTotalAmount(), it is passed a function sumAmount()and the initial value of the counter, which is equal to 0.

Another way to solve our problem is to combine the methods map()and reduce(). What is meant by their "combination"? The point here is that we can use the method map()to convert an array shoppingCartinto an array containing only the property values ​​of the amountobjects stored in this array, and then use the methodreduce()and function sumAmount(). Here's what it looks like:

const getAmount = (order) => order.amount;
const sumAmount = (acc, amount) => acc + amount;
functiongetTotalAmount(shoppingCart) {
  return shoppingCart
    .map(getAmount)
    .reduce(sumAmount, 0);
}
getTotalAmount(shoppingCart); // 120

The function getAmount()takes an object and returns only its property amount. After processing the array using the method map()to which this function has been transferred, a new array is obtained that looks like [10, 30, 20, 60]. Then, with the help reduce(), we find the sum of the elements of this array.

▍ Combined use of filter (), map () and reduce () methods


Above, we talked about how higher-order functions work, looked at array methods filter(), map()and reduce(). Now, on a simple example, consider the use of all three of these functions.

Let's continue the example of an online store. Suppose the shopping cart now looks like this:

let shoppingCart = [
  { productTitle: "Functional Programming", type: "books", amount: 10 },
  { productTitle: "Kindle", type: "eletronics", amount: 30 },
  { productTitle: "Shoes", type: "fashion", amount: 20 },
  { productTitle: "Clean Code", type: "books", amount: 60 }
]

We need to find out the cost of the books in the order. Here is what algorithm can be proposed to solve this problem:

  • Filter an array by the value of the property of typeits elements, given that we are interested in the value of this property books.
  • Convert the resulting array to a new one, containing only the value of the goods.
  • Add all the cost of goods and get the total value.

Here is the code that implements this algorithm:

let shoppingCart = [
  { productTitle: "Functional Programming", type: "books", amount: 10 },
  { productTitle: "Kindle", type: "eletronics", amount: 30 },
  { productTitle: "Shoes", type: "fashion", amount: 20 },
  { productTitle: "Clean Code", type: "books", amount: 60 }
]
const byBooks = (order) => order.type == "books";
const getAmount = (order) => order.amount;
const sumAmount = (acc, amount) => acc + amount;
functiongetTotalAmount(shoppingCart) {
  return shoppingCart
    .filter(byBooks)
    .map(getAmount)
    .reduce(sumAmount, 0);
}
getTotalAmount(shoppingCart); // 70

Results


In this article we talked about the application of some functional programming ideas in JavaScript development. We hope you find these ideas useful.

Dear readers! Do you use functional programming methods in your projects?




Also popular now: