Enumerating collections the Ruby way

One small slice of pizza for my friend

Enumeration is the complete and ordered listing/traversing of all items in a collection. To list over something in Ruby, most of us may start with something like this:

class Collection
  def initialize(items)
    @items = items
  end

  def each(&block)
    @items.each { |item| block.(item) }
  end

  # OR
  def each(&block)
    @items.each(&block)
  end
end

A good follow question will be can we now use the #map and #reduce that we all love? We will be horrified to learn that we need to implement them by hand if we follow the way above!

Fortunately, we can implement our own with the Enumerable module, as do by the Hash, Array and Range classes. By including the Enumerable module in our collection class and implementing the #each method, we will get access to all methods that has an element of listing over elements such as #map, #reduce and #each_slice.

Example

There are two booths in a data collection exercise. The first collects a person's name and the second collects the person's hobbies. After filling up the entire list, the organizer wants to traverse over a person's name and hobbies together. An array of names and a two-dimensional array of hobbies are readied in the same sequence. We can imagine these requirements as a NameHobbiesColletion class to list over a person's name and hobbies in sequence.

class NameHobbiesCollection
  include Enumerable

  def initialize(names, array_of_hobbies)
    @names = names
    @array_of_hobbies = array_of_hobbies
  end

  # This will be the only method we need to implement for traversal.
  def each(&block)
    @names.zip(@array_of_hobbies).each do |name, hobbies|
      block.(name, hobbies)
    end
  end
end

Testing Them Out

  1. Let's setup an instance and see some enumeration methods in action!

    collection = NameHobbiesCollection.new(
      %w(James Joe Tony),
      [
        %w(Talking Baking),
        %w(Science Coding Talking),
        %w(Coffee)
      ]
    )
  2. We can use #each to list over the collecitons and do things:

    collection.each do |name, hobbies|
      puts "#{name} has hobbies of #{hobbies.join(', ')}"
    end
    # => James has hobbies of Taking, Baking
    # => Joe has hobbies of Science, Coding, Talking
    # => Tony has hobbies of Coffee
  3. We can use #map to produce an array of human-readable logs:

    collection.map do |name, hobbies|
      "name:#{name} hobbies:[#{hobbies.join(',')}]"
    end
    # => [
    # =>   "name:James hobbies:[Talking,Baking]",
    # =>   "name:Joe hobbies:[Science,Coding,Talking]",
    # =>   "name:Tony hobbies:[Coffee]"
    # => ]
  4. #partition allows us to group chatty people together and the others together:

    collection.partition do |_name, hobbies|
      hobbies.include? 'Talking'
    end
    # => [
    # =>   [["James", ["Talking", "Baking"]], ["Joe", ["Science", "Coding", "Talking"]]],
    # =>   [["Tony", ["Coffee"]]]
    # => ]
  5. We can use #each_slice to iterate over the collection in chunks, useful for persisting items to the database in batches instead of everything at once:

    collection.each_slice(2).to_a
    # => [
    # =>   [["James", ["Talking", "Baking"]], ["Joe", ["Science", "Coding", "Talking"]]],
    # =>   [["Tony", ["Coffee"]]]
    # => ]

Can we make it chainable?

If we want to chain methods like collection.map.with_index, the colletion is expected to return an Enumerator. To do that, we can rely on the Object#to_enum method to create an enumerator listing over the collection using a specified method from it. Any arguments should be past over.

  def each(*args, &block)
    # When there is no block given, we return an Enumerator that points to this each method!
    return to_enum(__method__, *args) unless block_given?

    # Implementation follows here when there is a block.
  end

Testing them out

  1. Chaining #each with #with_index:

    collection.each.with_index do |(name, hobbies), index|
      puts "queue #{index}: #{name} has hobbies of #{hobbies.join(', ')}"
    end
    # => queue 0: James has hobbies of Talking, Baking
    # => queue 1: Joe has hobbies of Science, Coding, Taking
    # => queue 2: Tony has hobbies of Coffee
  2. Iterating over the collection with an arbitrary object with #each and #with_object and returns it:

    collection.each.with_object({}) do |(name, hobbies), hash|
      hash[name] = hobbies
    end
    # => {
    # =>   "James"=>["Talking", "Baking"],
    # =>   "Joe"=>["Science", "Coding", "Talking"],
    # =>   "Tony"=>["Coffee"]
    # => }
  3. Emitting the entire collections:

    collection.each.entries
    collection.each.to_a
    # => [
    # =>  ["James", ["Talking", "Baking"]],
    # =>  ["Joe", ["Science", "Coding", "Talking"]],
    # =>  ["Tony", ["Coffee"]]
    # => ]

References

Enumerator includes the Enumerable module. Explore more methods availbale in the Enumerator module below:

  1. Enumerable: https://ruby-doc.org/core-3.0.2/Enumerable.html
  2. Enumerator: https://ruby-doc.org/core-3.0.3/Enumerator.html