skip to content

In Ruby, what does a yield expression evaluate to, and how should a with_timing helper hand the block's result back to its caller?

level: middleimportance: should knowfreq 45%

answer

  1. the block's last expression
  2. empty block gives nil
  3. a trailing puts returns nil
  4. keep the result, or yield last
  5. ensure runs but its value is discarded

basics

~20 s

yield evaluates to the block's last expression, or nil for an empty block. A with_timing wrapper should make yield its return value, for example by timing in an ensure clause, instead of ending with a logging call that returns nil.

solid answer

~40 s

`yield` is an expression: its value is whatever the block's last expression produced, and `nil` for an empty block. A wrapper such as `with_timing(label) { load_rows }` exists to add behaviour around the work without changing what the work returns, so the caller should get `load_rows`'s result. The classic bug is ending the method with `puts "took ..."`, which makes the method return `nil`. Two fixes: store the result (`result = yield`), log, then return `result`; or put `yield` in the method body and the timing in a method-level `ensure`, because an `ensure` clause runs even when the block raises and its own value is discarded. Measure with `Process.clock_gettime(Process::CLOCK_MONOTONIC)` rather than `Time.now`, which can jump when the wall clock changes.

code

ruby · 10 lines
ruby
def with_timing(label)
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  yield
ensure
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
  warn format("%s took %.3fs", label, elapsed)
end

total = with_timing("sum invoices") { [120, 80, 45].sum }
total # => 245, and "sum invoices took 0.000s" went to stderr

go deeper

for a junior

Recall that yield evaluates to the block's last expression and that puts returns nil, so a method ending in puts returns nil.

for a middle

Explain why a wrapper must be transparent and show both fixes: saving the result, or yielding last with the timing in a method-level ensure.

for a senior

Show that the ensure shape keeps both the block's value and the timing when the work fails, and that a monotonic clock is the right measure for elapsed time.

for a principal

Discuss when block wrappers are the right instrumentation seam versus central tooling, and how to keep wrappers transparent so they can be added or removed without changing behaviour.

## yield is an expression In Ruby, `yield` runs the block that the caller attached to the current method call, and the `yield` expression **evaluates to the block's last expression**. The method can use that value like any other: ```ruby def doubled 2 * yield end doubled { 21 } # => 42 ``` A few rules follow directly: - An **empty block** (`with_timing("x") {}`) makes `yield` evaluate to `nil`. - The block's value is only a **value handed back to the method**; the method's own return value is still its own last expression. - If the block raises, `yield` never produces a value: the exception propagates out through the method. ## The wrapper pattern A **wrapper method** runs setup, yields to the caller's block, then runs teardown. Timing, logging, retrying and transactions all take this shape. The contract of a good wrapper is that it is **transparent**: the caller writes ```ruby rows = with_timing("load rows") { fetch_rows(limit: 100) } ``` and expects `rows` to be exactly what `fetch_rows` returned. ## The bug: a trailing log line ```ruby def with_timing(label) started = Process.clock_gettime(Process::CLOCK_MONOTONIC) yield puts "#{label}: #{Process.clock_gettime(Process::CLOCK_MONOTONIC) - started}" end ``` This method returns the value of its last expression, the `puts` call, and **`puts` returns `nil`**. Every caller now gets `nil`, and the block's result is thrown away. Nothing fails loudly: the timing line prints and the data silently disappears. ## Two correct shapes 1. **Keep the result explicitly.** Assign `result = yield`, do the logging, then end the method with `result`. Clear and easy to read, but if the block raises, the logging line never runs. 2. **Time in an `ensure` clause.** Make `yield` the last expression of the method body and move the measurement into a method-level `ensure`. The `ensure` clause runs whether the block returned normally or raised, and **its value is discarded**, so the method still returns what `yield` returned. | Shape | Returns block value | Logs when the block raises | |---|---|---| | `yield` then `puts` | no, returns `nil` | no | | `result = yield`, log, `result` | yes | no | | `yield` with `ensure` logging | yes | yes | The one exception to "ensure's value is discarded" is an explicit `return` written inside the `ensure` clause, which overrides the result and even swallows an exception in flight; do not put one there. ## Measuring time correctly - **`Process.clock_gettime(Process::CLOCK_MONOTONIC)`** reads a clock that only moves forward, so elapsed times stay correct if the system clock is adjusted. - `Time.now` reads the wall clock; subtracting two `Time` objects gives a `Float` of seconds, but the result can be wrong or negative across a clock change. - `Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)` returns milliseconds directly when that unit reads better in logs. ## Passing data to the block A wrapper may also yield values: `yield(started)` would let the block see the start time. Whatever the block computes still comes back as `yield`'s value, so the same transparency rule applies. Keep the argument list small; the point of the wrapper is that the caller's code looks the same with or without it. ## Checklist for a transparent wrapper 1. The method's **last expression is `yield`** (or a variable holding its value). 2. Logging, metrics and cleanup live in **`ensure`** or run before `yield`, never after it as the final line. 3. Arguments passed to `yield` are **documented**, because the caller's block parameters depend on them. 4. The wrapper **does not rescue** exceptions it cannot handle; it lets the caller see the original error. 5. A test asserts that `with_timing("x") { :result }` returns `:result`, which catches the trailing-`puts` bug immediately. ## Interview angle Interviewers use this question to check that a candidate knows `yield` returns a value, knows `puts` returns `nil`, and reaches for `ensure` so the timing is recorded even when the work fails.

  • If with_timing calls yield(started) instead of yield, what does the caller receive?
    Still the block's value. The argument only fills the block's first parameter, so `with_timing("x") { |t0| compute(t0) }` sees the start time, and whatever `compute` returns becomes the value of `yield` and, with the ensure shape, of the whole method.
  • Why is Process::CLOCK_MONOTONIC preferred to Time.now for elapsed time?
    A monotonic clock only moves forward and is not stepped when the system's wall clock is set or corrected, so the difference between two readings is a reliable duration. `Time.now` follows the wall clock, which can jump, giving wrong or negative durations.

A with_timing wrapper is a courier who carries a parcel through a checkpoint: the checkpoint stamps the time, but the courier must still hand over the parcel it was given, not the stamp.

saying these in an interview costs you the question

  • A method automatically returns its block's value
  • puts returns the string it printed, so a trailing log line is harmless
  • An ensure clause's last expression becomes the method's return value
  • yield returns the block itself as a Proc
  • Time.now differences are the most reliable way to time code