skip to content

What forces a short-lived local value onto the heap when its scope is a single function call?

level: middleimportance: must knowfreq 58%

answer

  1. three causes, not one
  2. reachability, size, capture
  3. stored, returned or published
  4. a length decided at run time
  5. a capture handed outside the scope

basics

~20 s

Three independent causes. A reference escapes by being stored, returned or published; the size is not known when the frame layout is decided; or a closure captures the value and outlives the scope. Any one of them is enough.

solid answer

~50 s

Being short-lived in the source is not the same as being provably short-lived. Three separate conditions each force dynamic allocation. First, **a reference escapes**: it is written into a longer-lived structure, returned, published where another thread can reach it, or passed to a call whose body the compiler cannot examine and which must therefore be assumed to retain it. Second, **the size is not known** when the frame layout is settled — a buffer whose length comes from run-time input cannot be given a fixed slot in a frame of fixed size. Third, **a capture outlives the scope**: a closure or deferred task holds the value and is itself handed out, so the captured state has to survive the frame. Notice what is *not* on the list: how many fields the value has, how many bytes it occupies, or the mere fact that it was passed as an argument.

code

pseudocode · 14 lines
pseudocode
procedure build(n, rows):
    a = make Point(1, 2)
    total = a.x + a.y            // a never escapes: frame-local

    b = make Point(3, 4)
    registry.last = b            // cause 1: stored in longer-lived memory

    buf = make Buffer(n)         // cause 2: length known only at run time
    fill(buf, rows)

    c = make Point(5, 6)
    later = closure returning c.x // cause 3: capture handed to the caller

    return total, later

go deeper

for a junior

Remember that three different things push a value into dynamic storage: a reference getting out, a size nobody knew in advance, and a capture that lives longer than the code that made it.

for a middle

Be able to separate the three causes and give one concrete shape for each, and to say what does not force dynamic allocation - argument passing, field count, loops and mutation.

for a senior

Read a hot path and predict which of the three is firing, because the remedy differs: change the interface, bound the size, or shorten the capture's lifetime.

for a principal

Consider which of the three a codebase should never allow on its critical paths, and encode that in the shape of the interfaces rather than in a comment asking maintainers to be careful.

## Short-lived in the source is not short-lived in the proof A value written inside one function, used a few lines later and never mentioned again *looks* frame-local. Whether it can be treated as frame-local is a different claim, and it fails for three unrelated reasons. They are worth keeping separate, because each has its own fix. ## Cause 1: a reference escapes The value stays frame-local only while every reference to it dies with the frame. Each of these breaks that: - **Stored** into a field of something that already lives on the heap, or into a shared collection, a cache or a registry. - **Returned**, directly or inside another value that is returned. - **Published** where another thread can find it — handed to a queue, written to a shared field, passed to a task that will run elsewhere. - **Passed to a call the compiler cannot examine**, which must be assumed to do any of the above. Note the shape of this one: the value may in fact never be retained; what is missing is the *evidence*, and the analysis is conservative. ## Cause 2: the size is not known when the frame is laid out A frame's size is settled when the code is compiled, because entering the call has to reserve it in one step. A buffer whose length is computed from run-time input has no fixed slot to be given. Ecosystems differ here — some offer a restricted form of run-time-sized stack space, always with a bound, because an unbounded request would run the frame into the guard page and take the whole thread down — but the general rule stands: **unknown size at layout time means dynamic allocation.** This cause is independent of escape: a buffer that nothing outside will ever see still cannot be given a frame slot of unknown width. ## Cause 3: a capture outlives the scope A closure, a deferred task or a continuation that names the value keeps a reference to it. If the closure itself is returned, stored or scheduled, then everything it can reach has to survive the frame that created it. Capturing by copying the reference does not help: copying a reference does not copy the referent, and the referent is what needs to stay alive. ## Side by side | cause | what fails | typical fix | |---|---|---| | escaping reference | nothing outside may still reach the value | hand out the components or a copy instead of the reference | | unknown size | the frame needs a fixed slot | bound the size, or accept a dynamic block | | capture that outlives the scope | captured state must outlive the frame | capture plain values, or keep the closure inside the scope | ## What does not force the heap Just as useful to have straight, because each of these is a common wrong answer: - **Passing the value as an argument.** If the callee is visible to the compiler and does not retain the reference, the value has not escaped; it was merely read by a frame that is still nested inside this one. - **Having many fields, or large fields.** Size affects the frame's width, not the region. - **Being created inside a loop.** The same slot is reused each iteration when the value does not escape. - **Being mutated.** Mutation of a frame-local value is just writing to local storage. - **Being read many times.** Reads create no reference that survives. ## Why this matters at work The three causes have different consequences under load. An escaping temporary in a hot loop produces one dynamic block per iteration, so the volume scales with throughput. An unknown-size buffer usually produces one larger block per operation, and the interesting question becomes whether the size is bounded at all. A capture that outlives the scope is the one that also changes *lifetime*: the captured state stays alive for as long as the closure is held, which is how a value that looks local ends up retained far longer than the call that made it. Recognising which of the three you are looking at tells you whether to change the interface, bound the size, or shorten the capture.

  • Why does passing a value to a call not always make it escape?
    Because the callee's frame is nested inside the caller's, so a reference that the callee only reads dies before the caller returns. Escape requires the reference to reach something that outlives the caller's frame. The verdict flips only when the callee stores, returns or publishes it — or when the compiler cannot examine the callee and must assume it does.
  • A buffer is sized from run-time input but never leaves the function. Can it still be frame-local?
    Generally not, because the frame's size is settled when the code is compiled and an arbitrary length has no fixed slot. Some ecosystems allow a bounded run-time-sized region inside a frame; without such a facility, or without a bound, the buffer is a dynamic block. Non-escape alone is not enough.

saying these in an interview costs you the question

  • Names escaping references only and forgets a run-time-sized buffer
  • Thinks a value used solely inside one function can never need the heap
  • Believes passing a value to another call always makes it escape
  • Says the heap is chosen whenever a value has more than a few fields
  • Treats a closure capture as free because the captured value is small