Ruby Yield and Blocks as a Sandwich
Let’s look at yield and blocks by writing a method to make a sandwich.
def sandwich(bread_type, filling)
puts bread_type
puts filling
puts bread_type
end
sandwich("Whole Wheat", "Jelly")
Whole Wheat
Jelly
Whole Wheat
This is great but marketing quickly tells us our one filling sandwiches are way too simple. They want a sandwich function that can have any style of filling in the center. To make it flexible we will define how the filling is printed when we call the function. To do this we use an implicit block.
# The block is implicit because it is NOT defined as an argument
# Read the first line and there is NO sign this function takes a block
def sandwich(bread_type)
puts bread_type
# Here is the only sign this function takes a block.
# Yield says, "Hey, there should be a block. Call it here"
yield
puts bread_type
end
# Everything between the do and the end is our block
# It does not run before or after the sandwich function
# Instead it runs inside the sandwich function IF yield is called
sandwich("Whole Wheat") do
puts "Peanut Butter"
puts "Honey"
puts "Bananas"
end
Whole Wheat
Peanut Butter
Honey
Bananas
Whole Wheat
This is great! People start to make all sorts of sandwiches!
sandwich("Bagel") do
puts "Cream Cheese"
puts "Lox"
end
Bagel
Cream Cheese
Lox
Bagel
But as users often do someone does something unexpected. They decide they don’t want a filling so the just skip the block!
sandwich("Bagel")
Bagel
Traceback (most recent call last):
5: from /Users/rose/.rvm/rubies/ruby-2.6.3/bin/irb:23:in `<main>'
4: from /Users/rose/.rvm/rubies/ruby-2.6.3/bin/irb:23:in `load'
3: from /Users/rose/.rvm/rubies/ruby-2.6.3/lib/ruby/gems/2.6.0/gems/irb-1.0.0/exe/irb:11:in `<top (required)>'
2: from (irb):74
1: from (irb):56:in `sandwich'
LocalJumpError (no block given (yield))
That didn’t go so well! The problem is our function assumes there will always be a block. What if we want the option of a block but we also want the option of NOT having a block? Besides yield, ruby comes with a call to check if there is a block.
def sandwich(bread_type)
puts bread_type
# block_given? returns true if there is a block
yield if block_given?
puts bread_type
end
# Now we can make a basic sandwich.
# But is it really a sandwich?
sandwich("Bagel")
Bagel
Bagel
One nice thing about blocks is they can have access to the scope where they are defined. For example, what if I always want to use the same cheese?
cheese = "Cheddar"
sandwich("Whole Wheat") do
# I have access to cheese which was defined outside the block
puts cheese
end
Whole Wheat
Cheddar
Whole Wheat
But the block can also gain access to the variables INSIDE the method that calls it by using arguments to yield. What if I want to support a double-decker sandwich?
def sandwich(bread_type)
puts bread_type
# Note that yield is being called with an argument
yield(bread_type) if block_given?
puts bread_type
end
# Note that my block now takes an argument called layer
sandwich("Whole Wheat") do |layer|
puts "Cheese"
puts layer
puts "Cheese"
end
Whole Wheat
Cheese
Whole Wheat
Cheese
Whole Wheat
It can help to think about this in a slightly different way. Imagine your block is just a special function named yield that can ONLY be called inside sandwich:
# DO NOT write your code like this!
# This is just a visual on how to think about what yield does
# in the above example
def yield(layer)
puts "Cheese"
puts layer
puts "Cheese"
end
def sandwich(bread_type)
puts bread_type
yield(bread_type)
puts bread_type
end
One nice thing about passing arguments into yield. The calling block does NOT have to pay attention to them. This still works:
def sandwich(bread_type)
puts bread_type
yield(bread_type) if block_given?
puts bread_type
end
sandwich("Bagel") do
puts "Butter"
end
Bagel
Butter
Bagel
When we started this discussion the block was called an implicit block. What if we want our method definition to be clearer? So if someone is reading it they know “I need to use a block here!” Then we can define an explicit block instead. Here’s our sandwich function with an explicit block:
def sandwich(bread_type, &block)
puts bread_type
# Instead of yield use call on the block argument
block.call(bread_type)
puts bread_type
end
sandwich("Whole Wheat") do |layer|
puts "Cheese"
puts layer
puts "Cheese"
end
Whole Wheat
Cheese
Whole Wheat
Cheese
Whole Wheat
Except we once again have the same problem we had before. This fails if there is no filling:
sandwich("Whole Wheat")
Whole Wheat
Traceback (most recent call last):
5: from /Users/rose/.rvm/rubies/ruby-2.6.3/bin/irb:23:in `<main>'
4: from /Users/rose/.rvm/rubies/ruby-2.6.3/bin/irb:23:in `load'
3: from /Users/rose/.rvm/rubies/ruby-2.6.3/lib/ruby/gems/2.6.0/gems/irb-1.0.0/exe/irb:11:in `<top (required)>'
2: from (irb):126
1: from (irb):116:in `sandwich'
NoMethodError (undefined method `call' for nil:NilClass)
When using explicit blocks we have to check that the block exists.
def sandwich(bread_type, &block)
puts bread_type
# Note the use of the & to only call call if block isn't nil
block&.call(bread_type)
puts bread_type
end
sandwich("Whole Wheat")
Whole Wheat
Whole Wheat
It’s a ruby convention to refer to a block using the name block but just like other arguments the name doesn’t matter.
# It's the & that says this refers to a block
# The name after the & just gives us a way to reference it
def sandwich(bread_type, &filling)
puts bread_type
filling&.call(bread_type)
puts bread_type
end
sandwich("Bagel") do
puts "Butter"
end
Bagel
Butter
Bagel
Do you have any questions about blocks and yield? Please leave them in the comments!