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
.