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