Skip to content

Instantly share code, notes, and snippets.

@Thomascountz
Created August 7, 2023 01:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Thomascountz/0914d6129f4396bab7bb46c7ef9794dc to your computer and use it in GitHub Desktop.
Save Thomascountz/0914d6129f4396bab7bb46c7ef9794dc to your computer and use it in GitHub Desktop.
Closures in Ruby

πŸš€ Let's explore Closures in #Ruby with a space-themed example! 🌌 #ThomasTip

Closures are functions/methods that can be invoked from other functions or methods, while retaining access to variables from their original scope.

def launch_sequence(seconds)
  start = Time.now
  -> do
    elapsed = Time.now - start
    remaining = seconds - elapsed.round
    if remaining.positive?
      "#{remaining} seconds till launch! πŸš€"
    else
      "We have lift off! πŸŽ‰"
    end
  end
end

apollo = launch_sequence(10)

sleep(2)

puts apollo.call
=> "8 seconds till lauch πŸš€"

sleep(2)

puts apollo.call
=> "6 seconds till lauch πŸš€"

Essentially, closures let you save some piece of code, carry its surrounding context (the closure) with it, and use it later.

It’s like packing a lunchbox for the function to eat later after it cycles to work. πŸš΄βœ‰οΈ

πŸš€ Let's break down this #Ruby closure for a rocket launch sequence.

The method launch_sequence takes one parameter: seconds (this is the total countdown time for our rocket launch) and returns a lambda to be called later.

def launch_sequence(seconds)
  start = Time.now
  -> do
    elapsed = Time.now - start
    remaining = seconds - elapsed.round
    if remaining.positive?
      "#{remaining} seconds till launch! πŸš€"
    else
      "We have lift off! πŸŽ‰"
    end
  end
end

Inside the method, we first capture the current time with start = Time.now. This marks the start of our countdown. πŸ•’

The start variable is currently only in scope of the launch_sequence method, but we use it within the lambda body on line :4.

def launch_sequence(seconds)
  start = Time.now
  -> do
    elapsed = Time.now - start # line 4
    remaining = seconds - elapsed.round
    if remaining.positive?
      "#{remaining} seconds till launch! πŸš€"
    else
      "We have lift off! πŸŽ‰"
    end
  end
end

Inside the lambda, elapsed = Time.now - start calculates elapsed time since the countdown started.

Because we've enclosed the start variable inside the lambda, each time we call launch_sequence, we get back a lambda that "remembers" its own start time!

Because the lambda has access to start, it can continue to be used to calculate remaining, even though the program execution leaves the launch_sequence context.

def launch_sequence(seconds)
  start = Time.now
  -> do
    elapsed = Time.now - start
    remaining = seconds - elapsed.round
    if remaining.positive?
      "#{remaining} seconds till launch! πŸš€"
    else
      "We have lift off! πŸŽ‰"
    end
  end
end

apollo = launch_sequence(10)

sleep(2)

puts apollo.call
=> "8 seconds till lauch πŸš€"

sleep(2)

puts apollo.call
=> "6 seconds till lauch πŸš€"

tl;dr, Even though start was defined outside of the lambda, it's accessible inside the lambda because of closures. Closures encapsulate the scope where they are defined, allowing the lambda to "remember" its use it when called, even if that context is no longer in scope.

When do we use closures?

Here's a stripped down example from a gem I'm working on.

In this case, the library code will call a lambda passed in through a configuration object.

This lambda takes one argument (result) and checks if it's less than or equal to the target value.

# Library Code
class Algorithm
  Configuration = Struct.new(:end_condition_function)

  def self.run(config, count = 10)
    puts count
    result = count - 1
    if config.end_condition_function.call(result)
      end_condition_reached = true
    end
    run(config, result) unless end_condition_reached
  end
end

# Client Code
def end_condition_function(target)
  ->(result) { result <= target }
end

config = Algorithm::Configuration.new(end_condition_function(5))

The target value is captured from the surrounding context when the lambda is defined, so each it "remembers" the target variable.

In this way, the library code is giving the client full flexibility to determine when the algorithm should stop!

You may be more familiar with closures implemented via blocks and procs.

Take the Enumerable module for example: each, map, select. These all accept blocksβ€”closuresβ€”as arguments.

five = 5

my_closure = Proc.new { |num| num + five }

[1, 2, 3].map(&my_closure)
=> [6, 7, 8]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment