skip to content

A long-lived callback is rewritten to capture two values copied into locals beforehand — what does that release?

level: middleimportance: should knowfreq 40%

answer

  1. the closure holds what you handed it
  2. the owner goes, the values stay
  3. a copied handle is still a handle
  4. released only if nothing else reaches it
  5. copy out small self-contained values

basics

~20 s

Everything that was alive only through the enclosing instance. The closure now holds two locals, so the instance, its collections and its caches become unreachable — unless one of the copied values leads back into that same graph.

solid answer

~40 s

Copying the needed values into locals before the function is built changes what the closure must hold: those locals, instead of the object that owned them. Everything that was alive only because the instance was captured is now free — the instance, its collections, its caches, and whatever hung off them. What the rewrite does not release is whatever the copied values themselves reference: copying a handle to a container copies the handle, not the contents, so a narrowed capture that still holds one large structure has moved the problem rather than removed it. The rule of thumb is to copy out small, self-contained values and to ask, for each one, what it drags behind it.

code

pseudocode · 9 lines
pseudocode
// inside the same screen object
method startJob(service)
    bar = statusBar          // copied out before the function exists
    name = jobLabel
    service.onProgress(function (percent)
        bar.setValue(percent)
        log(name, percent)
    end)
end

go deeper

for a junior

Remember the shape of the fix: take out the few values the body needs, then build the function from those locals.

for a middle

Explain precisely what becomes unreachable after the rewrite, and why a copied handle to a large structure keeps that structure alive.

for a senior

Verify instead of assuming: name what still reaches the old graph afterwards, including other closures and other entries in the same holder.

for a principal

Make "what a long-lived function may capture" an explicit standard, and accept that any rule the team cannot check in seconds will not hold.

## What the rewrite actually changes The environment of a closure is decided by the free names in its body. Copying values into locals *before* the function literal is written changes those names: the body now mentions locals rather than members, so the environment is those locals and the enclosing instance is not part of it. The ordering matters — the copy has to happen before the function value is created, because the environment is fixed at creation. Nothing else about the callback changes. It is invoked at the same moments, from the same holder, with the same arguments. The only difference is the set of things it is obliged to keep alive. ## What it releases After the rewrite, anything that was reachable **only** through the enclosing instance becomes unreachable once the rest of the program drops that instance: - the instance itself; - its collections, and the elements in them; - its caches and decoded buffers; - an upward link to a containing object, and that object's graph in turn. This is usually the whole point, because that upward link is what makes a hub-shaped object so expensive to pin: a single captured reference can reach far more than the code around it suggests. ## What it does not release Three things survive the rewrite, and missing any of them is how a "fixed" leak comes back: 1. **Whatever the copied values reference.** Copying a handle to a container into a local copies the handle. The container and every element in it are still reachable through the closure. 2. **Anything another reference still reaches.** If a second closure, a field somewhere, or another entry in the same holder still refers to the instance, releasing this one changes nothing. 3. **The entry itself.** The holder still has an entry per registration; the rewrite makes each entry cheap, it does not make the count smaller. | what you copy out | what the closure then pins | |---|---| | a number or an identifier | its own few bytes | | a handle to a small leaf object | that object | | a handle to a container | the container and all its elements | | a handle to a hub object | the hub and everything behind it | Read down that table before congratulating yourself: rows one and two are a fix, row three is a smaller version of the same problem, and row four is no fix at all. ## Choosing what to copy out The practical test for each candidate value is: *what does this reach?* - **Prefer self-contained values** — identifiers, counts, labels, flags. They reach nothing, so they cost their own size forever and that is affordable. - **Prefer a narrow collaborator** to the object that owns it. If the body drives one small component, capture that component rather than the hub that holds it. - **Extract a function beforehand** when the body needs behaviour rather than data, so the closure captures that function value instead of the instance that defined the behaviour. - **Be suspicious of anything plural.** A list, a map, a buffer or a cache copied out is a handle to everything inside it. One honest trade-off comes with the technique. Once a value is copied out, the closure works from what it was handed at creation rather than from whatever the object holds later, so the rewrite is equivalent only when the body does not need to follow subsequent changes to that object. Where it does, the narrow capture has to be a small object that exposes the current value without also reaching the rest of the graph. ## Checking the result A short review at the keyboard is enough for most cases, and it is the same four questions each time: 1. Does the body still name any member, directly or through a receiver? 2. For each local it does name, what is the largest thing reachable from it? 3. Is there anything else in the program still holding the instance you meant to release? 4. If this entry is never removed, is the remaining cost one you are willing to pay for the whole process? If the answer to the last one is yes, the retention has stopped being a defect and become a decision — which is the actual goal. Narrow captures do not depend on anyone remembering to clean up later, and that is what makes them the durable half of this fix.

  • The narrowed callback still holds one small mutable holder object. Is that a problem?
    Only if the holder reaches back into the large graph. A holder whose fields are scalars costs its own size and nothing more; the same holder carrying a reference to the loaded data pins all of it. What it points at matters, not how small it looks.
  • What if the callback genuinely needs the enclosing object's behaviour, not just its data?
    Capture the narrowest thing that provides it — a small collaborator, or a function extracted before the closure is built. If nothing narrower exists, the retention is real rather than accidental, and the number of live entries becomes the quantity to bound instead.

saying these in an interview costs you the question

  • Thinks copying a reference into a local copies the data.
  • Assumes narrowing a capture always shrinks the retained graph.
  • Believes the instance is freed while another closure still holds it.
  • Copies out a container and calls the retention fixed.
  • Thinks the rewrite changes when or how often the callback runs.