Ruby Search Enumerators

Objectives

  1. Understand return values for enumerators.
  2. Use a truthy or falsey evaluation in a block.
  3. Use #select to select matching elements from a collection based on a block.
  4. Use #detect to find a matching element from a collection based on a block.
  5. Use #reject to filter matching elements from a collection based on a block.

Overview

Every method in ruby must return a value. When we iterate or enumerate over a collection with #each, the return value is always the original collection. This is an example of a static return value, no matter what we do with #each, it will always return the same object that received the call to #each.

["Red", "Yellow", "Blue"].each do |color|
  puts "There are #{color.length} letters in #{color}"
end #=> ["Red", "Yellow", "Blue"]

Often we want to search for elements in a collection based on a condition. Imagine wanting to find all even numbers in a collection of numbers using #each.

matches = []
[1,2,3,4,5].each do |i|
  matches << i if i.even? # add i to the matches array if it is even
end #=> [1,2,3,4,5]
matches #=> [2,4]

Implementing a selection routine with a low-level enumerator like #each is costly in a few ways.

  1. We have to maintain state with the local array matches.
  2. Our block is complicated with conditional logic that can be implicit with a better enumerator.
  3. Our code lacks intention and clear semantics. If we mean, #find_all or #select, why don't we just say that?

#select

When you evoke #select on a collection, the return value will be a new array containing all the elements of the collection that cause the block passed to #select to return true. That means for each iteration, if the block evaluates to true, the element yielded to that iteration will kept in the return value array.

[1,2,3,4,5].select do |number|
  number.even?
end #=> [2,4]

In the first iteration of the block above, number will be assigned the value 1. Because 1.even? will return false, 1 will not be in the return array for this call to #select (same for 3 and 5). In the second iteration, number will be 2. Because 2.even? will return true, 2 will be in the return array (same for 4).

You can see the clarity and expressiveness of this syntax in the short block form below.

[1,2,3,4,5].select{|i| i.odd?} #=> [1,3,5]

[1,2,3].select{|i| i.is_a?(String)} #=> []

Notice that if no element makes the block evaluate to true, an empty array is returned.

#detect

Whereas #select will return all elements from the original collection that cause the block to evaluate to true, #detect will only return the first element that makes the block true.

[1,2,3].detect{|i| i.odd?} #=> 1

As you can see, even though both 1 and 3 would cause the block to evaluate to true, because 1 is first in the array, it alone is returned.

[1,2,3,4].detect{|i| i.even?} #=> 2
[1,2,3,4].detect{|i| i.is_a?(String)} #=> nil

Notice also that #detect will always return a single object where #select will always return an array.

#reject

#reject will return an array with the elements that make the block true removed.

[1,2].reject{|i| i.even?} #=> [1]

Conclusion

#select, #detect, and #reject are part of a family of search and filter type enumerators whose purpose is to help you refine a collection to only matching elements. They are way easier to manage then using lower-level methods like #each and create meaningful return values based on expressions in a block.