When you're coding in Ruby, one piece of syntax you'll see a lot of is blocks, with either the curly braces or do...end
syntax. So in this tutorial, we will take a look at how we can use blocks as convenient feature of a Ruby developer's toolbelt, and how that compares to Go.
🐼 Looping over collections with Lola
In Go, the idiomatic way to loop over a slice is with the range
keyword, like this:
var doggoTricks = []string{
"roll over", "speak", "shake paws", "toodle-oo",
}
for _, trick := range doggoTricks {
fmt.Printf("Lola! Can you %s?\n", trick)
lola.DoTrick(trick)
}
fmt.Println("Good pupper! Have a biscuit! 🦴")
In the for loop's top line, we loop over the doggoTricks
slice one item at a time, putting the current dog trick in the variable trick
. Then, inside the block, we use the trick
variable to tell my dog Lola to do that trick, and then she does the trick using the function lola.DoTrick
. And because of the curly braces block, the varable trick
is scoped to only exist inside the for loop.
Ruby does have loops of its own, but another cool, and in my opinion quite satisfying way of looping over a Ruby array is with the Array#each method. Doing the same loop in Ruby looks like this:
doggo_tricks = ['roll over', 'speak', 'shake paws', 'toodle-oo']
doggo_tricks.each do |trick|
puts "Lola! Can you #{trick}?"
lola.do_trick trick
end
puts "Good pupper! Have a biscuit! 🦴"
The part do |trick| ... end
is the block, and inside the pair of pipe (these |
) characters, you name the variable that's scoped to the block. Then, inside the block, you can use that variable, such as printing it with puts
and passing it into lola.do_trick
.
In addition to looping over arrays, there's also an each method for hashes that works similarly to looping over maps in Go. And each
isn't the only method that takes in a block; you can also use blocks on methods like Array#sort
to influence how an array is sorted, Array#map
to create a new array by running the code in the block on each item in the current array, and Array#filter
to return an array of only the items where the block's code would return true.
As an example, if we had code like
with_paws = doggo_tricks.filter { |trick| uses_paws trick }
then the with_paws
array would only have tricks where uses_paws
is true, which includes toodle-oo
and shake paws
.
If you read my last tutorial on building a dev.to fetcher in Ruby, by the way, a couple places where we used blocks in this way were:
- with
Array.map
to convert an array of Ruby hashes to an array ofArticle
objects - walking a Markdown document's nodes
By the way, toodle-oo is Lola's signature trick, and it's really cute!
🌺 Having an object only in the part of the code where you need it, as told by making a smoothie
Another great use of blocks in your code is when you want to declare a variable, use it, and then do any clean-up code afterwards, like if you're opening a file, reading it into some kind of data structure, and then closing it when you no longer need that File
variable, for example. When a Ruby API has use cases following that pattern, blocks are a common way to do that pattern.
As a more fun example, when it's a hot day out, nothing cools off a sloth like a refreshing hibiscus and cecropia leaf smoothie, fresh from the blender. Without blocks, the Ruby code would look something like this:
blender = get_blender()
blender.add('ice cubes', 'hibiscus flowers', 'cecropia leaves')
blender.serve_drinks()
# now on to more Ruby code for the rest of a sloth's day!
The one problem with this code is, the blender
variable is still in scope, and if this is in the middle of a long section of Ruby code, it's easy to forget to call clean_up(blender)
and put_back(blender)
.
The Go approach to making sure that code gets called would be to either use defer
like this:
blender := getBlender()
defer func() {
cleanUp(blender)
putBack(blender)
}
blender.Add("ice cubes", "hibiscus flowers", "cecropia leaves")
blender.ServeDrinks()
// now on to more Ruby code for the rest of a sloth's day!
The Go function in the defer
statement runs at the end of the Go code block we're in, so no matter what branches of code we take, cleanUp(blender)
and putBack()
blender will be called.
For a Ruby approach with blocks, let's say that this get_blender
function had clean_up(blender)
and put_back(blender)
built-in. Then, the Ruby code for serving smoothies for sloths would look like this:
get_blender do |blender|
blender.add(
'ice cubes', 'hibiscus flowers', 'cecropia leaves'
)
blender.serve_drinks
end
We put the code we want to run into the block, and use the variable blender
inside the block to add the ingredients and then serve the smoothies. Then, behind the scenes, get_blender
calls the cleanup code. The person using our blender API, and the people reviewing their code, don't need to pore over lines of code to make sure all the cleanup code is called, because that's built right in, saving time and bug fixes for your dev team!
In my last tutorial on building a dev.to image link fetcher in Ruby, by the way, a couple places where we used blocks in this way were:
- Sending an HTTP request to dev.to, deserializing the response from JSON, and then no longer needing the response body to be in-scope
- Generating a CSV file object, and then inside the block adding the image links as rows of the CSV
🍹 Making your own functions that use blocks
One final thing to look at for demystifying Ruby blocks, is how to make your own code that works with them. To do that, we use a keyword called yield
. This keyword confused me when I was first learning Rails, but essentially you can use yield
to do the pattern of:
- Run any code that's before the yield keyword
-
yield
0 or more Ruby objects that can be passed into the block - Then, run any code past the yield keyword
Let's try that out by giving the get_blender
function a definition:
def get_blender
blender = take_blender_out_of_cabinet()
blender.plug_in()
yield blender
clean_up blender
put_away blender
end
We first run take_blender_out_of_cabinet()
and blender.plug_in()
to get the blender ready. Then, we run yield blender
. At that point, the coder using our blender can run a block that has the blender
passed in as a variable in the block's scope. We run that code, and then return to the body of the get_blender
function, where we call clean_up blender
and put_away blender
to clean up after the sloths enjoy a nice tasty smoothie!
You don't need to necessarily just use a block once in a function, by the way. Remember how we used the Array filter
and each
methods with blocks that run on each item in an array? Let's say we had some prep work to do before putting the ingredients into the blender. Before blending the hibiscus flowers and cecropia leaves, let's wash them, which we can do by using yield
inside a loop, like this:
class Blender
def add(*ingredients)
# do prep work for the smoothie here!
for ingredient in ingredients
yield ingredient
add_ingredient_to_blender ingredient
end
self.now_make_the_smoothie!
end
end
For each ingredient we pass into add
, we yield
the ingredient to the block passed in before putting it in the blender. Finally, with all that done, we now_make_the_smoothie!
So now, the code for adding our smoothie ingredients would look like this:
blender.add(
'ice cubes', 'hibiscus flowers', 'cecropia leaves'
) do |ingredient|
if need_to_wash? ingredient
wash ingredient
end
end
In the block we are using, we pass the ingredient into need_to_wash?
, and then wash the ones where need_to_wash?
returns true (ice cubes are already made of water to begin with so no need to wash them). We run the block for each ingredient one at a time, so with all our smoothie ingredients prepped and put in the blender, we finally call blender.now_make_the_smoothies!
to get refreshing hibiscus-cecropia smoothies to get just the energy you need for a long slothful day, brought to you by blocks!
For more information about how yield
and yield_self
, I recommend looking at this blog post by RubyGuides.
Top comments (2)
If you're using this pattern with resources which need to be cleaned up, don't forget to
ensure
! :)Without
ensure
, your cleanup can be bypassed withraise exception
anywhere laterthrow tag
anywhere down the stackbreak
exits the block it's inreturn
exits the method it's inUsing
ensure
get's you back to what you're used to withdefer
.Thanks for sharing that Ruby idiom, I had not heard about that keyword before!