In Ruby, how do you memoise a price lookup in a lambda that closes over a Hash, and why can cache[sku] ||= miss the cache?
answer
- cache local captured once
- one Hash per factory call
- ||= recomputes nil and false
- key? or fetch with a block
- no eviction by default
basics
~20 sCreate the Hash as a local in a factory method and return a lambda that checks it before computing; every call shares that Hash. cache[sku] ||= price_for(sku) recomputes whenever the stored price is nil or false, so check key? instead.
solid answer
~40 sAssign `cache = {}` inside a factory method and return `->(sku) { cache.fetch(sku) { cache[sku] = source.price_for(sku) } }`. The lambda closes over the `cache` variable, so all its calls use one Hash, and each call to the factory gets a fresh cache. The shorter `cache[sku] ||= source.price_for(sku)` is the classic trap: `||=` assigns whenever the current value is `nil` or `false`, so an unknown SKU whose price is `nil` is looked up again on every call. Checking `cache.key?(sku)`, or `fetch` with a block, treats a stored `nil` as a hit. The captured Hash lives as long as the lambda and never evicts anything, so a long-lived lookup needs a size limit or a shorter lifetime.
code
ruby · 14 linesdef build_price_lookup(source, limit: 1_000)
cache = {}
->(sku) do
cache.fetch(sku) do
cache.shift if cache.size >= limit # drop the oldest entry
cache[sku] = source.price_for(sku) # nil is stored too
end
end
end
lookup = build_price_lookup(catalog)
lookup.call("SKU-1") # asks catalog
lookup.call("SKU-1") # served from cache
lookup.call("GONE") # stores nil; the next call is a hitgo deeper
Remember the shape: a local Hash in a factory method, and a returned lambda that checks it before doing the expensive work.
Explain why ||= recomputes nil and false, and why key? or fetch with a block fixes it; state that each factory call gets its own cache.
Discuss lifetime and growth: a closure cache in a constant lives for the process, so bound it, scope it to a request, or expose a reset.
Decide when closure-held caches are acceptable versus an object or a shared cache store, weighing inspection, memory limits and concurrent access.
## A lookup that remembers **Memoisation** means storing the result of an expensive call so the next call with the same argument returns the stored result. A closure gives Ruby a compact way to do it without a class: a factory method creates a local `Hash` and returns a lambda that closes over it. ```ruby def build_price_lookup(source) cache = {} ->(sku) { cache.fetch(sku) { cache[sku] = source.price_for(sku) } } end ``` The lambda captures the variables `cache` and `source`. `Hash#fetch` with a block returns the stored value when the key exists and otherwise runs the block; the block stores the freshly computed price with `cache[sku] = ...` and returns it, because an assignment returns the assigned value. Tracing the calls makes the behaviour concrete: 1. The first `lookup.call("SKU-1")` misses, runs the block, asks the source and stores the price. 2. The second call with the same SKU finds the key and returns the stored price without touching the source. 3. A call for a discontinued SKU stores `nil`; later calls for it are hits, because `fetch` checks for the key, not for a truthy value. 4. A new call to `build_price_lookup` starts again with an empty Hash. ## Who owns the cache - **One Hash per factory call.** Each call to `build_price_lookup` runs `cache = {}` in a new environment, so two lookups built separately never share entries. - **Shared by every call of the same lambda.** All invocations of one lookup read and write the same `cache` object. - **Private.** Only closures created in the same factory call can reach `cache` by name; `Proc#binding` can too, but that is a debugging tool rather than an interface. - **Lifetime.** The Hash lives exactly as long as something references the lambda; store the lambda in a constant and the cache lives for the whole process. ## `||=` and stored nil or false `cache[sku] ||= compute` behaves like `cache[sku] || (cache[sku] = compute)`. It skips the computation only when the stored value is **truthy**. In Ruby only `nil` and `false` are falsy, so: | Memo form | Stored `nil` counts as a hit? | Stored `false` counts as a hit? | Stored `0` counts as a hit? | |---|---|---|---| | `cache[sku] \|\|= compute` | No, recomputed every time | No, recomputed every time | Yes | | `cache.key?(sku) ? cache[sku] : cache[sku] = compute` | Yes | Yes | Yes | | `cache.fetch(sku) { cache[sku] = compute }` | Yes | Yes | Yes | A price source that returns `nil` for a discontinued SKU is exactly the case where `||=` silently turns the cache into a pass-through for those keys. ## Lifetime and size A closure cache has no eviction policy of its own. Options, from simplest: 1. **Scope the lookup to a unit of work.** Build it at the start of a request or a batch job and let it be collected when that work ends. 2. **Cap the size.** A Ruby `Hash` keeps insertion order, so `cache.shift if cache.size >= limit` before storing drops the oldest entry, giving a simple first-in, first-out bound. 3. **Expose a reset.** Return a second lambda from the same factory call, `-> { cache.clear }`, which closes over the same `cache`. ## Closure cache versus an instance variable | Aspect | Closure over `cache` | `@prices \|\|= {}` in an object | |---|---|---| | Visibility | Hidden inside the lambda | Readable by the object's other methods | | Lifetime | The lambda's | The object's | | Testing and inspection | Awkward | Easy to stub, reset and inspect | | Fits when | A small, local helper | The cache is part of an object's behaviour | Neither form is thread-safe by itself: two threads can both miss and compute the same price. ## What else the lambda keeps The lambda keeps its whole creating scope, not only `cache`: the `source` object, any other local of the factory method, and the `self` the factory ran on. Keeping the factory method small, with only the inputs the lookup needs, keeps that footprint to the cache and its source.
- How would you let callers clear this closure's cache without exposing the Hash?Return a second lambda from the same factory call, for example `[lookup, -> { cache.clear }]`. Both close over the same `cache` variable, so calling the second one empties the Hash the first one reads, while neither hands the Hash itself to the caller.
- When is an instance-variable memo such as @prices ||= {} a better home than a closure?When the cache belongs to an object's lifecycle, when several methods of that object need it, or when tests must inspect or reset it. A closure hides the state well but offers no seam for inspection, so it suits small local helpers better than domain objects.
saying these in an interview costs you the question
- cache[sku] ||= lookup caches every result, including nil
- Each call of the lambda starts with an empty cache
- Every lookup built by the factory shares one cache
- A closure's cache is freed when the factory method returns
- Hash#fetch with a block stores the block's value in the hash