Objective-C

Block Modify Local Variable Core Implementation Logic

By Seren  |  01 Sep, 2026  |  Leave a comment


Why does adding __block to a variable inside a Block suddenly allow modification? The answer involves a hidden pointer redirect that most tutorials gloss over. The answer lies in Block’s variable capture mechanism and the underlying data structure of __block.

The Phenomenon: What Happens Without __block

Here’s some basic code:

void blockFunc1()
{
    int num = 100;
    void (^block)() = ^{
        NSLog(@"num equal %d", num);
    };
    num = 200;
    block();  // Prints: num equal 100
}

num was 100 when the Block was defined, and changed to 200 when the Block executed, but the printed value is still 100. This shows the Block captures the value at definition time, not execution time.

More importantly, if you try to modify num inside the Block:

int multiplier = 7;
int (^myBlock)(int) = ^(int num) {
    multiplier ++;  // ❌ Compiler error: Variable is not assignable
    return num * multiplier;
};

The compiler immediately throws an error, indicating the need for the __block specifier. By default, local variables captured by a Block are read-only.

Why Can’t You Modify It: Looking at the C++ Conversion

Using clang -rewrite-objc to convert Objective-C code to C++ reveals the Block’s underlying structure:

struct __main_block_impl_0 {
    struct __block_impl impl;
    struct __main_block_desc_0* Desc;
    int num;  // Captured variable — stores the value, not a pointer
    __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _num, int flags=0) : num(_num) {
        impl.isa = &_NSConcreteStackBlock;
        impl.Flags = flags;
        impl.FuncPtr = fp;
        Desc = desc;
    }
};

The key point here: when a Block captures a regular local variable, it stores the value (int num), not a pointer (int *num).

The constructor __main_block_impl_0(..., int _num, ...) : num(_num) shows that num‘s value is copied into the Block struct at definition time. The external num variable and the Block’s internal num are two separate pieces of memory that don’t affect each other.

Why not use a pointer? Because local variables are on the stack and can be destroyed at any time. If the Block captured an address, the variable might not exist when the Block executes — accessing a dangling pointer would crash.

What __block Does: Moving the Variable from Stack to Heap

With __block, the situation is completely different:

void blockFunc2()
{
    __block int num = 100;
    void (^block)() = ^{
        NSLog(@"num equal %d", num);
    };
    num = 200;
    block();  // Prints: num equal 200
}

This time it prints 200, meaning the Block and the outside world are accessing the same data.

Looking at the C++ converted code, the variable modified by __block is wrapped into a struct:

struct __Block_byref_num_0 {
    void *__isa;
    __Block_byref_num_0 *__forwarding;  // Points to itself
    int __flags;
    int __size;
    int num;  // The actual value lives here
};

The Block struct no longer stores the value, but a pointer to this struct:

struct __main_block_impl_0 {
    struct __block_impl impl;
    struct __main_block_desc_0* Desc;
    __Block_byref_num_0 *num;  // Pointer, not value
    // ...
};

The __forwarding pointer is the key. When the __block variable is still on the stack, __forwarding points to itself. When the Block is copied to the heap, the __Block_byref_num_0 on the stack is copied to the heap, and the stack’s __forwarding is updated to point to the heap copy.

That’s how the modification works:

// When modifying a __block variable, it actually does:
(num.__forwarding->num) = 200;

Whether accessed from inside or outside the Block, it always finds the actual value location on the heap through __forwarding.

Capture Rules for Different Variable Types

Regular local variables: int num = 100 — Block captures the value. Cannot be modified. Needs __block.

Static local variables: static int num = 100 — Block captures a pointer. Can be modified. No __block needed. Static local variables have the same lifetime as global variables, even though their scope is limited to the function — so it’s safe.

Global variables: int num = 100 — Block accesses directly. Can be modified. No capture needed. Global variables exist for the entire program lifetime.

Static global variables: static int num = 100 — Same as global variables.

Capturing Objective-C Objects: Pointer + Memory Management

When capturing an Objective-C object, the Block captures a pointer. But the automatic variable’s pointer itself is on the stack — the Block can’t modify the pointer itself, but it can modify what the pointer points to.

NSMutableString *mStr = @"mStr".mutableCopy;
void (^myBlock)(void) = ^{
    mStr = @"newMstr".mutableCopy;  // ❌ Can't modify the pointer
    [mStr appendString:@"-ExtraStr"];  // ✅ Can modify what the pointer points to
    NSLog(@"Inside Block: mStr:%@", mStr);
};

The difference: changing the pointer to point to a new object would make the stack pointer point to a different object — which the Block doesn’t allow. But modifying the object’s content through the pointer doesn’t change the pointer’s value itself.

Under ARC, when a Block captures an Objective-C object, it automatically manages its reference count — creating a strong reference to the object, which is the root cause of retain cycles.

Summary

The core logic of modifying local variables in Blocks can be summarized in one sentence:

  • Without __block: Value copy. The Block stores the variable’s value, separate from the external variable. Changes outside don’t affect the Block, and the Block can’t change the outside.
  • With __block: Pointer passing. The variable is wrapped into a __Block_byref_xxx struct on the heap. Both the Block and the outside world access the same data through the __forwarding pointer — modifications are visible to both.

__block essentially moves the variable from the stack to the heap, freeing its lifetime from scope limitations, allowing it to be safely shared and modified by Blocks.

The most common mistake I see junior developers make is forgetting that __block changes the variable’s lifetime. In one project, a colleague wrote a Block that captured a __block counter variable, then expected it to be reset when the function returned. But because __block moves the variable to the heap, it persists until the last Block referencing it is deallocated. The fix was switching from __block int counter to a regular int counter — the Block only needed to read it, not modify it, so the value copy behavior was exactly what we wanted.

Understanding this distinction — value copy vs pointer passing — has saved me from countless subtle bugs. When you see unexpected behavior inside a Block, the first question to ask is: “Is this variable captured by value or by pointer?” The answer determines whether modifications inside the Block are visible outside, and whether the variable’s lifetime extends beyond the function scope.

A Real Project Scenario: Tracking an Asynchronous Image Batch

I ran into this distinction while building a small batch image pipeline. Four product images were processed on background queues, and the UI needed a completion counter. A normal local integer gave each Block the value captured when the Block was created, so it could not act as shared progress state. Changing it to __block made every completion handler reach the same counter.

There was a second problem that __block did not solve: several callbacks could finish at the same time. Shared lifetime does not imply thread safety. I kept the worker jobs concurrent but sent every counter mutation to one serial state queue. That made the example behave like a real pipeline without hiding a data race inside a convenient demo.

How the Counter Is Shared Safely

NSInteger plannedCount = imageNames.count;
__block NSInteger completedCount = 0;

dispatch_queue_t stateQueue =
    dispatch_queue_create("demo.image-pipeline.state",
                          DISPATCH_QUEUE_SERIAL);

void (^reportProgress)(NSString *) = ^(NSString *imageName) {
    completedCount += 1;
    NSLog(@"Finished %@ — progress %ld/%ld",
          imageName,
          (long)completedCount,
          (long)plannedCount);
};

dispatch_async(stateQueue, ^{
    reportProgress(imageName);
});

plannedCount is an ordinary captured value. The reporting Block keeps the snapshot it saw at creation time. completedCount is different: the compiler wraps it in by-reference storage, and access is redirected through the generated forwarding pointer. The serial queue is an application-level synchronization decision layered on top of that compiler behavior.

What the Runnable Demo Proves

  • The outer plannedCount can change to 999 while the Block continues reporting the captured value 4.
  • The shared __block counter advances from 0 to 4 as asynchronous work completes.
  • Worker tasks remain concurrent, but all writes to shared state are serialized.
  • The final group wait proves that all four callbacks updated the same by-reference variable.

This is the practical lesson I keep from the compiler-generated __Block_byref structure: __block answers “which storage do these Blocks share?” It does not answer “which queue may mutate that storage?” Those are separate decisions.

Download and Run the Complete Project

The complete tested source is available in the dedicated repository: Objective-C __block Counter Demo on GitHub.

git clone https://github.com/2252408699/objc-block-byref-counter-demo.git
cd objc-block-byref-counter-demo
make run

Requirements: macOS and Xcode or the Xcode Command Line Tools. The Makefile builds the Objective-C command-line program with Blocks and ARC enabled.

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 *