skip to content

A callback created inside a screen object reads one unqualified field of that object — what does the callback retain?

level: middleimportance: must knowfreq 66%

answer

  1. the read still needs a receiver
  2. one field, whole object
  3. capture is transitive
  4. the hub behind the small field
  5. copy the value into a local first

basics

~10 s

The whole enclosing object, not the field. An unqualified member read resolves through the instance that owns it, so the closure captures that instance and transitively keeps everything reachable from it alive.

solid answer

~40 s

Reading a member without qualifying it still means "the member of the object I was written inside", so the closure has to hold a reference to that instance in order to perform the read at call time. Capturing one instance captures its entire reachable graph: every field, the collections in those fields, and whatever they point at in turn. A screen object typically holds loaded rows, cached images and a parent view, so a callback that only wanted a progress bar now pins the whole screen. The size of the retention has nothing to do with the size of the thing you thought you captured. The fix is to copy the one or two values the body needs into locals before the function is created, so the closure captures those instead.

code

pseudocode · 11 lines
pseudocode
object DetailScreen
    field statusBar
    field loadedRows      // thousands of records
    field imageCache

    method startJob(service)
        service.onProgress(function (percent)
            statusBar.setValue(percent)   // unqualified member read
        end)
    end
end

go deeper

for a junior

Remember that a function written inside an object can only read that object's members by keeping hold of the object itself.

for a middle

Explain the transitive part: capturing one instance keeps every object reachable from it alive, whatever the body actually touches.

for a senior

Size the damage: per registered callback the cost is the graph unique to that instance, multiplied by how many were registered and never dropped.

for a principal

Decide where functions that outlive their creator may be built at all, and what shape the team writes by default at those call sites.

## Why one field means the whole object When a function written inside an object's body names a member without qualifying it, the name still resolves to *that object's* member. To perform the read at call time the function needs the object, so **the instance is what gets captured** — there is nothing narrower to capture, because a field has no existence independent of the object that holds it. This is the part candidates consistently get wrong. They read the body, see one small field, and estimate the capture as that field. The environment is not chosen by size or by intent; it is whatever the body needs in order to run, and one unqualified member read needs the entire receiver. ## Capture is transitive Holding the instance holds everything reachable from it. Reachability does not stop at the fields the body happens to touch, and it does not stop at one level: - the fields of the instance, whether or not the callback reads them; - the contents of any collection in those fields, element by element; - objects those elements refer to, and so on to the end of the graph; - anything a parent or owner reference leads back into. A screen-shaped object is close to a worst case for this, because it is a hub: it holds the rows it loaded, images it decoded, and a link upward to the surface that contains it. One captured reference to it can pin megabytes that the callback never mentions. | what the body names | what the closure captures | what stays alive | |---|---|---| | its own parameter | nothing | nothing | | a local copied out beforehand | that local | what the local itself reaches | | an unqualified member | the enclosing instance | the instance and its whole graph | | a member through an explicit receiver | the enclosing instance | the instance and its whole graph | The last two rows are the ones worth memorising: writing the receiver explicitly is a spelling change, not a capture change. The read still goes through the object either way. ## Why long-lived callbacks are where it bites A closure that is created, called and dropped inside one method captures the instance too — and it does not matter, because the closure dies immediately and the instance was alive anyway. The damage appears only when the closure outlives what it captured: 1. A callback is handed to a service, a scheduler or a notification list that lives longer than the screen. 2. The screen is closed and every other reference to it is dropped. 3. The callback is still held, so the screen is still reachable, so nothing about it is released. 4. The next screen repeats the pattern, and the retained set grows by one screen each time. Nothing here looks like a mistake at any single step, which is why interviewers like the question: it tests whether a candidate knows the *consequence* of a feature they treat as free. ## The fix: capture the values, not their owner Before the function is written, copy out what the body actually needs: - pull the one collaborator the callback drives into a local; - pull the identifier or label it reports into a local; - then write the function so that its body names only those locals and its own parameters. Now the environment is those two locals. The instance is no longer part of it, and once the screen is closed nothing reaches it, so the rows and the images go with it. Two caveats keep this honest. First, the copied locals still pin whatever *they* reference — copying a handle to a large container narrows nothing. Second, once you copy a value out, the closure works from what you handed it rather than from the object's current state, so the rewrite is only equivalent when the body does not need to follow later changes to that object. ## Signals you are about to do this - the function literal is written inside an object that owns collections or caches; - the value it is handed to outlives the object — a registration, a subscription, a schedule; - the body mentions a member name rather than a parameter or a local; - the callback is "small", which is the reasoning that hides the cost in the first place. Any two of those together are worth thirty seconds of thought at the keyboard, which is cheaper than any later investigation of why memory grows one screen at a time.

  • Does qualifying the field with an explicit receiver reduce what is captured?
    No. Explicit or implicit, the read is performed through the instance at call time, so the instance is captured either way. Only moving the value into a local before the function value is created changes what the environment holds.
  • Two callbacks are created inside the same object — how much is retained?
    One instance and its graph, counted once. The object is captured by reference, not copied, so both closures reach the same instance and it is released only when neither closure is held any more.
  • What if the body names no member and uses only its parameter?
    Then nothing from the enclosing object is needed and nothing from it is captured, so the function value can be stored indefinitely at negligible cost. That is exactly what the copy-out rewrite is trying to reach.

Lending someone a single key: because the key is on your keyring, the ring goes with it — and so do the house, the car and the office.

saying these in an interview costs you the question

  • Says the closure captures only the field its body reads.
  • Estimates retention from the size of the callback's code.
  • Assumes the screen object is released as soon as it is closed.
  • Thinks qualifying the member read changes what is captured.
  • Believes a small callback cannot pin a large graph.