loading...

Ruby Enumerables Make Your Code Short and Sweet

keithrbennett profile image Keith Bennett ・3 min read

One of the most amazing things about Ruby is the richness of its Enumerable library; there are so many things it can do. Another is Ruby's ability to express intent with the utmost conciseness and clarity. However, out in the wild I very often see code that fails to take full advantage of these qualities.

As a contrived example, let's say we're keeping track of letter frequencies in a document. We define a class to contain them as:

LetterFrequency = Struct.new(:letter, :frequency, :vowel?)

I've seen a lot code that looks like this:

def filtered_and_transformed_records_1(records)
  results = []

  records.each do |record|
    next unless record.vowel?
    results << [record.letter, record.frequency]
  end

  results
end

In more primitive languages one must use these approaches, but in Ruby we have some major refactorings that can make this code much, much simpler.

First, we can use each_with_object to eliminate the need for the explicit initialization of the function-local variable containing the array and its explicit return, on the first and last lines of the method:

def filtered_and_transformed_records_2(records)
  records.each_with_object([]) do |record, results|
    next unless record.vowel?
    results << [record.letter, record.frequency]
  end
end

I say function-local because we do need the block-local variable results inside the each_with_object block. However, we've narrowed the scope of the results variable, and that's always a good thing.

each_with_object is like each except that it will pass two variables to the block instead of one. In addition to the object from the Enumerable that each passes, it passes the object you are using to accumulate results. You initialize the accumulator by passing its initial value to the each_with_object method. In this case we are passing a newly created empty array.

each_with_object's return value is the accumulator object, so you don't need to specify the accumulator explicitly for it to be the value returned by the method.

The each_with_object usage may not feel natural at first, but once you've seen it a few times your mind will parse it with almost zero effort. (By the way, I always had trouble remembering the order of its arguments until I realized that they were in the same order as in the method name itself; each for the enumerated object and object for the accumulator object.)

The second refactoring is instead of using control flow constructs like next, we can use the Enumerable methods select or reject. We could refactor the code further into:

def filtered_and_transformed_records_3(records)
  records.select(&:vowel?).each_with_object([]) do |record, results|
    results << [record.letter, record.frequency]
  end
end

After this refactoring, we see the filter where it is more appropriate and helpful. Instead of it being on a line inside the block, it's just a few characters immediately after the input array (records.select...).

We've already simplified this method quite a bit, but there's even more we can do. Because select returns the filtered array, we can simplify even further by using map instead of each_with_object!:

def filtered_and_transformed_records_4(records)
  records.select(&:vowel?).map { |record| [record.letter, record.frequency] }
end

Although as software developers our mission is to deliver functionality, the other side of that coin is to do so as simply as possible. Put otherwise, we need to remove accidental complexity (a.k.a. incidental complexity) so that only the essential complexity remains. The functional approaches described here are extremely effective at doing this. We've ended up with a simple one-liner.


Whenever you start feeling that your code is getting verbose or awkward, ask yourself "could I improve this code with Enumerable?" The answer may well be yes.


For your reference, here is a file that contains the methods in the article, and verifies that they all produce the same result.


[Note: This article may occasionally be improved. Its commit history is here.]

Discussion

pic
Editor guide