3
\$\begingroup\$

I want to find average of an array of objects based on their key values using the new functional programming style. I found my way around array reduce and solved my problem, but not sure if this is the best way to do it.

Please take a look at my code and see if this is the way to use reduce for my purpose.

Let's say I have an array of objects as follows:

private data = [
    {tv: 1, radio:5,fridge:4},
    {tv: 2, radio:2,fridge:null},
    {tv: 3, radio:6,fridge:5}
];

I want to create another array containing the averages of each of the items in my data array. What I have, and is working, is below:

function summary(){
    var keys= Object.keys(data[0]);
    var sums = {};
    var averages = Object.keys(this.data.reduce((previous, element) => {
        keys.forEach(el => {
            if(element[el] !== null){
                if (previous.hasOwnProperty(el)) {
                    previous[el].value += element[el];
                    previous[el].count += 1;
                } else {
                    previous[el] = {
                        value: element[el],
                        count: 1
                    };
                }
            }
        });
        return previous;
    }, sums)).map(name => {
        return {
            name: name,
            average: sums[name].value / sums[name].count
        };
    });
    console.log(averages);
}

Running the code will give me my expected results:

average = [ 
    { "name": "tv", "average": 2 },
    { "name": "radio", "average": 4.333333333333333 }, 
    { "name": "fridge", "average": 4.5 } 
]

But is this the best way to solve my problem using new reduce functions?

\$\endgroup\$
0

3 Answers 3

5
\$\begingroup\$

Here is possibly an even more functional programming style solution, which makes use of a temporary ES6 Map object. This has the advantage over a plain object: you can turn it into an array of pairs, and chain on that to get the final result:

var data = [
    {tv: 1, radio:5, fridge:4},
    {tv: 2, radio:2, fridge:null},
    {tv: 3, radio:6, fridge:5}
];

var avg = Array.from(data.reduce(
        (acc, obj) => Object.keys(obj).reduce( 
            (acc, key) => typeof obj[key] == "number"
                ? acc.set(key, (acc.get(key) || []).concat(obj[key]))
                : acc,
        acc),
    new Map()), 
        ([name, values]) =>
            ({ name, average: values.reduce( (a,b) => a+b ) / values.length })
    );

console.log(avg);

Instead of immediately summing up the values, this code first collects the different values into an array per property, in a Map, then it calculates the averages from those arrays, turning it into the desired target structure.

Alternative output structure

Personally I find it more logical to produce output that has the same structure as the input objects, so I provide this very similar alternative. Only the final map is replaced by a reduce:

var data = [
    {tv: 1, radio:5, fridge:4},
    {tv: 2, radio:2, fridge:null},
    {tv: 3, radio:6, fridge:5}
];

var avg = Array.from(data.reduce(
        (acc, obj) => Object.keys(obj).reduce( 
            (acc, key) => typeof obj[key] == "number"
                ? acc.set(key, (acc.get(key) || []).concat(obj[key]))
                : acc,
        acc),
    new Map())).reduce( 
        (acc, [name, values]) =>
            Object.assign(acc, { [name]: values.reduce( (a,b) => a+b ) / values.length }),
        {}
    );

console.log(avg);

Performance improvement

As you asked in comments about performance, I tried to improve on it, without giving up on functional programming.

I took my first code version (which will be more performant than the second), and changed the first half of the algorithm: the numbers are now summed up immediately, keeping a count next to it. For this I introduced an immediately invoked (arrow) function:

var data = [
    {tv: 1, radio:5, fridge:4},
    {tv: 2, radio:2, fridge:null},
    {tv: 3, radio:6, fridge:5}
];

var avg = Array.from(data.reduce(
        (acc, obj) => Object.keys(obj).reduce( 
            (acc, key) => typeof obj[key] == "number"
                ? acc.set(key, ( // immediately invoked function:
                        ([sum, count]) => [sum+obj[key], count+1] 
                    )(acc.get(key) || [0, 0])) // pass previous value
                : acc,
        acc),
    new Map()), 
        ([name, [sum, count]]) => ({ name, average: sum/count })
    );

console.log(avg);

This stays within the functional programming rules, but I expect better performance than the first two versions I posted.

\$\endgroup\$
6
  • 1
    \$\begingroup\$ Spreading is overrated. You should use Array.from, especially when you use a mapping callback, so that you can avoid the intermediate array. \$\endgroup\$
    – Bergi
    Commented Sep 3, 2016 at 22:58
  • 1
    \$\begingroup\$ @Bergi, updated to use Array.from. In 1st snippet also with the use of the mapping callback. Nice to save an intermediate array like that. \$\endgroup\$
    – trincot
    Commented Sep 3, 2016 at 23:09
  • \$\begingroup\$ @trincot I spent a good amount of time studying your code. It is brilliant! I learned a lot from your code. I liked the way that stores the results in the same structure as the input. Do you know by which way would be faster using all functional style as your code or mixed way as code provided by Bergi. I should compare them with a big array to see which one is faster. \$\endgroup\$
    – Afi
    Commented Sep 4, 2016 at 15:02
  • \$\begingroup\$ I guess that Bergi's code would be faster. I focussed more on the functional programming style. Mainly the concat I have in my code to maintain an array will cost in performance. But it was the price to pay for pure functional programming, which I understood was the main target of your question. If you were looking for best-performance, I would have answered differently ;-) \$\endgroup\$
    – trincot
    Commented Sep 4, 2016 at 15:17
  • \$\begingroup\$ I just added another alternative in my answer which I expect to perform better, but which still sticks to functional programming. But functional programming does not allow mutation of values, so alternatives that do will often be faster. \$\endgroup\$
    – trincot
    Commented Sep 4, 2016 at 15:47
2
\$\begingroup\$

Your approach is mostly OK, but I can see some points that could be improved:

  • Having to assign sums to a variable so that you can map over its keys is ugly. Yes, there's no better way to that with builtin JS methods, but this thing should be factored out into a helper function
  • Instead of using forEach, you can nest reduce calls. Or just use a simple for in loop over each object's properties - it doesn't matter, you have to execute side effects anyway.
  • Instead of starting with an empty object, you could start with an object where all the known properties are initialised to nothing. That would avoid the hasOwnProperty check (which, as you have written it, is fragile anyway).
  • You have a bug for the case that data.length is 0 - Object.keys will fail.
  • Don't put the console.log inside summary, put it around the call and return your averages. Also I'm not sure where this.data comes from, better take it as a parameter.

function mapObject(o, fn) {
    return Object.keys(o).map(k => fn(o[k], k));
}
function summary(data) {
    if (!data.length) return {};
    var keys = Object.keys(data[0]);
    return mapObject(data.reduce((previous, element) => {
        return keys.reduce((previous, k) => {
            if (element[k] != null) {
                previous[k].value += element[k];
                previous[k].count += 1;
            }
            return previous;
        }, previous);
    }, keys.reduce(function(sums, k) {
        sums[k] = {value: 0, count: 0};
        return sums;
    }, {})), (x, name) => ({
        name,
        average: x.value / x.count
    }));
}

or

function summary(data) {
    return mapObject(data.reduce((previous, element) => {
        for (k in element) {
            if (element[k] == null) continue;
            if (k in previous) {
                previous[k].value += element[k];
                previous[k].count += 1;
            } else {
                previous[k] = { value: element[k], count: 1 };
            }
        }
        return previous;
    }, {}), (x, name) => ({
        name,
        average: x.value / x.count
    }));
}
\$\endgroup\$
4
  • \$\begingroup\$ Thanks @Bergi for your comments. nesting reduce makes a perfect sense and is more elegant. I couldn't find anything about mapObject and my plunker gave me error could you explain more about it please. \$\endgroup\$
    – Afi
    Commented Sep 3, 2016 at 21:40
  • \$\begingroup\$ @Afi mapObject is just the thing you had factored out. Sorry I forgot to include it in the first version of my answer. \$\endgroup\$
    – Bergi
    Commented Sep 3, 2016 at 22:56
  • \$\begingroup\$ Thanks @Bergi for clarifying it. I learned from your code. I would like to compare these different approches with the first one from trincot to see which one is faster in big arrays. \$\endgroup\$
    – Afi
    Commented Sep 4, 2016 at 15:04
  • \$\begingroup\$ @Afi the concat in his first version is probably a perf-killer. And there's nothing wrong with summing immediately. Apart from that, using a Map instead of an object is probably a lot cleaner (I kinda forgot that this is tagged ES6, I should've remarked that as well) and iterating it should be faster than mapObject with its intermediate array, though engine optimisations for ES6 are not yet as sophisticated as they could be - I'd recommend the more modern style anyway. Looking forward to hear about your benchmark results! \$\endgroup\$
    – Bergi
    Commented Sep 4, 2016 at 16:04
1
\$\begingroup\$

a little bit shorter version, using Object.entries() (support - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/entries#Browser_compatibility) on the given data array starting from the second item. Also passing to reduce() initial value (as second parameter) - first data item:

var averages = Object.entries(data.slice(1).reduce((res, curr) => {
  return {
    tv: res.tv + curr.tv, 
    radio: res.radio + curr.radio, 
    fridge: res.fridge + curr.fridge
  }
}, data[0])).map(val => {
  return {
    name: val[0],
    average: val[1] / data.length
  };
});

console.log(averages);

I also count fridge: null as fridge: 0, and take in a consideration when calculating average, so I have average of 9 / 3 = 3, while your code produces 9 / 2 = 4.5

\$\endgroup\$
5
  • \$\begingroup\$ This assumes certain properties, while OP's code does not. \$\endgroup\$
    – trincot
    Commented Sep 3, 2016 at 21:02
  • \$\begingroup\$ @Andriy thanks for your response. this looks clean, but not many browsers have ECMAScript 2017 and i couldn't test it. Other thing is that here null wouldn't mean zero and should not be included in average calculations. But I liked the fact you set the first object as the initial value. I would not mark it as an answer, but i learned from your response, so here you are an up-vote. thanks \$\endgroup\$
    – Afi
    Commented Sep 3, 2016 at 21:35
  • \$\begingroup\$ This fails horribly on an empty data array. \$\endgroup\$
    – Bergi
    Commented Sep 3, 2016 at 22:59
  • \$\begingroup\$ @Bergi - lol, what result were you hoping to get when asking for an average of nothing? Most functions will "fail" when you pass them invalid parameters. \$\endgroup\$
    – ashleedawg
    Commented Mar 7, 2020 at 0:38
  • \$\begingroup\$ @ashleedawg Either 0, Infinity, NaN or a proper error message. Not "TypeError: Reduce of empty array with no initial value" \$\endgroup\$
    – Bergi
    Commented Mar 7, 2020 at 13:26

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.