(original) (raw)
# CLOSURES IN RUBY Paul Cantrell https://innig.net # Email: username "paul", domain name "innig.net" # I recommend executing this file, then reading it alongside its output. # # Alteratively, you can give yourself an unreasonable Ruby test by deleting all # the comments, then trying to guess the output of the code! # # (Naive HR departments, please do not use that idea as a hiring quiz.) # A closure is a chunk of code which meets three criteria: # # * It can be passed around as a value and # # * executed on demand by anyone who has that value, at which time # # * it can refer to variables from the context in which it was created # (i.e. it is closed with respect to variable access, in the # mathematical sense of the word "closed"). # # (The word "closure" actually has an imprecise meaning, and some people don't # think that criterion #1 is part of the definition. I think it is.) # # Closures are a mainstay of functional languages, but are present in many other # languages as well. Scheme pioneered them; more recently Javascript, popularized # them. They enable all sort of cool stuff: they allow deferred execution and some # elegant tricks of style. # # Ruby is based on the "principle of least surprise," but I had a really nasty # surprise in my learning process. When I understood what methods like "each" # were doing, I thought, "Aha! Ruby has closures!" But then I found out that a # function can't accept multiple blocks -- violating the principle that closures # can be passed around freely as values. # # This document details what I learned in my quest to figure out what the deal is. def example(num) puts puts "------ Example #{num} ------" end # ---------------------------- Section 1: Blocks ---------------------------- # Blocks are like closures, because they can refer to variables from their defining context: example 1 def thrice yield yield yield end x = 5 puts "value of x before: #{x}" thrice { x += 1 } puts "value of x after: #{x}" # A block refers to variables in the context it was defined, not the context in which it is called: example 2 def thrice_with_local_x x = 100 yield yield yield puts "value of x at end of thrice_with_local_x: #{x}" end x = 5 thrice_with_local_x { x += 1 } puts "value of outer x after: #{x}" # A block only refers to *existing* variables in the outer context; if they don't exist in the outer, # a block won't create them there: example 3 thrice do # note that {...} and do...end are completely equivalent y = 10 puts "Is y defined inside the block where it is first set?" puts "Yes." if defined? y end puts "Is y defined in the outer context after being set in the block?" puts "No!" unless defined? y # OK, so blocks seem to be like closures: they are closed with respect to variables defined in the # context where they were created, regardless of the context in which they're called. # # But they don't look quite like closures in the code above because we aren't passing them around # as we would any othe value: "yield" can *only* refer to the block passed to the method it's in. # # We can pass a block on down the chain, however, using &: example 4 def six_times(&block) thrice(&block) thrice(&block) end x = 4 six_times { x += 10 } puts "value of x after: #{x}" # So do we have closures? Not quite! We can't hold on to a &block and call it later at an arbitrary # time; it doesn't work. This, for example, will not compile: # # def save_block_for_later(&block) # saved = &block # end # # But we *can* pass it around if we use drop the &, and use block.call(...) instead of yield: example 5 def save_for_later(&b) @saved = b # Note: no ampersand! This turns a block into a closure of sorts. end save_for_later { puts "Hello!" } puts "Deferred execution of a block:" @saved.call @saved.call # But wait! We can't pass multiple blocks to a function! As it turns out, there can be only zero # or one &block_params to a function, and the ¶m *must* be the last in the list. # # None of these will compile: # # def f(&block1, &block2) ... # def f(&block1, arg_after_block) ... # f { puts "block1" } { puts "block2" } # # What the heck? # # I claim this single-block limitation violates the "principle of least surprise." The reasons for # it have to do with ease of C implementation, not semantics. # # So: are we doomed, never to do anything wild and Lisp-like with closures? # ---------------------------- Section 2: Closure-Like Ruby Constructs ---------------------------- # Despair not! When we pass a block ¶m, then refer to that param without the ampersand, that # is secretly a synonym for Proc.new(¶m): example 6 def save_for_later(&b) @saved = Proc.new(&b) # same as: @saved = b end save_for_later { puts "Hello again!" } puts "Deferred execution of a Proc works just the same with Proc.new:" @saved.call # We can define a Proc on the spot, no need for the ¶m: example 7 @saved_proc_new = Proc.new { puts "I'm declared on the spot with Proc.new." } puts "Deferred execution of a Proc works just the same with ad-hoc Proc.new:" @saved_proc_new.call # Behold! A true closure! # # But wait, there's more.... Ruby has a whole bunch of things that seem to behave like closures, # and can be called with .call: example 8 @saved_proc_new = Proc.new { puts "I'm declared with Proc.new." } @saved_proc = proc { puts "I'm declared with proc." } @saved_lambda = lambda { puts "I'm declared with lambda." } def some_method puts "I'm declared as a method." end @method_as_closure = method(:some_method) puts "Here are four superficially identical forms of deferred execution:" @saved_proc_new.call @saved_proc.call @saved_lambda.call @method_as_closure.call # So in fact, Ruby has no less than seven -- count 'em, SEVEN -- different closure-like constructs: # # 1. block (implicitly passed, called with yield) # 2. block (&b => f(&b) => yield) # 3. block (&b => b.call) # 4. Proc.new # 5. proc # 6. lambda # 7. method # # Though they all look different, some of these are secretly identical, as we'll see shortly. # # We already know that (1) and (2) are not really closures -- and they are, in fact, exactly the # same thing. Numbers 3-7 seem identical. Are all 7 just different syntaxes for identical semantics? # ---------------------------- Section 3: Closures and Control Flow ---------------------------- # No, they aren't! One of the distinguishing features has to do with what "return" does. # # Consider first this example of several different closure-like things *without* a return statement. # They all behave identically: example 9 def f(closure) puts puts "About to call closure" result = closure.call puts "Closure returned: #{result}" "Value from f" end puts "f returned: " + f(Proc.new { "Value from Proc.new" }) puts "f returned: " + f(proc { "Value from proc" }) puts "f returned: " + f(lambda { "Value from lambda" }) def another_method "Value from method" end puts "f returned: " + f(method(:another_method)) # But put a "return" inside a proc, and surprising things happen! example 10 def g f(proc { return "Value from proc" }) puts "Finished with g!" end g # Wait, what? A little more logging will make it clear: example 11 def g result = f(proc { return "Value from proc" }) puts "f returned: " + result # never executed "Value from g" # never executed end puts "g returned: #{g}" # Note that the return inside the proc didn't just return from the proc -- it returned # all the way out of g, bypassing not only the rest of g but the rest of f as well! It worked # almost like an exception. # # This means that it's not possible to call a proc containing a "return" when the creating # context no longer exists: example 12 def make_proc_new begin Proc.new { return "Value from Proc.new" } # this "return" will return from make_proc_new ensure puts "make_proc_new exited" end end begin puts make_proc_new.call rescue Exception => e puts "Failed with #{e.class}: #{e}" end # (Note that this makes it unsafe to pass Procs across threads.) # A proc, then, is not quite truly closed: it depends on the context where it was created still # existing, because the "return" is tied to that context. # # Not so for lambda: example 13 def g result = f(lambda { return "Value from lambda" }) puts "f returned: " + result "Value from g" end puts "g returned: #{g}" # And yes, you can call a lambda even when the creating context is gone: example 14 def make_lambda begin lambda { return "Value from lambda" } ensure puts "make_lambda exited" end end puts make_lambda.call # Inside a lambda, a return statement only returns from the lambda, and flow continues normally. # So a lambda is like a function unto itself, whereas a Proc remains dependent on the control # flow of its caller. # # A lambda is Ruby's true closure. # Note that "proc" is in fact just a shortcut for "Proc.new". Amusingly, or horrifyingly, this # was not always the case! In Ruby 1.8, "proc" was a synonym for ... "lambda". # # Let's all raise a glass to compatibility-breaking language changes. # I'll spare you the rest of the experiments, and give you the behavior of all 7 cases: # # "return" returns from caller: # 1. block (called with yield) # 2. block (&b => f(&b) => yield) # 3. block (&b => b.call) # 4. Proc.new # 5. proc # # "return" only returns from closure: # 6. lambda # 7. method # ---------------------------- Section 3a: But...WHY? ---------------------------- # Why on earth would we want a thing with this strange behavior? Because it makes Ruby’s loops # beautiful. Javascript developers using closure-based forEach constructs will be familiar with the # conundrum that Ruby’s blocks solve. # # Suppose we want to write a Javascript function that efficiently checks whether the running sum of # numbers in an array ever exceeds some maximum: # # function isSumGreaterThan(array, max) { # let sum = 0; # for(let x of array) { # sum += x; # if(sum > max) { # return true; // Stop searching! We have our answer. # } # } # return false; # } # # If we try to write this code with forEach(), which closely resembles Ruby’s each(), # the same approach suddenly breaks: # # function isSumGreaterThan(array, max) { # let sum = 0; # array.forEach((x) => { # sum += x; # if(sum > max) { # return true; // Oops! Doesn't work # } # }); # return false; # } # # The problem is that "return true" doesn’t return from isSumGreaterThan(); it only returns from the # closure we’re passing to forEach. To make that work using functional iteration in Javascript -- or # in most true functional languages, for that matter! -- we have to switch from forEach to an # entirely different method of Array: # # function isSumGreaterThan(array, max) { # let sum = 0; # return array.some((x) => { # sum += x; # return sum > max; # }); # } # # But Ruby doesn’t need a special case, because Ruby’s blocks and procs retain the control flow of # their context. Unlike Javascript, a return inside a loop is the same as a return outside one: example 15 def is_sum_greater_than(array, max) sum = 0 array.each do |x| sum += x return true if sum > max # returns from is_sum_greater_than, not just the block end return false end puts is_sum_greater_than([5, 3, 7], 10) puts is_sum_greater_than([5, 3, 7], 20) # Control flow constructs like "next" and "break" have the same behavior. # # THIS is why Ruby has the two superficially similar but fundamentally different constructs of # blocks and lambdas. It is a defining feature of the language: blocks and closures, two different # forms of code-as-value, existing side by side. # (Interestingly, Swift makes a very similar distinction with between @escaping and non-espcaing # closures; however, it does not adopt Ruby’s dual control flow behavior.) # ---------------------------- Section 4: Closures and Arity ---------------------------- # The other major distinguishing of different kinds of Ruby closures is how they handle mismatched # arity -- in other words, the wrong number of arguments. # # In addition to "call," every closure has an "arity" method which returns the number of expected # arguments: example 16 puts "One-arg lambda:" puts (lambda {|x|}.arity) puts "Three-arg lambda:" puts (lambda {|x,y,z|}.arity) puts "No-args lambda: " puts (lambda {}.arity) # ...with a special case: puts "Varargs lambda: " puts (lambda {|*args|}.arity) # Watch what happens when we call these with the wrong number of arguments: example 17 def call_with_too_many_args(closure) begin puts "closure arity: #{closure.arity}" closure.call(1,2,3,4,5,6) puts "Too many args worked" rescue Exception => e puts "Too many args threw exception #{e.class}: #{e}" end end def two_arg_method(x,y) end puts; puts "proc:" ; call_with_too_many_args(proc {|x,y|}) puts; puts "lambda:" ; call_with_too_many_args(lambda {|x,y|}) puts; puts "Method:" ; call_with_too_many_args(method(:two_arg_method)) def call_with_too_few_args(closure) begin puts "closure arity: #{closure.arity}" closure.call() puts "Too few args worked" rescue Exception => e puts "Too few args threw exception #{e.class}: #{e}" end end puts; puts "proc:" ; call_with_too_few_args(proc {|x,y|}) puts; puts "lambda:" ; call_with_too_few_args(lambda {|x,y|}) puts; puts "Method:" ; call_with_too_few_args(method(:two_arg_method)) # ---------------------------- Section 5: Rant ---------------------------- # # This is quite a dizzing array of syntactic options, with subtle semantics differences that are not # at all obvious, and riddled with minor special cases. It's like a big bear trap from programmers # who expect the language to just work. # # Complex as it is now, the behavior above used to be far more complex, with special cases that were # utterly nonsensical. # # Why are things this way? Because Ruby is: # # (1) designed by implementation, and # (2) defined by implementation. # # The language grows because the Ruby team tacks on cool ideas, without maintaining a real spec # apart from CRuby. A spec would make clear the logical structure of the language, and thus help # highlight inconsistencies like the ones we've just seen. Instead, little inconsinstencies creep # into the language, confuse the crap out of poor souls like me who are trying to learn it, and then # get submitted as bug reports. Yes, I know, language design is hard -- but something like this # proc/lambda issue or the arity problem wasn't so hard to get right the first time. Yammer yammer. # ---------------------------- Section 5a: Counter-Rant ---------------------------- # # When I first wrote this guide in ~2006, the rant above was much more justified because there # were all sorts of goofy inconsistencies in the arity behavior and Kernel.proc made lambdas and # not procs. The whole thing had a sort of "is anybody paying attention?!" feeling. # # Well, yes, anybody was. Subsequent releases of the language mopped up the arity inconsistencies, # and made "proc" mean proc. Ruby still doesn't have a specification -- but it does have an active # community and a willingness to correct its mistakes, and it turns out that's even better. # # A spec would still be nice though. # ---------------------------- Section 6: Summary ---------------------------- # # So, what's the final verdict on those 7 closure-like entities? # # "return" returns from closure # True closure? or declaring context...? Arity check? # --------------- ----------------------------- ------------ # 1. block (called with yield) N declaring no # 2. block (&b => f(&b) => yield) N declaring no # 3. block (&b => b.call) Y except return declaring no # 4. Proc.new Y except return declaring no # 5. proc Y except return declaring no # 6. lambda Y closure yes # 7. method Y closure yes # # The things within each of these groups are all semantically identical -- that is, they're # different syntaxes for the same semantics: # # 1. block (called with yield) # 2. block (&b => f(&b) => yield) # ------- # 3. block (&b => b.call) # 4. Proc.new # 5. proc # ------- # 6. lambda # 7. method # # Or at least, this is how I *think* it is, based on experiment. There's no authoritative answer # other than testing the CRuby implementation, because there's no real spec -- see rant above -- so # there may be other differences I haven't discovered. # # The final verdict: Ruby has three types of closures and near-closures, expressible in seven # syntactic variants. Not pretty. But you sure sure do cool stuff with them! That's up next.... # # This concludes the "Ruby makes Paul agitated" portion of our broadcast; from here on, it will be # the "Ruby is awesome" portion. # ---------------------------- Section 7: Doing Something Cool with Closures ---------------------------- # Let's make a data structure containing all of the Fibonacci numbers. Yes, I said *all* of them. # How is this possible? We'll use closures to do lazy evaluation, so that the computer only # calculates as much of the list as we ask for. # To make this work, we're going to use Lisp-style lists: a list is a recursive data structure with # two parts: "first," the next element of the list, and "rest," the remainder of the list. # # For example, the list of the first three positive integers is [1,[2,[3]]]. Why? Because: # # [1,[2,[3]]] <--- first=1, rest=[2,[3]] # [2,[3]] <--- first=2, rest=[3] # [3] <--- first=3, rest=nil # # Here's a class for traversing such lists: example 18 class LispyEnumerable include Enumerable def initialize(tree) @tree = tree end def each iter = @tree until iter.nil? first, rest = iter yield first iter = rest end end end list = [1,[2,[3]]] LispyEnumerable.new(list).each do |x| puts x end # So how to make an infinite list? Instead of making each node in the list a fully built data # structure, we'll make it a closure -- and then we won't call that closure until we actually need # the value. This applies recursively: the top of the tree is a closure, and its rest is a closure, # and the rest's rest is a closure.... example 19 class LazyLispyEnumerable include Enumerable def initialize(tree) @tree = tree end def each while @tree first,rest = @tree.call # <--- @tree is a closure yield first @tree = rest end end end list = lambda{[1, lambda {[2, lambda {[3]}]}]} # same as above, except we wrap each level in a lambda LazyLispyEnumerable.new(list).each do |x| puts x end example 20 # Let's see when each of those blocks gets called: list = lambda do puts "first lambda called" [1, lambda do puts "second lambda called" [2, lambda do puts "third lambda called" [3] end] end] end puts "List created; about to iterate:" LazyLispyEnumerable.new(list).each do |x| puts x end # Now, because the lambda defers evaluation, we can make an infinite list: example 21 def fibo(a, b) lambda { [a, fibo(b,a+b)] } # <--- this would go into infinite recursion if it weren't in a lambda end LazyLispyEnumerable.new(fibo(1,1)).each do |x| puts x break if x > 100 # we don't actually want to print all of the Fibonaccis! end # This kind of deferred execution is called "lazy evaluation" -- as opposed to the "eager # evaluation" we're used to, where we evaluate an expression before passing its value on. (Most # languages, including Ruby, use eager evaluation, but there are languages (like Haskell) which use # lazy evaluation for everything, by default! This only makes sense in value-typed languages where # evaluation order doesn't matter. End aside.) # # Though cool, this way of implementing lazy evaluation is terribly clunky! We had to write a # separate LazyLispyEnumerable that *knows* we're passing it a special lazy data structure. How # unsatisfying! Wouldn't it be nice of the lazy evaluation were invisible to callers of the lazy # object? # # As it turns out, we can do this. We'll define a class called "Lazy," which takes a block, turns it # into a closure, and holds onto it without immediately calling it. The first time somebody calls a # method, we evaluate the closure and then forward the method call on to the closure's result. class Lazy def initialize(&deferred_value) @deferred_value = deferred_value end def value @value ||= @deferred_value.call end def method_missing(method, *args, &block) value.send(method, *args, &block) end end def lazy(&b) Lazy.new &b end # This basically allows us to say: # # lazy { value } # # ...and get an object that *looks* exactly like value -- except that value won't be created until # the first method call that touches it. It creates a transparent lazy proxy object. Observe: example 22 x = lazy do puts "<<< Evaluating lazy value >>>" "lazy value" end puts "x has now been assigned" puts "About to call one of x's methods:" puts "x.size: #{x.size}" # <--- .size triggers lazy evaluation puts "x.swapcase: #{x.swapcase}" # So now, if we define fibo using lazy instead of lambda, it magically works with our original # LispyEnumerable -- which has no idea it's dealing with a lazy value! example 23 def fibo(a, b) lazy { [a, fibo(b, a + b)] } end LispyEnumerable.new(fibo(1,1)).each do |x| puts x break if x > 100 # we don't actually want to print all of the Fibonaccis! end # Fantastic! We just traversed an infinite list like that's totally a normal thing that people do. # # And of course it works with finite lists too...right? example 24 def fibo_up_to_max(a, b, max) lazy { [a, fibo_up_to_max(b, a + b, max)] if a <= max } end puts "List created; about to iterate:" LispyEnumerable.new(fibo_up_to_max(1, 1, 100)).each do |x| puts x end # Wait ... what’s that thing doing at the end there? # # Well, the thing at end of the list is a lazy nil: fibo_up_to_max always returns a lazy value, # and that lazy value happens to be nil when the "if" condition is false. # # The failure happens when this line of LispyEnumerable encounters that lazy nil: # # first, rest = iter # # Let's zoom in on that: example 24 first, rest = nil puts "Regular nil: first=#{first} rest=#{rest}" first, rest = lazy { nil } puts "Lazy nil: first=#{first} rest=#{rest}" # Here's the problem. When we do this: # # x,y = z # # ...Ruby wants to know whether z is an array, like this: # # x,y = [0,1] # x=0, y=1 # # ...or something else: # # x,y = 0 # x=0, y=nil # # So Ruby calls z.respond_to?(:to_ary) to see if z would like to behave as an array. If so, Ruby # will do the multiple assignment; if not, it will just set x=z and y=nil. # # We want our Lazy to forward the respond_to? call to our fibo list. But it doesn't forward it, # because we used the method_missing to do the proxying -- and every Object implements respond_to? # by default, so the method isn't missing! The respond_to? doesn't get forwarded; instead, out Lazy # says "No, I don't respond to to_ary; thanks for asking." # # The obvious solution would be to forward respond_to? manually, but there’s an even better one. # The class called "Object" is not the root of Ruby's type hierarchy. It has a superclass: example 26 p Object.superclass # BasicObject implements just the bare minimum of methods, without all the helpful default # behaviors of Object. And guess what the "bare minimum" doesn't include? Our friend "respond_to?"! # That means that if we make BasicObject the superclass of Lazy, then respond_to? is missing, it # will be handled by method_missing, and everything will just work! Object.send(:remove_const, :Lazy) # nixes the previous class declaration class Lazy < BasicObject # <-- the fix def initialize(&deferred_value) @deferred_value = deferred_value end def value @value ||= @deferred_value.call end def method_missing(method, *args, &block) value.send(method, *args, &block) end end # And *now* our original Lispy enum will work: example 27 LispyEnumerable.new(fibo_up_to_max(1, 1, 100)).each do |x| puts x end # Our lazy objects aren't quite perfectly invisible. They still, for example, don't behave nicely # with ==: example 28 puts lazy { 3 } == 3 puts 3 == lazy { 3 } # Fixing this, and finding the other problems like it, I leave as a vexing exercise for the reader. # ---------------------------- Section 8: Wrap-Up ---------------------------- # So sure, this was all entertaining -- but is it good for anything? # # Anybody who has ever used Ruby to iterate over a collection, define an ActiveRecord scope, or # configure a Sinatra route knows the answer to that. # # What about the lazy proxies and infinite lists? Are those just toys? Honestly, I'm surprised Ruby # projects don't use them more often. Suppose you have a resource-intensive object. Perhaps it # requires a network or database call during creation, or it will use a lot of memory once it # exists. And suppose that this object may or may not be used, but you don't know at the time it's # created whether it ever will be. Making it lazy will prevent it from consuming resources until it # needs to, without burdening other code with explicitly bringing it to life. # # OK, I'll stop making your brain hurt now. Hope this has been a bit enlightening! The experience # of working it out certainly was for me. # # Cheers, # # Paul