Why Ruby blocks exist, part II

Putting return values to work

Last time, we showed you how to use Ruby’s each method with blocks to process the elements of an array, and how it can save you a lot of repetitive looping code. That was just an introduction, though.

In our previous examples, the block was a final destination. We passed data into blocks, but it never came back out (unless we printed it to the screen). Today we’re going to look at block return values, and how your methods can use them to manipulate your data in even more powerful ways.

Before we move on…

We should mention that there are two syntaxes for blocks in Ruby. In the earlier post, we used the do ... end syntax:

my_method(my_argument) do |my_parameter|
  # Code here
end

With blocks that fit on one code line, it’s often preferred (though not required) to use the alternate curly-brace syntax for blocks:

my_method(my_argument) {|my_parameter| # Code here }

We’ll be using the curly-brace style of blocks today.

Blocks can return a value

We saw in the previous post that if you give arguments to the yield keyword, they’ll be passed to the block as a parameter, kind of like arguments to a method.

What we didn’t show you before is that, also like methods, blocks can return a value. Every time code in a block runs, the result of the last statement executed becomes the return value of the block. We can access this return value by storing the value of the call to yield.

def print_block_value
  block_value = yield
  puts block_value
end

print_block_value { 2 + 2 } # Prints "4".
print_block_value { "hello".reverse } # Prints "olleh".

Putting return values to work

The map method

One useful method that uses a block’s return value is map, which calls a block for each element of a collection, and builds a new array out of the values the block returns:

numbers = [1, 3, 5]
squares = numbers.map {|number| number ** 2}
p squares # Prints '[1, 9, 25]'

names = ['Bob', 'Alice', 'Carol']
mirrors = names.map {|name| name.reverse}
p mirrors # Prints '["boB", "ecilA", "loraC"]'

[By the way, we’ll be making heavy use of the p method in this post, because it prints arrays in an easy-to-inspect format.]

If you wanted to write your own method similar to map, it might look something like this:

def my_map(collection)
  # This new array will hold the block return values
  results = []
  # Loop through the collection members
  collection.each do |item|
    # Pass the item to the block, and note the return value
    current_result = yield(item)
    # Append the result to the output array
    results << current_result
  end
  # Return the new set of values derived via the block
  return results
end

numbers = [1, 3, 5]
p my_map(numbers) {|number| number ** 3} # Prints '[1, 27, 125]'
names = ['Bob', 'Alice', 'Carol']
p my_map(names) {|name| name.length} # Prints '[3, 5, 5]'

The find_all method

The map method gives you the return values from the block directly in its output, but it’s possible to use return values in other ways, too. The find_all method gives you only the members of a collection for which a block returns a true value (rejecting those for which the block returns false). Because it uses a block, the criteria for selecting items can be whatever you want.

numbers = [1, 2, 3, 4, 5]
p numbers.find_all {|number| number.odd?} # Prints '[1, 3, 5]'

names = ['Joe', 'Stephen', 'Dave', 'Anna']
p names.find_all {|name| name.length > 3} # Prints '["Stephen", "Dave", "Anna"]'

If you read the above as “find all numbers that are odd” or “find all names whose length is greater than 3 characters”, it’s a lot more intuitive than thinking about the true or false return values.

A custom implementation of find_all might look like this:

def my_find_all(collection)
  # This array holds the items selected from the collection
  results = []
  collection.each do |item|
    # Pass the item to the block, and note the return value
    item_meets_criteria = yield(item)
    if item_meets_criteria
      results << item
    end
  end
  return results
end

numbers = [1, 2, 3, 4, 5]
p my_find_all(numbers) {|number| number.even?} # Prints '[2, 4]'

names = ['Joe', 'Stephen', 'Dave', 'Anna']
p my_find_all(names) {|name| name.include? "a"} # Prints '["Dave", "Anna"]'

But wait, there’s more!

There are many more methods that use block return values to work with collections. Here’s a sampling:

  • all?/any?: Returns true if the block value is true for all members of a collection (or any member for any?).
  • grep: Returns all members that are equal to the method argument. Doesn’t require a block but will pass matching members to the block if present.
  • group_by: Splits the collection into groups, named according to the block’s return value.
  • inject: Passes two arguments to a block – the last value returned from the block and the next value to process. Often used for summing collections.
  • max_by/min_by: Selects the item for which the block returned the largest (or for min_by, the smallest) value.
  • sort: Doesn’t require a block, but uses the block return value to decide how the collection is sorted, if it’s present.

That’s all for now…

Block return values add a great many more methods to our toolkit. Using them takes a little getting used to, but they can greatly simplify your code once you get the hang of them.

We’re still not done, though. Everything we’ve shown you has worked with arrays, but there are many other types of collections you can use these exact same methods with. And blocks aren’t just for working with collections, either. We’ll look at more of the possibilities in an upcoming post. Stay tuned!

Editor’s note: This post is adapted from Jay’s upcoming book, Head First Ruby.

tags: , ,