DEV Community

Brandon Weaver
Brandon Weaver

Posted on • Edited on

Let's Read - Eloquent Ruby - Ch 18 – Block Wrapping

Perhaps my personal favorite recommendation for learning to program Ruby like a Rubyist, Eloquent Ruby is a book I recommend frequently to this day. That said, it was released in 2011 and things have changed a bit since then.

This series will focus on reading over Eloquent Ruby, noting things that may have changed or been updated since 2011 (around Ruby 1.9.2) to today (2024 — Ruby 3.3.x).

Chapter 18. Execute Around with a Block

The focus of this chapter is how we start using block functions to wrap our code and transport around values. Sound abstract? Don't worry, we'll get into the examples soon which will make it a lot clearer, and once you see it you'll see it fairly frequently in Ruby programs.

It's no exaggeration to say you'll find block functions everywhere in Ruby, and this is just another way they're used.

Add a Little Logging

Suppose that, as the book mentions, we had a way to store and perform actions against a document like so:

class SomeApplication def do_something doc = Document.load("resume.txt") # Do something interesting with the document doc.save end end 
Enter fullscreen mode Exit fullscreen mode

Chances are in a production app we'd want some sort of logging so we'd be able to tell what's happening, as well as debugger information for development. It might look something like this, which may look familiar to you from other languages:

class SomeApplication def initialize(logger) @logger = logger end def do_something begin @logger.debug "Starting Document load" @doc = Document.load("resume.txt") @logger.debug "Completed Document load" rescue @logger.error "Load failed!" end # Do something interesting with the document begin @logger.debug "Starting Document save" @doc.save @logger.debug "Completed Document save" rescue @logger.error "Save failed!" raise end end end 
Enter fullscreen mode Exit fullscreen mode

But as the book mentions we're only really doing two things with a lot of logging around them. Every action is going to add more and more, and it could become difficult to manage. Going with previous parts of the book we might be tempted to pull things out into named methods to simplify a bit:

class SomeApplication def initialize(logger) @logger = logger end def do_something @doc = load_with_logging("resume.txt") # Do something interesting with the document save_with_logging(@doc) end def load_with_logging(file) @logger.debug "Starting Document load" doc = Document.load(file) @logger.debug "Completed Document load" doc rescue @logger.debug "Load failed!" end def save_with_logging(doc) @logger.debug "Starting Document save" doc.save @logger.debug "Completed Document save" rescue @logger.error "Save failed!" raise end end 
Enter fullscreen mode Exit fullscreen mode

...but even that has its drawbacks and only really moves things around. What if there were a better way? Well in Ruby blocks give us a great way to clearly and concisely perform a series of actions around another one, like so:

class SomeApplication def initialize(logger) @logger = logger end def do_something with_logging "load" do @doc = Document.load("resume.txt") end # Do something interesting with the document with_logging "save" do @doc.save end end def with_logging(description) @logger.debug "Starting #{description}" yield @logger.debug "Complete #{description}" rescue @logger.error "#{description} failed!" end end 
Enter fullscreen mode Exit fullscreen mode

The new with_logging function allows us to call the original code within the block function, but also wraps the idea of logging around it. That lets us get rid of all the logging lines and cleans up our code quite a bit in the process. Even better is that we're only adding a few lines to get all that logging.

As the book mentions this isn't limited to the above code. Because we wrote it in a generic way we could very easily use it for something completely different:

class SomeApplication def do_something_silly with_logging "Compute miles in a light year" do 186_000 * 60 * 60 * 24 * 365 end end end 
Enter fullscreen mode Exit fullscreen mode

That's a lot of the power of block functions, and really functions in general, is we can express the idea of actions more succinctly and more generically than we might with classes. Can you imagine a Loggable module that you'd have to include and conform to an interface to work with? Perhaps in Java, but in Ruby we have a few more tricks to play

When It Absolutely Must Happen

Are we limited to doing actions around something? Not really, we could even get before and after:

def log_before(description) @logger.debug "Starting #{description}" yield end def log_after(description) yield @logger.debug "Done #{description}" end 
Enter fullscreen mode Exit fullscreen mode

The general idea, as the book mentions, is that using blocks allows us to call back to the original code from anywhere inside the method. If you want a quick challenge try and create a method to time something that uses a block function and see where you get. Later in the book it does have an example of this.

Setting Up Objects with an Initialization Block

One idiom in Ruby that can be interesting to see for the first few times is initialization blocks:

class Document attr_accessor :title, :author, :content def initialize(title:, author:, content: "") @title = title @author = author @content = content yield self if block_given? end end 
Enter fullscreen mode Exit fullscreen mode

Since we're yielding the original class using self we have full access to it inside the block:

new_doc = Document.new(title: "US Constitution", author: "Madison") do |doc| doc.content << "We the people" doc.content << "In order to form a more perfect union" doc.content << "provide for the common defense" end 
Enter fullscreen mode Exit fullscreen mode

Often times people will do this to allow more dynamic configuration of a class on initialization, but it also has a lovely side effect of wrapping it so anything happening inside the block stays in the block unless you happen to manipulate something outside of it.

Dragging Your Scope along with the Block

The book has this habit of progressively explaining a concept through a series of logical steps, of which looking at any step in isolation may yield some weird code. If you find that happening keep reading and see where the book is going with it, as more often than not it is going somewhere with this.

In this particular case the book introduces us to the idea of passing an object into a block function and yielding it back to the block:

class SomeApplication def initialize(logger) @logger = logger end def do_something with_logging "load", nil do @doc = Document.load("resume.txt") end # Do something interesting with the document with_logging "save", @doc do |the_object| the_object.save end end def with_logging(description, the_object) @logger.debug "Starting #{description}" yield(the_object) @logger.debug "Complete #{description}" rescue @logger.error "#{description} failed!" end end 
Enter fullscreen mode Exit fullscreen mode

As the book mentions it misses the point as any code outside the block is still visible inside the block, which is called closure. The opposite way? Outside can't see inside unless you mutate something on the outside.

The book goes on to mention there's nothing wrong with yielding an argument back to the block, in fact it's rather common as it demonstrates in this code example:

def with_database_connection(connection_info) connection = Database.new(connection_info) yield connection ensure connection.close end 
Enter fullscreen mode Exit fullscreen mode

In these cases inside of the block function we're in a specific context, the context of a database connection. There are others in Ruby, like the context of an open file, web connection, or several other things.

Carrying the Answers Back

Where blocks really get useful is when you start saving the return value of a block. Take this example code from the book:

def do_something_silly with_logging "Compute miles in a light year" do 186_000 * 60 * 60 * 24 * 365 end end 
Enter fullscreen mode Exit fullscreen mode

In this case not only are we executing around, but we're also returning the original value:

def with_logging(description) @logger.debug "Starting #{description}" return_value = yield @logger.debug "Completed #{description}" return_value rescue @logger.error "#{description} failed!" raise end 
Enter fullscreen mode Exit fullscreen mode

The book itself doesn't go here, but a common usecase for this I have handy is timing:

def timed(&block_function) start_time = Time.now return_value = block_function.call end_time = Time.now puts "Took #{end_time - start_time} to execute" return_value end 
Enter fullscreen mode Exit fullscreen mode

...and I've gotten a lot of mileage out of it while doing rudimentary debugging. Granted flame graphs and profilers are more useful for heavy lifting, but sometimes I want something drop-dead simple instead and this scratches that itch very well.

Staying Out of Trouble

The book mentions, as it often correctly does, that naming is important. Consider the following:

execute_between_logging_statements "update" do employee.load employee.status = :retired employee.save end 
Enter fullscreen mode Exit fullscreen mode

...as compared to a more generic version:

with_logging "update" do employee.load employee.status = :retired employee.save end 
Enter fullscreen mode Exit fullscreen mode

The latter is clearer and more immediately communicates the intent.

In the Wild

Remember when the yield self bit and the database connection were mentioned earlier as one of a large number of examples in Ruby? File is one of those examples:

# No access to the file File.open("/etc/passwd") do |f| # Able to access the file end # File is closed 
Enter fullscreen mode Exit fullscreen mode

...as are CSV and several other core classes. It's a common idiom, and one to be familiar with.

Speaking of yield self a lot of gem files do the same:

require_relative "lib/rake/version" Gem::Specification.new do |s| s.name = "rake" s.version = Rake::VERSION s.authors = ["Hiroshi SHIBATA", "Eric Hodel", "Jim Weirich"] s.email = ["hsbt@ruby-lang.org", "drbrain@segment7.net", ""] s.summary = "Rake is a Make-like program implemented in Ruby" s.description = <<~DESCRIPTION Rake is a Make-like program implemented in Ruby. Tasks and dependencies are specified in standard Ruby syntax. # ...  DESCRIPTION # More below end 
Enter fullscreen mode Exit fullscreen mode

Do note this is an updated version from the one the book mentioned from Rake as it was at the time this article was written.

The other example the book uses is going to look very familiar to the above timing function:

module ActiveRecord class Migration # Takes a message argument and outputs it as is. # A second boolean argument can be passed to specify whether to indent or not. def say(message, subitem = false) write "#{subitem ? " ->" : "--"} #{message}" end # Outputs text along with how long it took to run its block. # If the block returns an integer it assumes it is the number of rows affected. def say_with_time(message) say(message) result = nil time_elapsed = ActiveSupport::Benchmark.realtime { result = yield } say "%.4fs" % time_elapsed, :subitem say("#{result} rows", :subitem) if result.is_a?(Integer) result end end end 
Enter fullscreen mode Exit fullscreen mode

The ActiveSupport::Benchmark.realtime { result = yield } is doing just the same thing.

Wrapping Up

Once you see it you can't unsee it. A lot of Ruby uses the idea of wrapping behavior using block functions, and frequently when writing libraries and utilities I'm going to be writing several of these types of methods myself. Whether that's timing, wrapping contexts, doing things before or after, or any number of other tasks this is going to be a frequent tool you use in Ruby.

Top comments (0)