Video

Want to see the full-length video right now for free?

Sign In with GitHub for Free Access

Notes

Blocks are a core concept in Ruby, and while they make frequent appearances in Ruby code, developers may not be aware of exactly how they work or all the things they can do. In this video, Boston Development Director Josh Clayton walks through this powerful Ruby language feature, showing how blocks work and how you can effectively use them in your own code.

Lexical scope

The Climate Control gem allows a test to be run with its own set of temporary environment variables (usually accessed through ENV). It uses blocks to establish a contained scope for the test and its modified environment without polluting the general environment for other tests.

RSpec.describe Environment do
  it "modifies the ENV only when the block is run" do
    updated_env = nil
    Environment.set FOO: "bar" do
      updated_env = ENV["FOO"]
    end

    expect(updated_env).to eq "bar"
    expect(ENV["FOO"]).to be_nil
  end
end

Blocks can be delineated with the familiar do and end or with curly braces. (Remember that not all curly braces represent blocks! Sometimes they signify a hash.) The block allows the establishment of a lexical scope for an anonymous method.

# a block using do-end
within ".body" do
  has_css(".nav")
end

# a block using {}
2.times { puts "hello" }

& preceding an argument (such as &block) designates a block argument in a method's argument list. In this method, &block means the argument will be a block:

def run(&block)
  begin
    cache_old_values
    assign_env
    block.call
  ensure
    reset_env
  end
end

Within a method, a block argument can be executed by sending it #call. Keep in mind that the block can be called multiple times!

Getting fancy (yield and explicit context)

When you define a block, you can specify "block variables" (declared between pipe characters, such as |memo|) that are only accessible within the block.

result = Calculator.run(start: 5) do |c|
  c.add 5
end

Within a method #yield provides a separate means of invoking a block (in addition to the previously mentioned Proc#call), with the ability to pass arguments to a block (which then become block arguments). You can see #yield in Rails layouts, when the layout yields to the specified view.

def self.run(start:)
  yield Operations.new(start)
end

The block's execution will return the value of the last evaluation of the block. This behavior allows the building of chainable methods.

result = Calculator.run(start: 5) do |c|
  c.add 5
  c.multiply_by 2
  c.subtract 15
end

expect(result).to eq 5

Mixing it up with Object#instance_exec

We discussed #call and #yield as ways to invoke a block. There is a separate third way to invoke a block. Object#instance_exec allows you to run a block in the context of the calling object, meaning that references in the block (including self) refer to the calling object. Here's the example from Ruby's documentation:

class KlassWithSecret
  def initialize
    @secret = 99
  end
end
k = KlassWithSecret.new
k.instance_exec(5) {|x| @secret+x }   #=> 104

Within the block, @secret references the instance variable in the object that received #instance_exec with the block.

FactoryGirl makes use of #instance_exec to set attributes in a factory. In this simplified factory example, #instance_exec is used with #method_missing to dynamically build a hash key-value pairs out of method-argument pairs. The methods declared in the block will be executed within the runner's context, where they will not be defined. The runner will then delegate those method calls (with their arguments) to #method_missing, where the implementation will build up the hash's keys and values. (Note that #method_missing is not part of Ruby's block functionality, but it is often used with it.)

def method_missing(name, *args, &block)
  @result[name.to_sym] = args.first
end

The #block_given? method can be called within a method to check if a block argument was supplied. Here Josh uses #block_given? to allow the HashDSL class to build a hash with an arbitrary level of nested values using recursion:

def method_missing(name, *args, &block)
  @result[name.to_sym] = if block_given?
    Runner.new.instance_exec(&block).__result__
  else
    args.first
  end
  self
end