Skip to content

02 · Blocks/Procs/Lambdas Advanced

Level 1 covered the basics: blocks, yield, and the argument/return differences between Proc and lambda. This lesson goes deeper into how blocks actually work under the hood — closures, explicit block capture with &, converting between the three callable types, and a few patterns you'll see constantly in real Ruby code.

Closures — blocks remember their surrounding variables

A block isn't just code — it's code bundled with a reference to the local variables that were in scope where it was created. This bundle is called a closure, and it keeps working even after that surrounding method has returned:

def make_counter
  count = 0
  increment = lambda { count += 1 }   # captures `count` by reference
  increment
end

counter = make_counter
puts counter.call   # 1
puts counter.call   # 2
puts counter.call   # 3

make_counter already returned, yet counter still has access to count and can keep mutating it. Each call to make_counter creates a new count, so two counters never share state:

counter_a = make_counter
counter_b = make_counter
counter_a.call
counter_a.call
puts counter_a.call   # 3
puts counter_b.call   # 1 -- independent closure, independent `count`

Explicit block capture with &block

Every method can implicitly receive a block and invoke it with yield, but naming it explicitly with &block turns it into a real Proc object you can store, pass along, or call conditionally:

def process(items, &transformer)
  items.map { |item| transformer.call(item) }
end

doubled = process([1, 2, 3]) { |n| n * 2 }
puts doubled.inspect   # [2, 4, 6]

The & also works in reverse — prefixing an existing Proc/lambda/symbol with & when calling a method converts it back into a block:

square = ->(n) { n * n }
puts [1, 2, 3].map(&square).inspect   # [1, 4, 9]

# The famous &:symbol idiom is the same mechanism: Symbol#to_proc
puts ["ruby", "rails"].map(&:upcase).inspect   # ["RUBY", "RAILS"]

&:upcase works because Symbol#to_proc turns :upcase into ->(obj) { obj.upcase } automatically — that's the entire trick behind Ruby's shortest, most idiomatic map calls.

yield vs calling a captured block — they're not quite the same cost

yield is faster than &block.call because Ruby doesn't have to allocate a Proc object unless you explicitly ask for one by naming it:

def fast_version
  yield 5          # no Proc object created
end

def flexible_version(&block)
  block.call(5)     # Proc object created up front, but block is a real value
end

Prefer plain yield unless you actually need the block as an object (to pass it elsewhere, store it, or check respond_to?(:call) on it).

Multiple yields and yielding multiple values

A method can yield more than once, and can yield more than one value at a time — the block destructures them the same way multiple assignment does:

def each_pair(hash)
  hash.each do |key, value|
    yield key, value
  end
end

each_pair({ a: 1, b: 2 }) do |k, v|
  puts "#{k} => #{v}"
end
# a => 1
# b => 2

Curry — partially applying a lambda

curry transforms a multi-argument lambda into a chain of single-argument lambdas, letting you supply arguments in stages:

multiply = ->(a, b, c) { a * b * c }
curried = multiply.curry

double = curried[2]           # fixes a = 2, returns a new lambda
double_and_triple = double[3]   # fixes b = 3

puts double_and_triple[4]   # 24  (2 * 3 * 4)
puts curried[2][3][4]         # 24  -- same thing, all at once

This is handy for building specialized versions of a general function without writing a wrapper method by hand.

Method objects — turning a method into a callable

Every object method can be extracted as a standalone Method object with .method(:name), and converted to a Proc with .to_proc (or passed directly with &):

def shout(text)
  text.upcase + "!"
end

shout_method = method(:shout)
puts shout_method.call("hello")   # HELLO!

puts ["a", "b"].map(&shout_method).inspect   # ["A!", "B!"]

This is useful when you already have a named method and want to reuse it as a block without redefining it as a lambda.

Proc.new vs proc vs lambda vs -> — which to reach for

p1 = Proc.new { |x| x }   # explicit Proc -- lenient arity, `return` exits enclosing method
p2 = proc { |x| x }         # shorthand for Proc.new -- identical behavior
l1 = lambda { |x| x }       # strict arity, `return` exits only the lambda
l2 = ->(x) { x }             # stabby lambda -- identical to l1, just terser syntax

In modern Ruby style: use proc/Proc.new rarely (mostly for &block capture from an actual block argument), and default to the stabby lambda ->(...) for anything you're constructing directly — its strict arity checking catches bugs that a lenient proc would silently swallow.

The return-from-a-proc footgun, revisited

This is worth repeating because it causes a real, hard-to-diagnose bug: a Proc's return unwinds the method that created it, not just the proc. If that method has already finished, Ruby raises LocalJumpError:

def get_proc
  Proc.new { return "done" }
end

stray_proc = get_proc
begin
  stray_proc.call   # get_proc already returned -- nothing to return FROM
rescue LocalJumpError => e
  puts "Error: #{e.message}"
end

A lambda created the same way is perfectly safe to call later, because its return only ever exits the lambda itself — one more reason lambdas are the safer default when a callable is going to be stored or passed around rather than used immediately.

How It Actually Works

Every block, proc, and lambda carries a binding — a live reference to the local-variable table, self, and method-visibility context of the scope where it was written, not a snapshot copied at creation time. This is why Proc.new { x += 1 } continues to see updates to x made by code outside the proc after the proc is created, and vice versa — both sides are reading and writing the exact same variable slot in the same binding object. instance_eval/instance_exec temporarily reassign self inside the block to a different receiver while keeping the same captured binding for local variables — this dual nature (borrowed self, original locals) is exactly what lets DSLs like RSpec's describe blocks call methods on an implicit receiver while still reading local variables from the surrounding test file. &block argument syntax performs an actual conversion at the call boundary: passing a Proc with & calls to_proc if needed and marks it for yield-style invocation, while capturing an implicit block as &block in a method signature calls the inverse conversion, wrapping the block's raw bytecode reference in a first-class Proc object.

Cheat sheet

Feature Syntax Notes
Capture the block as a Proc def m(&block) costs a Proc allocation
Convert Proc/lambda back to a block method(&my_proc)
Symbol shorthand block &:method_name via Symbol#to_proc
Extract a method as a callable method(:name) returns a Method object
Partially apply a lambda my_lambda.curry
Closures capture by reference shared local variables each closure-creating call gets fresh locals

Exercise

Write a method make_multiplier(factor) that returns a lambda closing over factor, so triple = make_multiplier(3) lets you call triple.call(7) to get 21. Then write a method retry_with_delay(&block) that calls the given block, and if it raises, waits (just puts "retrying..." instead of an actual sleep) and calls it exactly once more before giving up — you'll build the exception-handling half of this properly in Exception Handling.