af83

Building a collection with Enumerable

For our new awesome projet, we're gonna display a collection of kittens. Each kitten is defined by its name, age, and its cuteness. Cuteness being a number between 0 and 100.

Let's say we have our collection of kittens which looks like something like this:

# let's mock something responsible for generating a collection of results
Search = Struct.new(:results)

# Basic representation of a kitty
Kitty = Struct.new(:name, :age, :cuteness)

# Results set
search = Search.new([
  Kitty.new("darwin", 13, 42),
  Kitty.new(:kitty, 37, 97),
  Kitty.new("maru", 8, 64)
])

# Let's iterate
search.results.each do |kitty|
  p kitty.name, kitty.age, kitty.cuteness
end

Ok, this is a perfectly working search feature. We find some results, and display them to the user.

The product owner of this kitten application would like to display the total cuteness of this result set and the average cuteness.

search.results.map(&:cuteness).inject(:+).tap do |cuteness|
  p cuteness
  p cuteness / search.results.size
end

Again, this is working. Yet, something's still bugging me. To implement our awesome features, we make several calls to search.results, which is an internal object of the library. It is not our domain code.

Enumerable

Let's build our own private collection of kittens. An object with this API:

kitten = Kitten.new(search.results)

kitten.each do |kitty|
  p kitty.name, kitty.age, kitty.cuteness
end

p kitten.total_cuteness
p kitten.average_cuteness

Enumerable provides us with a module that implements methods like map, find, any?… The king of methods which make using collection in Ruby such a breeze.

So, we need a Kitten class which includes Enumerable and takes an Array as input.

class Kitten
  include Enumerable

  attr_reader :results
  private     :results

  def initialize(results)
    @results = Array(results)
  end
end

A few words about this basic class declaration. results is set as private because we do not want it to be directly accessed, everything must use the same api, aka the Kitten class itself.

This class accepts as input an object or a collection of objects thanks to the Array method called in the initialize.

Yet, this code does not work. To comply to the Enumerable contract, we need to define a each method. This is the method used behind the scenes by Enumerable to provide other methods like map.

class Kitten
  include Enumerable

  attr_reader :results
  private     :results

  def initialize(results)
    @results = Array(results)
  end

  def each
    results.each {|item| yield item }
  end
end

Kitten.new([Kitty.new("ohai", 42, 100), Kitty.new("kitty", 37, 97)]).each do |kitty|
  p kitty.name
end
# => "ohai"
# => "kitty"

Now, we need to add total_cuteness and average_cuteness.

class Kitten
  include Enumerable

  attr_reader :results
  private     :results

  def initialize(results)
    @results = Array(results)
  end

  def each
    results.each {|item| yield item }
  end

  def total_cuteness
    @total_cuteness ||= sum(&:cuteness)
  end

  def sum(&block)
    results.map(&block).inject(:+)
  end

  def average_cuteness
    total_cuteness / count
  end
end

Now, let' inspect our brand new Kitten collection.

kitten = Kitten.new([Kitty.new("ohai", 42, 100), Kitty.new("kitty", 37, 97)])
p kitten.class
# => Kitten
p kitten.to_a.class
# => Array
p kitten.total_cuteness
# => 197
p kitten.average_cuteness
# => 98
p kitten.count
# => 2
p kitten.sum(&:age)
# => 79

The to_a method creates a new Array based on items present in the collection. So, to_a returns an instance of Array, it is not a Kitten instance anymore.

This gives us a nice interface around a collection. We can easily add useful methods like total_cuteness or methods to comply with the Kaminari pagination interface.

Actually, there's nothing new here. This is one of the most common patterns in the ruby ecosystem, but lots of rails developers use this pattern in libraries like ActiveRecord everyday without noticing that has_many associations are not really simple instances of Array.

blog comments powered by Disqus