Software Development
Krzysztof Buszewicz
Krzysztof Buszewicz
Senior Software Engineer
2022-07-12

Loop with an object - it doesn't always have to be a hash!

Read an article coming from our Ruby Expert and learn why you don't need to always youse hash.

Introduction

When we want to aggregate some stuff, very often we use #each_with_object or extend the regular loop using #with_object. But in most cases Ruby developers are using a plain hash as the aggregator and maybe it's fine, but in this article, I'd like to show you that it doesn't always have to be a hash.

Case

We assume that all the files are placed in one directory (people).

Let's say we have the following people/people.csv file:

First Name,Last Name,Age
John,Doe,24
Jane,Dee,45
Josh,Bee,55
Andrea,Boya,34
Andrew,Moore,54

We want to find the total of rows and the average age - we could write the following script:

# people/parser.rb

require 'csv'

aggregated = CSV.foreach('people.csv', headers: true)
                .with_object({ total: 0, total_age: 0 }) do |row, agg|
                  agg[:total] += 1
                  agg[:total_age] += row['Age'].to_i
                end

total = aggregated[:total]
average_age = aggregated[:total_age].to_f / total

puts "Total: #{total}"
puts "Average age: #{average_age}"

And yes, it does the thing but reading such a code is a doubtful pleasure. It feels like a too low level. We can improve it by providing a dedicated aggregator for the loop.

# people/age_aggregator.rb

class AgeAggregator
  attr_reader :total, :total_age

  def initialize
    @total = 0
    @total_age = 0
  end

  def increment!
    @total += 1
  end

  def increment_age!(age)
    @total_age += age
  end

  def average_age
    total_age.to_f / total
  end
end

And then our loop would look as below:

# people/parser.rb

require 'csv'
require_relative './age_aggregator.rb'

aggregated = CSV.foreach('people.csv', headers: true)
                .with_object(AgeAggregator.new) do |row, agg|
                  agg.increment!
                  agg.increment_age!(row['Age'].to_i)
                end

puts "Total: #{aggregated.total}"
puts "Average age: #{aggregated.average_age}"

I think it's much clearer.

Summary

We've written more code, but our lower-level details are extracted to the separate class. Now the main script reads much better.

Of course, you can argue that the example is too simple to put so much effort into refactoring, but c'mon - it's just an example ;). If you had to aggregate more data, such aggregator objects are really the way to rescue.

Ruby Developer Offer

Read more:

Pros and cons of Ruby software development

Rails and Other Means of Transport

Rails Development with TMUX, Vim, Fzf + Ripgrep