π 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]