In Progress
Unit 1, Lesson 1
In Progress

Advanced Next

Video transcript & code

In episode #261 we introduced the next block control keyword. We saw how it can be used to skip forward when iterating over lists.

As we ended that episode, we were looking at what happens when we use next in a mapping context. We mapped over a list of strings. When we skipped empty strings using next, this produced nil values in the resulting output array.

objects = ["house", "mouse", "", "mush", "",
           "little old lady whispering 'hush'"]

result = objects.map do |o|
  next if o.empty?
  "Goodnight, #{o}"
end

result
# => ["Goodnight, house",
#     "Goodnight, mouse",
#     nil,
#     "Goodnight, mush",
#     nil,
#     "Goodnight, little old lady whispering 'hush'"]

But what if we instead want to substitute a special string whenever the input is blank? Do we have to switch over to using an if/else statement?

As a matter of fact, we can accomplish this goal with next as well. Let's say we just want to return the string "Goodnight, Moon" wherever the input data is empty. By supplying an argument to next, we accomplish exactly that.

objects = ["house", "mouse", "", "mush", "",
           "little old lady whispering 'hush'"]

result = objects.map do |o|
  next "Goodnight, Moon" if o.empty?
  "Goodnight, #{o}"
end

result.compact
# => ["Goodnight, house",
#     "Goodnight, mouse",
#     "Goodnight, Moon",
#     "Goodnight, mush",
#     "Goodnight, Moon",
#     "Goodnight, little old lady whispering 'hush'"]

What we see here is that the argument to next becomes the return value of the current invocation of a block.

Let's look at a more concrete example of using next with an argument. We have a list of filenames, which we want to winnow down using #select. It's a long list, and it's inefficient to iterate over it multiple times. So we want to get all of our rules for inclusion or exclusion in one place. To do that, we use a series of next statements.

The first statement checks that the filename represents an actual file, rather than a directory or a pipe or some other special entity. Since there is no point performing more checks if it's not a regular file, we skip the remaining checks using next with a false argument. This will cause the block to return false, telling #select that the current filename should not be included in the results.

Next we check that the file is readable by the current user, and skip forwards if it is not.

The next check is a little different. It identifies a file with a name that doesn't match the same naming pattern that all the other files have. We happen to know that we want to include that specific file, so we invoke next with a true argument. This skips all remaining tests and tells #select to go ahead and include this entry in the results.

Next is a test intended to exclude zero-length files. And then there is a final test that includes files matching a particular naming scheme.

Dir["../**/*.mp4"].select { |f|
  next false unless File.file?(f)
  next false unless File.readable?(f)
  next true if f =~ /078b-java-dregs\.mp4/
  next false if File.size(f) == 0
  next true if File.basename(f) =~ /^\d\d\d-/
}

This isn't the only way we could have written this code. We also could have structured it as a single chained boolean expression. But I find that boolean expressions tend to become harder to read the bigger they are, especially when they involve a lot of negation. I like how each line in this block is a self-contained rule which could be safely removed by deleting a single line.

I also like the fact that if we wanted to, we could step through these rules one by one in a debugger. That's not always the case with chained boolean expressions.

So far we've been discussing iteration. It's easy to talk about keywords like next and break and redo as if they are loop control operations. Partly because that is the context that they are commonly found in, and partly because it's easier to understand them by analogy to other languages that have dedicated loop-control operations.

It's important to understand, however, that in Ruby these keywords aren't really loop control operations. They are something more generalized: they are block-control operators. They work anywhere that blocks are passed and invoked, regardless if there is anything like iteration going on.

To hopefully make this principle clear, let's write a little method which accepts a block. It yields to the block twice, logging before and after each yield.

There is no looping or iterating over a list going on here, just two yields. Let's call this method, passing it a block. The block will log, invoke next, and then log again.

Finally, we log the moment the method returns.

When we execute this code, we can see that the block is, in fact, executed twice, just as intended. Each time, it invokes next, which causes control to be passed back to the yieldtwice method before the block can do anything else. The last line of the block is never reached. Again we can see how this behaves like an early return, except for a block instead of a method.

def yieldtwice
  puts "Before first yield"
  yield
  puts "Before second yield"
  yield
  puts "After last yield"
end

yieldtwice do
  puts "About to invoke next"
  next
  puts "Can never get here"
end
puts "After method call"

# >> Before first yield
# >> About to invoke next
# >> Before second yield
# >> About to invoke next
# >> After last yield
# >> After method call

Now let's take one last look at the difference between next and break. Instead of invoking next in the block, we'll invoke break.

def yieldtwice
  puts "Before first yield"
  yield
  puts "Before second yield"
  yield
  puts "After last yield"
end

yieldtwice do
  puts "About to invoke break"
  break
  puts "Can never get here"
end
puts "After method call"

# >> Before first yield
# >> About to invoke break
# >> After method call

This time, we can see that execution only gets as far as the first break before the whole method exits. Unlike next, break doesn't just bring a yield to an early end. It cancels the execution of the whole method that triggered the yield. That is, it forces an early return of the call to yieldtwice.

In working through these two examples, we can begin to see how break and next can effectively function as iteration control operators, because in Ruby iteration is always expressed using blocks. But in fact they don't know anything about loops or iteration; they are all about controlling the execution of blocks and the methods that yield to those blocks.

And that's probably enough to digest in one day. Happy hacking!

Responses