Ruby reduce method usage example

Reduce method can be used to reduce a set of values to a single number / variable by performing some operation on an individual element. If unaware, you may end up using iterations such as each, map, sum to obtain the result. This tutorial illustrates how we can use reduce appropriately.

Today, I was working on an optimization work on one of the Ruby on Rails project. There was an issue with sum displayed on the consumer side.

I started debugging by finding out the controller action which returns the response that was being displayed on the UI. The value that was getting displayed on the front-end was 0. I was expecting it to be some value instead of 0.

Let’s see the example of the scenario. Let’s say, we have review score for the category of products. While debugging I came across this piece of code.

# Returns a map of review score based by a category
def category_wise_review_score
  { 1 => 9.7, 2 => 6.3, 3 => 4.4 }
end

# Returns category IDs based on category name inputs.
# Considered static category name(s) for the illustration.
def category_ids
  Category.where(name: ['electronics', 'kitchen']).ids
end

# Returns sum of review score(s) of all categories
def total_score
  category_wise_review_score.slice(category_ids).values.map(&:to_i).sum
end

Now, the method total_score was returning output as 0.

Can you try and find out the reason why it could return value 0 by looking at the method total_score again?

Incorrect usage of slice

Yes, the slice method is used incorrectly in total_score method. The method total_score performs following actions.

  • slice key value pairs from category_wise_review_score having keys as category IDs
  • Get the values from resulting hash
  • Convert the invidual element into an array using map
  • Sum the values from resultant array

And return the response.

I checked out git history to see why this change was done this way. I found out the older implementation as below.

# Returns a map of review score based by a category
def category_wise_review_score
  { 1 => 9.7, 2 => 6.3, 3 => 4.4 }
end

# Returns category IDs based on category name inputs.
# Considered static category name(s) for the illustration.
def categories
  Category.where(name: ['electronics', 'kitchen'])
end

# Returns sum of review score(s) of all categories
def total_score
  sum = 0
  categories.each do |category|
    sum += category_wise_review_score[category.id].to_i
  end
  sum
end

Basically, it did not have category_ids method, instead it has cateogries method which returned ActiveRecord collection. And, total_score method called categories method and performed each operation which performed query like,

select * from categories where name in ('electronices', 'kitchen');

So, we understand from this that,

Author wanted to avoid selecting all columns from categories when querying.

As calling,

Category.where(name: ['electronics', 'kitchen']).ids

just performs a query on id column of the categories as given below.

select categories.id from categories where name in ('electronices', 'kitchen');

The author was right to avoid selecting unnecessary columns to be not used when querying categories.

So, where was the problem?

The problem was in the usage of slice method.

category_wise_review_score.slice(category_ids).values.map(&:to_i).sum

slice expects comma separated values to be sliced. Thus, array argument needs be passed as *category_ids as given below.

category_wise_review_score.slice(*category_ids).values.map(&:to_i).sum

This fixes the behavior and returns an expected total_score.

Can we still improve?

Yes, the way query was avoided by using Ruby methods such as, slice, values, map and sum was just unnecessary. Calling all these methods introduces 4 iterations on the hash. Thus, thing to learn from this:

Less is not always better

Don’t just go for one liners as a part of code optimization.

The total_score method was changes as given below.

# Returns sum of review score(s) of all categories
def total_score
  category_ids.reduce(0) do |sum, category_id|
    sum += category_wise_review_score[category_id].to_i
    sum
  end
end

tl;dr;

There is more than one way to achieve what we want in Ruby. Try and know Ruby / Rails methods and understand when they can be used appropriately to avoid such issues.