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?
answer
- the block's last expression
- empty block gives nil
- a trailing puts returns nil
- keep the result, or yield last
- ensure runs but its value is discarded
basics
~20 syield 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 linesdef 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 stderrgo deeper
Recall that yield evaluates to the block's last expression and that puts returns nil, so a method ending in puts returns nil.
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.
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.
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