Forcing a thunk raises an error instead of returning a value. What can the next demand do, and what does each choice cost?
answer
- a raised error is still an outcome
- once-only is a guarantee, not an optimisation
- retrying lets two readers disagree
- unforced, being evaluated, settled
- who pays when the body runs again
basics
~20 sEither the failure is cached, so every later demand raises the same error without re-running the body, or the cell stays unforced and the next demand retries. Caching keeps the value deterministic and run-once; retrying repeats the body and its cost.
solid answer
~50 sThere are two defensible designs and they trade different things. Caching the failure treats the raised error as the outcome: the body runs once, every demand from then on sees the same result, and the lazy value behaves like a value rather than like a call. Leaving the cell unforced makes the next demand run the body again, which can succeed where the first attempt failed - attractive when the failure was transient, but it discards the once-only guarantee that made the value safe to share, re-pays the body's cost, and lets two readers disagree about what the same value is. A third state matters just as much in practice: marking the cell while its body runs, so that a body demanding the value it is defining is reported as a cycle instead of looping.
code
pseudocode · 14 linesfunction force(cell)
if cell.state is DONE return cell.value
if cell.state is FAILED raise cell.error
if cell.state is RUNNING raise "value demanded while it is being computed"
cell.state = RUNNING
try
cell.value = cell.body()
cell.state = DONE
return cell.value
on error e
cell.error = e
cell.state = FAILED // settled, not back to unforced
raise ego deeper
Know that forcing can end badly: the body is ordinary code, so it can raise, and the lazy value then has no result to hand back.
State both designs - cache the failure, or leave the cell unforced - and say what each does to the once-only guarantee and to the cost of running the body.
Bring the operational angle: a retrying lazy value re-runs an expensive body under precisely the conditions that made it fail, and its readers can observe different outcomes for the same value.
Settle it as policy rather than per site. Deterministic failures belong in the cache, and anything that should be retried is really a request rather than a lazily computed value.
## Forcing has more than one outcome A thunk's body is ordinary code, so demanding a lazy value can end in three ways, not one: it returns a value, it raises, or it fails to terminate - including the special case where it demands the very value it is defining. A two-state model of a deferred computation, *unforced* and *forced*, has an answer only for the first of those. The other two are where the design decisions live. The question matters because the appeal of a lazy value rests on a promise: *this is a value, computed at most once, the same for everyone who reads it*. A failed force puts that promise under pressure, and how the implementation resolves it decides whether the promise survives. ## Design A - cache the failure Treat the raised error as the result. The cell moves to a settled state carrying the error; every later demand re-raises it immediately without running anything. - The body runs **at most once**, exactly as it does for a successful force. - Every reader observes the same outcome, so the value stays deterministic. - A repeated demand is cheap - it is a read, not a computation. - An expensive body that failed halfway is not paid for twice. - The cost: a failure that was genuinely transient is now permanent for the lifetime of that value. ## Design B - leave the cell unforced Treat the failure as if the demand never happened. The cell stays deferred and the next demand runs the body again. - A transient failure can resolve itself on the next attempt, which is the whole attraction. - But the once-only guarantee is gone: the body may run any number of times, and its cost is paid each time - typically under exactly the conditions that made it fail. - Two readers can now disagree. One demand raises, the next returns a value, and the lazy value stops behaving like a value at all. - Whether anything is retried becomes a function of who reads and in what order, which is not a property the reading code can see. | | Cache the failure | Leave it unforced | |---|---|---| | Times the body may run | at most once | once per demand until it succeeds | | Same outcome for every reader | yes | no | | Cost of a repeated demand | a read | a full re-run | | Transient failure recovers | no | possibly | ## The third state Independently of that choice, a robust implementation needs a state meaning **being evaluated right now**. Without it, a body that demands the value it is defining re-enters the same cell, finds it unforced, and starts the body again - looping forever, or in some designs reading an unfinished slot and producing a value that is quietly wrong. Marking the cell on entry lets the second entry be recognised and reported as a self-referential definition, which is a diagnosable error rather than a hang. That state also has to be cleaned up on the failure path. If the body raises while the cell is marked as being evaluated and nothing resets it, later demands report a cycle that does not exist - a real bug that is easy to write and hard to read back out of a stack trace. ## Choosing in practice 1. **Decide it once, as policy**, rather than per call site. Readers of a lazy value cannot see which discipline it uses, so consistency is what makes the abstraction trustworthy. 2. **Prefer caching for deterministic failures.** If the body will fail the same way every time, retrying only spends the cost again for the same answer. 3. **Model retryable work as a call, not as a value.** Anything whose outcome may legitimately differ between demands is a request; naming it as one puts retry policy, backoff and error handling where the caller can see and control them. 4. **Always detect re-entry**, whichever failure discipline you chose - a hang gives an operator nothing to work with, while a named self-reference error points at the definition that caused it.
- What does a being-evaluated state protect against?A body that demands the very value it is defining. With only unforced and forced states the implementation re-enters the same cell, finds it unforced and starts the body again, so it loops or reads an unfinished slot. Marking the cell while its body runs lets the second entry be recognised and reported as a self-referential definition.
- If retrying is what the caller wants, what is the honest way to model it?As a function or a request the caller invokes, not as a lazy value. A lazy value promises one outcome computed at most once; anything that may legitimately differ between demands is a call, and naming it as one keeps retry policy, backoff and error handling visible to the caller instead of hidden in a cell.
- Why is a cached failure easier to reason about than a retried one?Because it preserves the property the abstraction was sold on: one outcome, shared by every reader, produced by at most one run of the body. With retries, what a reader sees depends on how many demands came before it and in what order, which is invisible at the point of use.
saying these in an interview costs you the question
- Assumes forcing always ends in a value and never in a raised error.
- Thinks retrying after a failure is free because nothing was cached.
- Says a lazy value is deterministic however failures are handled.
- Believes two states are enough to make forcing safe.
- Treats a self-referential lazy definition as something that simply resolves later.