Objective-C

Block Underlying Structure and Variable Capture Explained

By Seren  |  28 Sep, 2026  |  1 Comment


I understood Objective-C Blocks much better after I stopped treating variable capture as a list of interview rules. The problem became concrete while I was building a small batch uploader. Each queued operation needed to remember its file name, all operations needed to update one retry budget, and the coordinator needed a progress callback without keeping itself alive forever.

Those requirements exercise the parts of Block capture that matter in day-to-day code. The runnable project at the end creates three delayed upload steps and verifies value capture, __block mutation, object lifetime, callback ownership, and cleanup. It stays on supported language behavior instead of depending on private class names or a fixed memory layout.

The Real Problem Was Work That Runs Later

A Block contains executable code together with the context that code needs. In the uploader, I build an array of operations first and execute them later. Every operation must still know which file it represents after the loop that created it has moved on.

for (NSString *fileName in files) {
    UploadStep step = [^{
        [processedFiles addObject:fileName];
        [coordinator recordSuccess];
    } copy];
    [steps addObject:step];
}

The array owns each Block, and each Block retains the Objective-C objects it captures strongly under ARC unless a different ownership qualifier is used. When the steps run, the file names, mutable results array, and coordinator are still available. This is what makes asynchronous completion handlers and queued operations practical.

I care more about that ownership contract than whether a particular optimized build puts some intermediate representation on a stack or heap. The compiler and runtime can change implementation details while the observable capture and lifetime rules remain the same.

Automatic Scalar Variables Are Captured as Values

The sample records how many files were originally planned. It then changes the local variable before invoking the Block:

NSInteger plannedCount = files.count;
__block NSInteger observedPlannedCount = 0;

void (^readSnapshot)(void) = ^{
    observedPlannedCount = plannedCount;
};

plannedCount = 99;
readSnapshot();

The check sees 3, not 99. An ordinary automatic scalar used by the Block is captured as a value when the Block expression is formed. That snapshot behavior is useful for request identifiers, page numbers, and immutable configuration that should describe the work as it was scheduled.

The captured value is read-only inside the Block by default. Trying to increment plannedCount there would produce a compiler error. This prevents code that appears to update the surrounding local variable but would otherwise only be changing captured state with unclear sharing rules.

Use __block When Several Closures Share a Changing Value

The retry budget is intentionally different. Several attempts must consume one shared counter, and the caller needs to inspect the final value. That variable is declared with __block:

__block NSInteger retryBudget = 2;

void (^consumeRetry)(void) = ^{
    if (retryBudget > 0) {
        retryBudget -= 1;
    }
};

consumeRetry();
consumeRetry();

After two calls, the surrounding variable is zero. __block permits the Block and its enclosing scope to observe and modify the same logical storage. It is a language feature for shared mutation, not a general signal that a value should live forever.

I keep this shared state small. If several callbacks can run concurrently, __block does not make increments, array mutations, or state transitions thread-safe. A serial queue, lock, actor-style owner, or another synchronization strategy is still required.

Object Capture Adds an Ownership Decision

Capturing an object is not the same question as capturing an integer. Under ARC, an object referenced normally by an escaping Block is kept alive by that Block. In the queued upload steps, this is desirable: the results array and coordinator must exist until the queued work finishes.

The important review question is who owns the Block. If a temporary operations array owns a Block that owns the coordinator, there is no cycle by itself. Once the array releases its operations, that ownership path disappears. If the coordinator stores a Block that strongly captures the same coordinator, both objects keep each other alive.

I avoid using retainCount to reason about this. The number can be affected by framework behavior and temporary ownership, and it does not explain the shape of the ownership graph. Drawing the arrows between owners is more reliable.

The Progress Callback Needs a Weak Capture

The coordinator stores its progress handler in a copy property. If that handler captured the coordinator strongly, the relationship would be a cycle. The project uses a weak reference outside the Block and promotes it to a strong local reference only while the callback is running:

__weak UploadCoordinator *weakCoordinator = coordinator;

coordinator.onProgress = ^(NSInteger completedCount) {
    UploadCoordinator *strongCoordinator = weakCoordinator;
    if (!strongCoordinator) {
        return;
    }
    strongCoordinator.lastReportedCount = completedCount;
};

The weak reference does not keep the coordinator alive. The strong local makes its lifetime stable for the remainder of that callback invocation. If the coordinator has already gone away, reading the weak reference produces nil and the handler returns safely.

This pattern is appropriate when the callback should not extend its owner’s lifetime. It is not mandatory for every Block. A short-lived operation may intentionally retain another object until its work completes. I choose weak capture from the ownership relationship, not from a rule that every reference to self is dangerous.

Why Block Properties Are Declared copy

The progress property is declared this way:

@property (nonatomic, copy, nullable) ProgressHandler onProgress;

copy is the conventional ownership attribute for a stored Block. It communicates that the receiver keeps a callable value that remains valid beyond the assigning scope. ARC handles the necessary lifetime operations, so application code should not inspect private Block classes or manually balance Block memory-management calls.

For local variables under ARC, assigning a Block to a strong collection or property also keeps it alive. The explicit [block copy] in the sample makes the delayed-storage intent easy to see, while the array becomes the long-term owner of each upload step.

Globals and Static Storage Are Not Captured Snapshots

The example increments a file-scope counter after the batch completes. Global and static variables already have storage that outlives a local function call, so a Block accesses that shared storage rather than creating the same kind of captured snapshot used for an automatic scalar.

That makes them mutable without __block, but it also makes them process-wide shared state. I use a global in the executable only to demonstrate the rule. In an application, a metrics object or dedicated store is usually easier to isolate and test.

The same concurrency warning applies. A global counter is not made atomic simply because a Block can access it. Multiple queues updating it need synchronization.

What I Avoid Assuming About the Underlying Structure

Compiler-rewritten output can be useful for learning, but it is a generated implementation view rather than source code an application should depend on. Names, flags, helper functions, and layouts can vary with compiler options, runtime version, architecture, ARC, and optimization.

I do not ship code that compares a Block against private runtime class names. I also avoid saying every capturing Block begins in one exact location and later moves somewhere else. Modern compilers can optimize aggressively, and ARC may copy an escaping Block without an explicit message in application code.

The stable ideas are simpler: ordinary automatic values are captured as snapshots, __block permits shared mutation, captured Objective-C objects have ownership consequences, and stored callbacks can create cycles. Those rules are enough to design and debug most production callback code.

Checks I Use During a Block Review

  • Does the work need a snapshot or a value that can change later?
  • If __block is used, can several threads mutate it?
  • Which object or collection owns the Block?
  • Which objects does the Block keep alive?
  • Does a stored callback point strongly back to its owner?
  • Should a weak reference become strong for one invocation?
  • Is the Block property declared with copy?
  • Can the behavior be tested without private runtime details?

In the upload example, the file names are stable values, the retry budget is shared mutable state, and the progress handler must not own its coordinator. Giving each capture a reason makes the code easier to review than applying __weak or __block everywhere.

Download and Run the Complete Project

The complete command-line project requires macOS and Xcode Command Line Tools. It uses ARC and Foundation, with no third-party dependencies, network requests, or credentials.

git clone https://github.com/2252408699/objc-block-capture-upload-batch.git
cd objc-block-capture-upload-batch
make run

A successful run prints eight PASS checks followed by All Block capture checks passed. The checks cover scalar snapshots, shared __block mutation, delayed operations, loop values, captured objects, progress delivery, global state, and coordinator deallocation.

Complete source code: objc-block-capture-upload-batch on GitHub

Seren
Seren

A developer exploring iOS, Objective-C, Swift, and other tech stacks. Here to document my learning process, pitfalls, and growing journey across new technologies.

Your email address will not be published. Required fields are marked *

1 Comment