CrackedRuby logo

CrackedRuby

Closures and Lexical Scope

Overview

Ruby implements closures through blocks, procs, and lambdas, which capture variables from their lexical environment and retain access to those variables even after the original scope exits. Lexical scoping determines which variables a closure can access based on where the closure is defined in the source code, not where it executes.

Ruby provides three primary closure mechanisms: blocks (anonymous closures passed to methods), Proc objects (first-class closure objects), and lambdas (strict Proc variants with different argument and return behavior). Each closure type captures local variables, instance variables, and constants from its defining scope.

outer_var = "captured"

# Block closure
[1, 2, 3].each { |n| puts "#{n}: #{outer_var}" }

# Proc closure  
my_proc = Proc.new { |n| puts "#{n}: #{outer_var}" }
my_proc.call(5)

# Lambda closure
my_lambda = lambda { |n| puts "#{n}: #{outer_var}" }
my_lambda.call(6)

Lexical scoping creates a hierarchy where inner scopes access variables from outer scopes, but not vice versa. When Ruby encounters a variable reference, it searches from the current scope outward through enclosing scopes until finding the variable or reaching the top level.

Variable capture occurs at closure creation time. The closure maintains references to the actual variable objects, not copies of their values. Changes to captured variables reflect in both the closure and the original scope.

counter = 0
increment = Proc.new { counter += 1 }

puts counter        # => 0
increment.call      # => 1
puts counter        # => 1

Basic Usage

Blocks represent the most common closure usage in Ruby. Methods receive blocks implicitly and execute them using yield. Block parameters appear between pipe characters, and the block body accesses both parameters and captured variables.

def repeat_with_index(times)
  times.times do |index|
    yield(index)
  end
end

message = "Hello"
repeat_with_index(3) { |i| puts "#{message} #{i}" }
# Output:
# Hello 0
# Hello 1  
# Hello 2

The block_given? method checks for block presence, enabling methods to behave differently based on block availability. Methods can also accept blocks as explicit parameters using the ampersand operator.

def process_data(data, &block)
  if block_given?
    data.map(&block)
  else
    data.dup
  end
end

numbers = [1, 2, 3]
doubled = process_data(numbers) { |n| n * 2 }  # => [2, 4, 6]
copied = process_data(numbers)                  # => [1, 2, 3]

Proc objects provide first-class closures that can be stored in variables, passed as arguments, and called later. The Proc.new constructor accepts a block and creates a callable closure object.

def create_multiplier(factor)
  Proc.new { |value| value * factor }
end

double = create_multiplier(2)
triple = create_multiplier(3)

puts double.call(5)   # => 10
puts triple.call(4)   # => 12

Lambdas behave similarly to procs but enforce argument count strictness and handle return statements differently. The lambda method creates lambda objects, while the stabby lambda syntax -> provides a more concise form.

# Traditional lambda syntax
greet_lambda = lambda { |name| "Hello, #{name}!" }

# Stabby lambda syntax
greet_stabby = ->(name) { "Hello, #{name}!" }

# Multi-line stabby lambda
calculate = ->(x, y) do
  result = x * y + 10
  "Calculation result: #{result}"
end

puts greet_lambda.call("Ruby")     # => "Hello, Ruby!"
puts calculate.call(3, 4)          # => "Calculation result: 22"

Variable capture creates references to the original variables, not copies. Multiple closures sharing the same captured variables see each other's modifications.

shared_state = []

collector1 = Proc.new { |item| shared_state << "1:#{item}" }
collector2 = Proc.new { |item| shared_state << "2:#{item}" }

collector1.call("apple")
collector2.call("banana")
puts shared_state  # => ["1:apple", "2:banana"]

Advanced Usage

Closures enable sophisticated metaprogramming patterns through dynamic method creation, scope manipulation, and behavior injection. Class and module methods can generate closures that capture configuration or state for later execution.

class EventHandler
  def initialize
    @callbacks = {}
  end
  
  def on(event, &callback)
    @callbacks[event] ||= []
    @callbacks[event] << callback
  end
  
  def trigger(event, data = {})
    return unless @callbacks[event]
    
    @callbacks[event].each do |callback|
      case callback.arity
      when 0 then callback.call
      when 1 then callback.call(data)
      else callback.call(event, data)
      end
    end
  end
end

handler = EventHandler.new
user_name = "Alice"

handler.on(:user_login) { |data| puts "#{user_name} logged in with ID #{data[:id]}" }
handler.on(:user_logout) { puts "#{user_name} logged out" }

handler.trigger(:user_login, { id: 123 })  # => "Alice logged in with ID 123"
user_name = "Bob"
handler.trigger(:user_logout)               # => "Bob logged out"

Closure composition creates powerful functional programming patterns where closures combine to form new behaviors. Higher-order functions accept and return closures, enabling flexible behavior customization.

def compose(*functions)
  functions.reduce do |f, g|
    lambda { |*args| f.call(g.call(*args)) }
  end
end

def partial(func, *partial_args)
  lambda { |*remaining_args| func.call(*(partial_args + remaining_args)) }
end

add = ->(x, y) { x + y }
multiply = ->(x, y) { x * y }
square = ->(x) { x * x }

# Function composition
add_then_square = compose(square, partial(add, 10))
result = add_then_square.call(5)  # => ((5 + 10) ** 2) = 225

Closures can capture and modify instance variables, class variables, and constants, creating stateful behaviors that persist across calls. This enables pattern implementations like memoization and caching.

class MemoizedCalculator
  def initialize
    @cache = {}
  end
  
  def memoize(name, &calculation)
    define_singleton_method(name) do |*args|
      cache_key = [name, args]
      
      @cache[cache_key] ||= begin
        puts "Computing #{name} with #{args.join(', ')}"
        calculation.call(*args)
      end
    end
  end
end

calc = MemoizedCalculator.new

calc.memoize(:fibonacci) do |n|
  if n <= 1
    n
  else
    calc.fibonacci(n - 1) + calc.fibonacci(n - 2)
  end
end

puts calc.fibonacci(10)  # Computes and caches intermediate values
puts calc.fibonacci(10)  # Returns cached result immediately

Binding objects provide explicit access to closure execution contexts. The binding method captures the current scope as a Binding object, which can evaluate code within that captured environment.

def create_scope_evaluator
  local_var = "captured value"
  
  {
    binding: binding,
    evaluator: lambda { |code| eval(code, binding) }
  }
end

scope = create_scope_evaluator
puts scope[:evaluator].call("local_var")           # => "captured value"
puts scope[:evaluator].call("local_var.upcase")    # => "CAPTURED VALUE"

# Direct binding usage
result = eval("local_var.length", scope[:binding])  # => 14

Common Pitfalls

Variable capture timing creates subtle bugs when closures are created inside loops. Each iteration captures the same variable reference, causing all closures to share the final loop value rather than their individual iteration values.

# Problematic: all closures capture same variable
closures = []
(1..3).each do |i|
  closures << lambda { puts "Value: #{i}" }
end

closures.each(&:call)
# Output: all print "Value: 3"

# Solution: capture value in new scope
closures = []
(1..3).each do |i|
  closures << lambda { |captured_i = i| puts "Value: #{captured_i}" }.curry[]
end

# Better solution: use block parameter
closures = (1..3).map { |i| lambda { puts "Value: #{i}" } }
closures.each(&:call)
# Output: prints "Value: 1", "Value: 2", "Value: 3"

Argument handling differs significantly between procs and lambdas. Procs ignore extra arguments and assign nil to missing parameters, while lambdas enforce exact argument counts and raise ArgumentError for mismatches.

proc_example = Proc.new { |a, b| puts "a=#{a}, b=#{b}" }
lambda_example = lambda { |a, b| puts "a=#{a}, b=#{b}" }

# Procs are lenient
proc_example.call(1)        # => "a=1, b="
proc_example.call(1, 2, 3)  # => "a=1, b=2" (ignores 3)

# Lambdas are strict
lambda_example.call(1)       # ArgumentError: wrong number of arguments
lambda_example.call(1, 2, 3) # ArgumentError: wrong number of arguments

Return statement behavior creates unexpected control flow issues. In procs, return exits the enclosing method, while in lambdas, return exits only the lambda itself.

def test_proc_return
  my_proc = Proc.new { return "from proc" }
  my_proc.call
  "after proc call"  # This line never executes
end

def test_lambda_return
  my_lambda = lambda { return "from lambda" }
  result = my_lambda.call
  "after lambda call: #{result}"
end

puts test_proc_return    # => "from proc"
puts test_lambda_return  # => "after lambda call: from lambda"

Memory leaks occur when closures capture large objects or maintain references to objects that should be garbage collected. Long-lived closures can prevent proper memory cleanup.

# Problematic: closure keeps entire large object in memory
def create_processor(large_dataset)
  # Closure captures entire large_dataset
  lambda { |key| large_dataset[key] }
end

# Solution: extract only needed data
def create_processor(large_dataset)
  needed_data = large_dataset.select { |k, v| important_key?(k) }
  lambda { |key| needed_data[key] }
end

# Alternative: use weak references for temporary associations
require 'weakref'

def create_temporary_processor(large_object)
  weak_ref = WeakRef.new(large_object)
  lambda do |method_name|
    obj = weak_ref.__getobj__ rescue nil
    obj&.send(method_name)
  end
end

Scope pollution happens when closures modify captured variables unexpectedly. Multiple closures sharing the same captured variables can interfere with each other's behavior.

# Problematic: shared mutable state
counter = 0
incrementers = 3.times.map do
  lambda { counter += 1 }
end

# All incrementers modify the same counter
incrementers.each(&:call)  # counter becomes 3

# Solution: each closure gets its own counter
incrementers = 3.times.map do |start_value|
  current = start_value
  lambda { current += 1 }
end

Reference

Closure Creation Methods

Method Syntax Argument Checking Return Behavior Use Case
Block { } or do..end Lenient Returns to calling method Iteration, configuration
Proc.new Proc.new { } Lenient Returns from enclosing method Stored callbacks, reusable logic
proc proc { } Lenient Returns from enclosing method Legacy compatibility
lambda lambda { } Strict Returns from lambda only Function objects, strict interfaces
Stabby lambda ->() { } Strict Returns from lambda only Concise function definitions

Block Testing Methods

Method Returns Description
block_given? Boolean Checks if current method received a block
Proc#lambda? Boolean Checks if Proc object is a lambda
Proc#arity Integer Returns number of parameters (-1 for variable args)

Proc Object Methods

Method Parameters Returns Description
#call(*args) Variable arguments Object Executes the closure with given arguments
#[](*args) Variable arguments Object Alias for call method
#===(*args) Variable arguments Object Used in case statements, calls the proc
#yield(*args) Variable arguments Object Alternative calling syntax
#curry(arity=nil) Optional integer Proc Returns curried version of the proc
#parameters None Array Returns parameter information as [type, name] pairs
#source_location None Array or nil Returns [filename, line_number] where proc was defined

Binding Methods

Method Parameters Returns Description
binding None Binding Captures current scope as Binding object
Binding#eval(code) String Object Evaluates code in the binding's context
Binding#local_variables None Array Returns local variable names in the binding
Binding#local_variable_get(name) Symbol/String Object Gets local variable value
Binding#local_variable_set(name, value) Symbol/String, Object Object Sets local variable value

Argument Behavior Comparison

Closure Type Too Few Args Too Many Args Default Values
Block Assigns nil Ignores extras Supported
Proc Assigns nil Ignores extras Supported
Lambda ArgumentError ArgumentError Supported

Variable Capture Rules

Variable Type Captured Shared Scope
Local variables Yes By reference Lexical
Instance variables Yes By reference Object scope
Class variables Yes By reference Class hierarchy
Constants Yes By reference Lexical with inheritance
Global variables Yes Global Application-wide

Performance Characteristics

Operation Block Proc Lambda
Creation overhead Lowest Medium Medium
Call overhead Lowest Medium Highest
Memory usage Lowest Medium Medium
Argument checking None None Full validation

Common Return Value Patterns

# Early return with lambda
validator = ->(data) { return false unless data.valid?; true }

# Multiple return points in proc
processor = Proc.new do |input|
  return nil if input.empty?
  return input.upcase if input.is_a?(String)
  input.to_s
end

# Block with implicit return
[1, 2, 3].map { |n| n * 2 }  # Returns transformed array