cswork / libraryOpen practice workspace

Articles

Ruby Blocks, Procs, and Lambdas: Interview Exercises on Return, Arity, and Closures

Work through Ruby block, Proc, and lambda interview exercises on return destinations, arity, delayed calls, and captured variables with runnable code traces.

Ruby block, Proc, and lambda questions look like syntax trivia until a return jumps out of the method you were tracing. The interview skill is to follow where the code was defined, when it is called, how it receives arguments, and what return exits. One word—closure—does not answer all four. A small execution trace does.

This guide uses original exercises rather than claiming that a named employer asks them. The Ruby Proc documentation is the primary source for lambda versus non-lambda arity and return behavior. The example outputs and debugging prompts are editorial exercises based on those documented rules; there is no candidate report to generalize. For a related closure exercise, open Design rotating warehouse simulator with closures and ask which captured values survive each call.

First, what is a block and when does it become a Proc?

A block is code attached to a method invocation. A method can invoke it with yield or receive it as a parameter prefixed with an ampersand, which makes it available as a Proc object. A Proc is an object that can be stored, passed, and called later. A lambda is a Proc with particular argument and return semantics; Ruby exposes whether it is a lambda through the lambda? method described in the Proc documentation.

def twice
  yield 1
  yield 2
end

twice { |n| puts n }

def keep(&block)
  block
end

saved = keep { |n| n * 2 }
saved.call(3) # 6

The first block is consumed while twice runs. The second is converted to a Proc and called after keep has returned. Do not say that every block is automatically an ordinary argument object; that hides the syntactic distinction the interviewer is testing. Equally, do not describe a Proc as a thread or asynchronous task. Saving a callable does not schedule it.

A strong opening answer is: “A method call can carry a block. I can yield to it or capture it as a Proc. A lambda is a Proc whose arity and return resemble a method more closely. I will trace the call site before predicting output.” That framing prepares you for the actual exercise rather than reciting definitions.

Exercise: where does return go?

The Ruby Proc documentation distinguishes a lambda's local return from a regular Proc's nonlocal return. For a lambda, return exits that lambda and control resumes in the method that called it. For an ordinary Proc created inside a method, return attempts to exit the defining method. If that method has already finished by the time the Proc runs, a LocalJumpError can result.

def lambda_trace
  f = -> { return :inside_lambda }
  [f.call, :after_call]
end

def proc_trace
  f = proc { return :from_method }
  f.call
  :after_call
end

lambda_trace # [:inside_lambda, :after_call]
proc_trace   # :from_method

Do the trace in order. In lambda_trace, the lambda produces a value for f.call, then the array evaluates its second element. In proc_trace, the return exits proc_trace itself, so the final expression is not reached. This is not simply a formatting difference between braces and arrow syntax; it is control flow.

Now move the Proc's invocation after its defining method ends:

def delayed_return
  proc { return :too_late }
end

later = delayed_return
later.call # LocalJumpError

The stored Proc remains callable, but its return target is no longer a live method invocation. The error is not evidence that closures cannot outlive a method; a closure without this nonlocal return can do so. It is evidence that this particular control transfer cannot complete. If the exercise uses next instead of return, or creates the Proc in another context, trace that construct separately rather than copying this answer mechanically.

Exercise: how many arguments are required?

The official Proc examples show lambda-style strictness and ordinary Proc leniency. A lambda checks the number of arguments more like a method. A non-lambda Proc may fill missing positional parameters with nil or ignore extras, subject to the shape of parameters and Ruby's argument rules. Do not generalize that every possible destructuring form behaves identically.

loose = proc { |a, b| [a, b] }
strict = ->(a, b) { [a, b] }

loose.call(7)  # [7, nil]
strict.call(7) # ArgumentError

The interview follow-up is usually about bugs, not syntax. A callback expected to receive an order ID and revision number. After an API change, its caller supplies only the ID. A lenient Proc can continue with revision nil and fail later at a less obvious location. A lambda fails at the boundary where the wrong argument count was supplied. Strictness helps reveal that particular contract violation; it does not validate the values of correctly counted arguments.

Try a second trace with an extra argument. Predict what the ordinary Proc accepts and what the lambda rejects. Then check whether the callback was created with proc, lambda, or the arrow form, and whether an ampersand conversion changed how the callback is passed. The observable call and its argument list matter more than the variable name chosen for it.

What does a closure capture?

A closure can refer to local variables from the environment in which it was created, even after the enclosing method returns. The useful model is a retained binding, not a one-time textual copy of every value. The following two calls share the captured counter in the one closure returned by make_counter:

def make_counter
  count = 0
  -> { count += 1 }
end

first = make_counter
first.call # 1
first.call # 2

second = make_counter
second.call # 1

Each invocation of make_counter creates a separate local environment, so first and second do not share a count. But repeated calls through first update the same captured binding. An interview answer should name the number of closures and environments, then give output. If the closure captures a mutable object, distinguish changing the binding to reference another object from mutating the object it currently references.

This matters for lifetime and memory too. If a long-lived registry stores a Proc that closes over a large request object, that object may remain reachable for as long as the registry holds the Proc. A closure is not automatically a leak: retaining data may be intentional. The debugging question is who retains the callable, what its environment references, and when that owner releases it. This is a general inference about object reachability, not a claim that Ruby's documentation promises one particular memory footprint.

How do you debug an unexpected return in a callback?

Begin with the code that constructs the callback, not only the line that calls it. Is it a block passed directly to the current method, an ordinary Proc, or a lambda? Which method was active at construction? Is the callback invoked immediately or stored for later? Does the body contain return, next, break, or an exception? Each has its own control-flow rules.

Imagine a method that registers a callback and returns normally. Later, an event loop invokes that callback. If the callback is an ordinary Proc containing return, a LocalJumpError is plausible because the original method has finished. Converting it to a lambda can change return to local behavior, but verify whether the caller relies on the old nonlocal exit when invoked synchronously. A fix that changes semantics should come with a test for both immediate and delayed calls.

Avoid resolving the symptom by rescuing LocalJumpError broadly. That can hide a broken control-flow contract. Prefer a callable whose return semantics match the intended lifetime, and have the calling method handle its result explicitly. If an exception is intended to abort work, use an appropriate exception strategy and document its boundary instead of accidentally relying on Proc return.

How should these distinctions influence API design?

Use a directly supplied block when an operation naturally calls user code as part of one method invocation, such as iterating records or wrapping a transaction. Store a callable object when the work must happen later or be composed. Choose a lambda where strict arity and local return make the callback boundary easier to reason about. This is a design recommendation, not a rule that an ordinary Proc is always wrong.

For a library API, state whether a callback can run after the registering method returns, whether it may run multiple times, how errors propagate, and what it receives. If a callback can outlive its registration, an ordinary Proc with a nonlocal return is a risky fit. If it is synchronous and the API intentionally lets a block terminate a surrounding method, that behavior may be useful, but should be explicit.

An interviewer may ask about method objects or symbols converted to Proc. They are related callable forms, but do not use them to dodge the central trace. First identify the object actually created by the code in front of you; then expand to alternatives. Another follow-up is threading: capturing a mutable variable does not make access to it safe when multiple threads call the closure. Lifetime, return semantics, and synchronization are independent dimensions.

A compact trace to rehearse aloud

For each snippet, write a four-column note: creation site, call site, arguments, return destination. Add a fifth column for captured bindings if output depends on external state. For lambda_trace, the creation and call sites are in the same method, arity is zero, and return ends only the lambda. For delayed_return, creation is in a completed method and the non-lambda return has no valid live target. For make_counter, there is no explicit return jump; the crucial fact is the persistent binding.

Then change one thing at a time. Replace proc with arrow lambda and predict the return. Call with one argument instead of two and predict whether an error occurs. Create a second counter and predict whether it shares state. These mutations isolate the rule that explains the output. That is what an interviewer can evaluate; a memorized slogan cannot tell them whether you can debug a real callback.

Practice five linked questions

QuestionRuby connectionFollow-up to answer aloud
Design rotating warehouse simulator with closuresRetained environmentWhich value changes across calls?
Implement a type-erased function wrapperCallable objectWho owns the function and its captured data?
Implement Composable Range IteratorsBlock and iterator contractsWhen is the callback invoked?
Design a Lazy Union Iterator for Sorted InputsDeferred executionWhich environment must remain available later?
Implement custom iterators and interfacesYield and callback behaviorWhat happens if consumer code returns early?

Start with Design rotating warehouse simulator with closures. Draw the retained state, then turn one callback into a lambda and another into an ordinary Proc. Predict the result and error behavior before executing it.

Sources and Further Reading