Functional JavaScript: five ways to find the arithmetic mean of array elements and the .reduce () method

Original author: James Sinclair
  • Transfer
Array iteration methods are similar to “starting drugs” (of course, they are not drugs; and I am not saying that drugs are good; they are just a figure of speech). Because of them, many "sit down" on functional programming. The thing is that they are incredibly convenient. In addition, most of these methods are very easy to understand. Methods like .map()and .filter()take only one callback argument and allow you to solve simple problems. But there is a feeling that the method .reduce()causes certain difficulties for many. To understand it is a little more difficult. I already wrote about why I think it creates a lot of problems. This is partly due to the fact that many manuals demonstrate the use of



.reduce().reduce()only when processing numbers. Therefore, I wrote about how many tasks that do not imply arithmetic operations can be solved with the help of .reduce(). But what if you absolutely need to work with numbers?

A typical use case .reduce()looks like a calculation of the arithmetic mean value of array elements. At first glance, it seems that there is nothing special in this task. But she is not so simple. The fact is that before calculating the average, you need to find the following indicators:

  1. The total amount of array element values.
  2. The length of the array.

Finding out all this is pretty simple. And computing averages for numeric arrays is also not an easy operation. Here is an elementary example:

function average(nums) {
    return nums.reduce((a, b) => (a + b)) / nums.length;
}

As you can see, there are no special incomprehensions here. But the task becomes harder if you have to work with more complex data structures. What if we have an array of objects? What if some objects from this array need to be filtered? What to do if you need to extract certain numerical values ​​from objects? In this situation, calculating the average value for array elements is already a bit more complicated task.

In order to deal with this, we will solve the training problem (it is based on thisassignment with FreeCodeCamp). We will solve it in five different ways. Each of them has its own advantages and disadvantages. An analysis of these five approaches to solving this problem will show how flexible JavaScript can be. And I hope that the analysis of the solutions will give you food for thought about how to use it .reduce()in real projects.

Task Overview


Suppose we have an array of objects that describe Victorian slang expressions. You need to filter out those expressions that are not found in Google Books (the property of the foundcorresponding objects is equal false), and find an average rating for the popularity of expressions. Here's what such data might look like (taken from here ):

const victorianSlang = [
        term: 'doing the bear',
        found: true,
        popularity: 108,
    },
        term: 'katterzem',
        found: false,
        popularity: null,
    },
        term: 'bone shaker',
        found: true,
        popularity: 609,
    },
        term: 'smothering a parrot',
        found: false,
        popularity: null,
    },
        term: 'damfino',
        found: true,
        popularity: 232,
    },
        term: 'rain napper',
        found: false,
        popularity: null,
    },
        term: 'donkey’s breakfast',
        found: true,
        popularity: 787,
    },
        term: 'rational costume',
        found: true,
        popularity: 513,
    },
        term: 'mind the grease',
        found: true,
        popularity: 154,
    },
];

Consider 5 ways to find the average value of assessing the popularity of expressions from this array.

1. Problem solving without using .reduce () (imperative loop)


In our first approach to solving the problem, the method .reduce()will not be used. If you have not come across methods for iterating arrays before, then I hope that parsing this example will clarify the situation a bit for you.

let popularitySum = 0;
let itemsFound = 0;
const len = victorianSlang.length;
let item = null;
for (let i = 0; i < len; i++) {
    item = victorianSlang[i];
    if (item.found) {
        popularitySum = item.popularity + popularitySum;
        itemsFound = itemsFound + 1;
}
const averagePopularity = popularitySum / itemsFound;
console.log("Average popularity:", averagePopularity);

If you are familiar with JavaScript, then you will easily understand this example. In fact, the following happens here:

  1. We initialize the variables popularitySumand itemsFound. The first variable popularitySum,, stores the overall rating of the popularity of expressions. And the second variable,, itemsFound(that's a surprise) stores the number of found expressions.
  2. Then we initialize the constant lenand variable item, which are useful to us when traversing the array.
  3. In the loop, the forcounter is iincremented until its value reaches the index value of the last element of the array.
  4. Inside the loop, we take the element of the array that we want to explore. We appeal to the element using the design victorianSlang[i].
  5. Then we find out if this expression is found in the collection of books.
  6. If an expression occurs in books, we take the value of its popularity rating and add it to the value of the variable popularitySum.
  7. At the same time, we also increase the counter of the found expressions - itemsFound.
  8. And finally, we find the average by dividing popularitySumby itemsFound.

So, we coped with the task. Perhaps our decision was not particularly beautiful, but it does its job. Using methods to iterate through arrays will make it a little cleaner. Let’s take a look at whether we succeed, and the truth is, to “clean” this decision.

2. Simple solution # 1: .filter (), .map () and finding the amount using .reduce () 


Let's, before the first attempt to use the methods of arrays to solve the problem, we break it into small parts. Namely, here is what we need to do:

  1. Select objects representing expressions that are in the Google Books collection. Here you can use the method .filter().
  2. Extract from the objects the evaluation of the popularity of expressions. To solve this subproblem, a method is suitable .map().
  3. Calculate the sum of the ratings. Here we can resort to the help of our old friend .reduce().
  4. And finally, find the average value of the estimates.

Here's what it looks like in code:

// Вспомогательные функции
// ----------------------------------------------------------------------------
function isFound(item) {
    return item.found;
};
function getPopularity(item) {
    return item.popularity;
}
function addScores(runningTotal, popularity) {
    return runningTotal + popularity;
}
// Вычисления
// ----------------------------------------------------------------------------
// Отфильтровываем выражения, которые не были найдены в книгах.
const foundSlangTerms = victorianSlang.filter(isFound);
// Извлекаем оценки популярности, получая массив чисел.
const popularityScores = foundSlangTerms.map(getPopularity);
// Находим сумму всех оценок популярности. Обратите внимание на то, что второй параметр
// указывает на то, что reduce нужно использовать начальное значение аккумулятора, равное 0.
const scoresTotal = popularityScores.reduce(addScores, 0);
// Вычисляем и выводим в консоль среднее значение.
const averagePopularity = scoresTotal / popularityScores.length;
console.log("Average popularity:", averagePopularity);

Take a closer look at the function addScore, and the line where it is called .reduce(). Note that it addScoretakes two parameters. The first, runningTotalknown as the battery. It stores the sum of the values. Its value changes every time when we sort through an array and execute an operator return. The second parameter,, popularityis a separate element of the array that we are processing. At the beginning of the array sorting operator returnto addScorehave never carried out. This means that the value has runningTotalnot yet been set automatically. Therefore, by calling .reduce(), we pass to this method the value that needs to be written in runningTotalat the very beginning. This is the second parameter passed .reduce().

So, we applied the methods of iterating arrays to solve the problem. The new version of the solution turned out to be much cleaner than the previous one. In other words, the decision turned out to be more declarative. We don’t tell JavaScript about exactly how to execute the loop; we don’t follow the indexes of the elements of arrays. Instead, we declare simple helper functions of a small size and combine them. Array methods do the hard work for us .filter(), .map()and .reduce(). This approach to solving such problems is more expressive. These array methods are much more complete than a loop can do, they tell us about the intent laid down in the code.

3. Easy Solution # 2: Using Multiple Batteries


In the previous version of the solution, we created a whole bunch of intermediate variables. For example - foundSlangTermsand popularityScores. In our case, such a solution is quite acceptable. But what if we set ourselves a more complex goal regarding code design? It would be nice if we could use the fluent interface design pattern in the program . With this approach, we could chain the calls of all functions and be able to do without intermediate variables. However, one problem awaits us here. Please note that we need to get the valuepopularityScores.length. If we are going to chain everything, then we need some other way of finding the number of elements in the array. The number of elements in the array plays the role of a divisor in calculating the average value. Let's see if we can change the approach to solving the problem so that everything can be done by combining method calls in a chain. We will do this by tracking two values ​​when iterating over the elements of the array, that is, using the “double battery”.

// Вспомогательные функции
// ---------------------------------------------------------------------------------
function isFound(item) {
    return item.found;
};
function getPopularity(item) {
    return item.popularity;
}
// Для представления нескольких значений, возвращаемых return, мы используем объект.
function addScores({totalPopularity, itemCount}, popularity) {
    return {
        totalPopularity: totalPopularity + popularity,
        itemCount:       itemCount + 1,
    };
}
// Вычисления
// ---------------------------------------------------------------------------------
const initialInfo    = {totalPopularity: 0, itemCount: 0};
const popularityInfo = victorianSlang.filter(isFound)
    .map(getPopularity)
    .reduce(addScores, initialInfo);
// Вычисляем и выводим в консоль среднее значение.
const {totalPopularity, itemCount} = popularityInfo;
const averagePopularity = totalPopularity / itemCount;
console.log("Average popularity:", averagePopularity);

Here, to work with two values, we used the object in the reducer function. With each pass through the array performed with addScrores, we update the total value of the popularity rating and the number of elements. It is important to note that these two values ​​are represented as a single object. With this approach, we can “trick” the system and store two entities within the same return value.

The function addScroresturned out to be a little more complicated than the function with the same name as the previous example. But now it turns out that we can use a single chain of method calls to perform all operations with the array. As a result of processing the array, an object is obtained popularityInfothat stores everything that is needed to find the average. This makes the call chain neat and simple.

If you feel the desire to improve this code, then you can experiment with it. For example - you can redo it so as to get rid of many intermediate variables. You can even try to put this code on one line.

4. Composition of functions without using dot notation


If you are new to functional programming, or if it seems to you that functional programming is too complicated, you can skip this section. Parsing it will benefit you if you are already familiar with curry()and compose(). If you want to delve into this topic, take a look at this material about functional programming in JavaScript, and, in particular, at the third part of the series in which it is included.

We are programmers who take a functional approach. This means that we strive to build complex functions from other functions - small and simple. So far, in the course of considering various options for solving the problem, we have reduced the number of intermediate variables. As a result, the solution code became simpler and easier. But what if this idea is taken to extremes? What if you try to get rid of all the intermediate variables? And even try to get away from some parameters?

You can create a function to calculate the average value using only one function compose(), without using variables. We call this “programming without the use of fine-grained notation” or “implicit programming”. In order to write such programs, you will need many auxiliary functions.

Sometimes such a code shock people. This is due to the fact that such an approach is very different from the generally accepted one. But I found that writing code in the style of implicit programming is one of the fastest ways to understand the essence of functional programming. Therefore, I can advise you to try this technique in some personal project. But I want to say that perhaps you should not write in the style of implicit programming the code that other people have to read.

So, back to our task of constructing a system for calculating averages. For the sake of saving space, we will move here to the use of arrow functions. Usually, as a rule, it is better to use named functions. Heregood article on this topic. This allows you to get better stack trace results in case of errors.

// Вспомогательные функции
// ----------------------------------------------------------------------------
const filter  = p => a => a.filter(p);
const map     = f => a => a.map(f);
const prop    = k => x => x[k];
const reduce  = r => i => a => a.reduce(r, i);
const compose = (...fns) => (arg) => fns.reduceRight((arg, fn) => fn(arg), arg);
// Это - так называемый "blackbird combinator".
// Почитать о нём можно здесь: https://jrsinclair.com/articles/2019/compose-js-functions-multiple-parameters/
const B1 = f => g => h => x => f(g(x))(h(x));
// Вычисления
// ----------------------------------------------------------------------------
// Создадим функцию sum, которая складывает элементы массива.
const sum = reduce((a, i) => a + i)(0);
// Функция для получения длины массива.
const length = a => a.length;
// Функция для деления одного числа на другое.
const div = a => b => a / b;
// Мы используем compose() для сборки нашей функции из маленьких вспомогательных функций.
// При работе с compose() код надо читать снизу вверх.
const calcPopularity = compose(
    B1(div)(sum)(length),
    map(prop('popularity')),
    filter(prop('found')),
);
const averagePopularity = calcPopularity(victorianSlang);
console.log("Average popularity:", averagePopularity);

If all this code seems to you complete nonsense - do not worry about it. I included it here as an intellectual exercise, and not in order to upset you.

In this case, the main work is in the function compose(). If you read its contents from the bottom up, it turns out that the calculations begin by filtering the array by the property of its elements found. Then we extract the property of the elements popularitywith map(). After that, we use the so-called " blackbird combinator ". This entity is represented as a function B1that is used to perform two passes of calculations on one set of input data. To better understand this, take a look at these examples:

// Все строки кода, представленные ниже, эквивалентны:
const avg1 = B1(div)(sum)(length);
const avg2 = arr => div(sum(arr))(length(arr));
const avg3 = arr => ( sum(arr) / length(arr) );
const avg4 = arr => arr.reduce((a, x) => a + x, 0) / arr.length;

Again, if you do not understand again, do not worry. This is just a demonstration that JavaScript can be written in very different ways. Of these features, this is the beauty of this language.

5. Solving the problem in one pass with the calculation of the cumulative average value


All of the above software constructs do a good job of solving our problem (including the imperative cycle). Those that use the method .reduce()have something in common. They are based on breaking the problem into small fragments. These fragments are then assembled in various ways. By analyzing these solutions, you may notice that in them we go around the array three times. There is a feeling that it is ineffective. It would be nice if there was a way to process the array and return the result in one pass. This method exists, but its application will require resorting to mathematics.

In order to calculate the average value for array elements in one pass, we need a new method. You need to find a way to calculate the average using the previously calculated average and the new value. We look for this method using algebra.

The average value of the nnumbers can be found using this formula:


In order to find out the average of n + 1numbers, the same formula will do, but in a different entry:


This formula is the same as this:


And the same thing as this:


If you convert this a bit, you get the following:


If you don’t see the point in all this, then it’s okay. The result of all these transformations is that with the help of the last formula we can calculate the average value during a single traversal of the array. To do this, you need to know the value of the current element, the average value calculated in the previous step, and the number of elements. In addition, most of the calculations can be carried out in the reducer function:

// Функция для вычисления среднего значения
// ----------------------------------------------------------------------------
function averageScores({avg, n}, slangTermInfo) {
    if (!slangTermInfo.found) {
        return {avg, n};
    return {
        avg: (slangTermInfo.popularity + n * avg) / (n + 1),
        n:   n + 1,
    };
}
// Вычисления
// ----------------------------------------------------------------------------
// Вычисляем и выводим в консоль среднее значение.
const initialVals       = {avg: 0, n: 0};
const averagePopularity = victorianSlang.reduce(averageScores, initialVals).avg;
console.log("Average popularity:", averagePopularity);

Thanks to this approach, the necessary value can be found bypassing the array only once. Other approaches use one pass to filter the array, another to extract the necessary data from it, and another to find the sum of the values ​​of the elements. Here, everything fits into one pass through the array.

Note that this does not necessarily make the calculations more efficient. With this approach, more calculations have to be done. When each new value arrives, we perform the operations of multiplication and division, doing this to maintain the current average value in the current state. In other solutions to this problem, we divide one number into another only once - at the end of the program. But this approach is much more efficient in terms of memory usage. Intermediate arrays are not used here, as a result we have to store in memory only an object with two values.

However, such a memory efficiency comes at a price. Now in one function we perform three actions. We filter the array in it, extract the number and recount the result. This complicates the function. As a result, looking at the code is not so easy to understand.

What to choose?


Which of the above five approaches to solving the problem can be called the best? In fact, it depends on many factors. Perhaps you need to process a really long array. Or perhaps your code needs to run on a platform on which not much memory is available. In such cases, it makes sense to use the solution to the problem where the array is processed in one pass. But if systemic limitations do not play a role, then one can successfully use more expressive approaches to solving the problem. The programmer needs to analyze his own situation and decide what is best for his application, which is most appropriate to use in his circumstances.

Perhaps someone will now have a question about whether there is a way to combine the advantages of different approaches to solving such a problem. Is it possible to break the task into small parts, but do all the calculations in one pass through the array? You can do it. To do this, you will need to apply the concept of transducers. This is a separate big topic.

Summary


We examined five ways to calculate the average for array elements:

  1. No use .reduce().
  2. Using methods .filter()and .map(), as well as a method .reduce()as a mechanism for finding the sum of numbers.
  3. Using a battery that is an object and stores multiple values.
  4. Using implicit programming techniques.
  5. With the calculation of the cumulative average with a single pass through the array.

What, nevertheless, is it worth choosing for practical use? In fact - you decide. But if you want to find some kind of hint, I will share my opinion on how you can decide on the most suitable way to solve the problem:

  1. Start by using the approach you understand best. If it allows you to achieve the goal - stop on it.
  2. If there is a certain approach that you do not understand, but want to study, solve the problem with its help.
  3. And finally, if you are faced with the problem of running out of memory - try the option where the array is bypassed once.

Dear readers! How do you handle arrays most often in JavaScript projects?


Also popular now: