module 01 · memory models · Part II · the languages

Chapter 16 — Swift and Objective-C: the compiler counts (ARC)

The 30-second version. Swift and Objective-C manage the heap with reference counting — Python's mechanism (Chapter 14) — but the counting is done by the compiler at compile time, with C++'s zero-runtime-system philosophy (Chapter 09): every strong pointer copy gets an inserted retain, every drop an inserted release, exactly like RAII's scope-exit destructor calls. No collector process, no pauses, and objects die at a deterministic moment: the last release. The price: cycles leak unless broken by hand with weak, and every pointer bump costs instructions. Around that core sits forty years of Apple engineering — an 8-byte header whose spare bits hide the refcount, pools that defer death one runloop turn, side tables that zero weak references, values that copy-on-write — and a kernel that kills your process, with no exception to catch, when you exceed its memory budget.

The compiler counts: ARC between Python and C++

Chapter 14 taught the mechanism: every object carries a count, references increment it, drops decrement it, zero means deallocate now. Chapter 09 taught the philosophy: if the compiler sees every construction and destruction site, it can emit the cleanup calls itself — that is why a C++ destructor call costs nothing at run time. ARC (Automatic Reference Counting, Clang 3.0 / Xcode 4.2, 2011) is the intersection:

The whole system in one example, with the inserted calls written as comments:

final class Room {
    var name: String
    init(name: String) { self.name = name }
    deinit { print("Room \(name) freed") }
}

func audit() {
    let kitchen = Room(name: "kitchen")  // strong count = 1 (you own it)
    let pantry  = kitchen                // INSERTED: objc_retain / swift_retain -> count 2
    print(pantry.name)                   // last use of pantry
}                                        // INSERTED: release x2 -> deinit runs HERE,
                                         // on this thread, at this instruction

One nuance against C++: a destructor runs at the closing brace (Chapter 09); Swift's optimizer may release the moment a variable's last use is over, often earlier. The guarantee is "after last use, no later than scope exit" — the exact point is the compiler's choice (quiz question 4).

What ARC buys. Nothing corresponds to Chapter 12's GC threads or Chapter 15's scavenger — no pauses, no write barriers, no allocation-rate hitches. Destruction is deterministic: deinit runs at the last release, on whatever thread performed it, so file handles and camera sessions close at a moment you can observe with a print — versus Chapter 12's finalizers, which run "eventually, maybe never."

What ARC costs. First, cycles leak — no cycle detector exists (quiz question 8), so a parent and child referencing each other strongly survive until the process dies; the escape hatch is weak, and unbroken cycles are the most common bug class in iOS code (section 4). Second, every pointer bump costs instructions: a strong copy is an atomic increment — one ldadda on arm64, a few cycles hot, hundreds when two cores ping-pong the refcount word (Chapter 02's false sharing, live). A tracing collector pays nothing per pointer and everything in pauses; ARC pays always-but-tiny. You only choose when you pay.

History in three sentences: Objective-C had manual retain/release from the NeXT era; Apple tried an optional garbage collector on the Mac (macOS 10.5, 2007) and never shipped it on iOS; ARC landed in 2011 and the collector was deprecated. Swift (2014) was born with ARC and never had another mode.

One word of header: the Objective-C object, and a free retain

Every Objective-C class descends from NSObject, and every instance begins with the same 8 bytes on 64-bit: the isa pointer, naming the object's class — Chapter 06's type word, one field instead of C++'s per-object vtable (method lookup is a runtime hash through the class). The word is nearly free real estate: an arm64 class pointer needs only ~33 bits, because user-space addresses stop at 0x1000000000. Since the 64-bit iPhone 5s (iOS 7, 2013), the runtime packs the rest with live data — the non-pointer isa.

The isa word — arm64, 64-bit, Apple objc4 (widely documented iOS layout;
field WIDTHS are private and have shifted across runtime releases — the
SHAPE is the lesson). 8 bytes total:

 63 62...............44 43 42 41......36 35.............3 2 1 0
+---+-------------------+---+---+---------+---------------+-+-+-+
| T |  extra_rc (19 b)  | S | W | magic(6)| shiftcls (33) |C|A|N|
+---+-------------------+---+---+---------+---------------+-+-+-+
  |   |                   |   |   |         |               | | |
  |   |                   |   |   |         |               | | +-- N = 1: packed isa
  |   |                   |   |   |         |               | +---- A: has associated objects
  |   |                   |   |   |         |               +------ C: has a C++ destructor
  |   |                   |   |   |         +---------------------- class pointer, << 3
  |   |                   |   |   |                 (mask 0x0000000ffffffff8)
  |   |                   |   |   +---------------------------------- 6 debugger magic bits
  |   |                   |   +-------------------------------------- W: weakly_referenced (side
  |   |                   |          table holds weak slots for it)
  |   |                   +------------------------------------------ S: refcount spilled to
  |   |                                                    the side table
  |   +-------------------------------------------------------------- strong count MINUS 1 (19 bits,
  |                                                                  524,287 max): retain is ONE
  |                                                                  atomic add on THIS word
  +------------------------------------------------------------------ tagged-pointer flag (Chapter 06
                                                                     tag bits, 1-bit form; tagged
                                                                     objects are not heap objects)

Three consequences:

Before 2011, all of it was manual — MRC. The rules were learnable; the discipline was brutal. The canonical trap explains the next section:

// MRC, pre-2011: returning an object has two wrong answers and one right one
- (NSString *)badCrash {                     // wrong answer #1: crash
    NSString *s = [[NSString alloc] initWithFormat:@"%@", self.name];
    [s release];            // count hits 0: the object dies RIGHT HERE
    return s;               // caller gets a dangling pointer (Chapter 21 fodder)
}
// wrong answer #2: return s with no release at all — the caller forgets
// the +1, and it leaks on every call
- (NSString *)right {
    NSString *s = [[NSString alloc] initWithFormat:@"%@", self.name];
    return [s autorelease]; // release DEFERRED, not skipped: the pool balances
                            // the +1 one turn later. Caller: use, never release.
}

MRC ran on naming conventions made law: methods beginning alloc, new, copy, mutableCopy return a +1 you must release; everything else returns +0 you must not. ARC turned the convention into compiler-enforced rules — it reads selector families and emits the matching calls, and still honors those families at interop boundaries, which is why Cocoa names are load-bearing.

Autorelease: dying exactly one runloop turn later

autorelease says: "release this object later, on my behalf." It exists because of the return bind — a function returning an object cannot know whether the caller will keep it, and the caller cannot know whether it owns the result. The answer is a deferred release: hand the object to an autorelease pool, a queue drained at a well-defined future moment.

The mechanism (objc4) is a page-structured stack — Chapter 04's stack rebuilt in the heap, one per thread:

thread-local pool stack:

  page (4,096 bytes)                page (4,096 bytes)
+------------------------------+  +------------------------------+
| header: magic, thread id,    |  |  ~500 more slots ...         |
|   parent/child page links    |<-+------------------------------+
| ~500 slots of (object ptr)   |  (doubly-linked pages; the "hot
| [ POOL_BOUNDARY = nil ] <---+   page" cached in TLS)
| slot slot slot ...           |
+------------------------------+
  push = objc_autoreleasePoolPush(): a nil boundary token lands in the page
  pop  = objc_autoreleasePoolPop(token): send -release to every object
         above the token, top-down; possibly freeing thousands in one sweep

Each page is 4,096 bytes with a ~56-byte header (magic, thread, page links) and roughly 500 object slots; overflow links another page.

Who calls pop on iOS: the runloop. The main thread wraps every event — each touch, timer fire, layout pass, network callback — in a push/pop pair, draining before the thread sleeps (kCFRunLoopBeforeWaiting). That is the "later" in autorelease: one runloop turn. Objects autoreleased during your event handler die together when the handler returns.

Under ARC, autorelease has not gone away; it went into two hiding places:

  1. Return values across the ObjC boundary. A callee returning a +0 object emits objc_autoreleaseReturnValue; the caller emits objc_retainAutoreleasedReturnValue. The pair calls each other's bluff through thread-local storage: the callee stashes its return address instead of autoreleasing; the caller checks TLS, recognizes the site, and skips the retain — the pool round trip collapses to two loads, which is why ARC returns are nearly free.
  2. Loops that create many temporaries. One event handler allocating 100,000 autoreleased objects keeps them all alive until the event ends — RSS climbs in a line that on iOS ends at a Jetsam kill (section 6). Drain per iteration instead:
for (NSDictionary *row in rows) {      // 100,000 rows, ONE runloop event
    @autoreleasepool {                 // push: boundary token
        Template *t = [self.template copy];
        NSAttributedString *s = [self render:row withTemplate:t];
        [self emit:s];
    }                                  // pop: releases t, s, and every
}                                      // autoreleased byte of render:, NOW

Swift's spelling is autoreleasepool { ... } — same push/pop, worth reaching for in tight loops over ObjC-heavy APIs (image decoding, JSON, CoreData). Pure Swift loops of value types have nothing to drain: ARC releases at last use, immediately.

Cycles, side tables, and capture lists

Refcounting's blind spot arrived in Chapter 14 (Python needed a whole gc module for it) and Chapter 09 (shared_ptr cycles leak until broken with weak_ptr). ARC has the same blind spot and no detector: you break cycles yourself with weak and unowned — and to know what those words cost, you need to know where weak references live.

Weak references are NOT a counter in the object. The design (ObjC runtime; Swift 5 has its own variant) is a side table: a fixed global array of 64 SideTable structures; each object's address hashes into exactly one. It owns the weak bookkeeping outside the object — which is exactly what makes zeroing possible:

global: SideTables[64]  -- hash(object address) --> one SideTable

+----------------------------------------------------------+
| SideTable                                                |
|   spinlock           (all weak zeroing under ONE lock)   |
|   refcnts:  map<obj, stored count>     (overflow only)   |
|   weak_table:                                           |
|     map<obj, weak_entry_t>                              |
|       weak_entry_t: 4 inline weak-pointer slots,         |
|                     spilling to a malloc'd array         |
+----------------------------------------------------------+

dealloc(obj), in order:
  1. isa.W set?  lock the table -> write nil into EVERY slot
                 -> free the entry                     <- THE ZEROING
  2. isa.S set?  drop the spilled refcount
  3. isa.C/A?    run C++ dtor / release associations
  4. free(obj)   <- memory becomes reusable only here

Why this design:

unowned vs weak — crash vs optional. weak var x: T? is an optional: on target death the side table zeroes the slot, x reads nil, and you handle absence. unowned let x: T is non-optional and never zeroed: access after death is a guaranteed fatal error ("attempted to read an unowned reference but the object was already deallocated" — Swift 5.9 wording is close to that) — a checked crash, not a dangling read. unowned is cheaper (no side entry, no unwrapping) and correct only when its lifetime is provably nested inside the target's. Async callbacks violate that nesting constantly.

The lazy-destroyed-object story is how unowned bugs survive testing. A dispatch_after block fires two seconds after being armed; the test passes because the owner "hasn't deallocated yet" — nobody saw a crash. The owner's destruction is lazy: its last strong reference may sit in an autorelease pool that drains per runloop event, or on a queue that runs later. Then a release build — which schedules all of this differently — moves the dealloc across the two-second boundary, and the crash appears in the field. Destruction timing is deterministic, but which moment it lands on depends on pool drains and scheduling, and the two build configurations choose differently.

Captures: closures are the other half of every cycle. A closure that escapes its scope (stored, or kept by an API) cannot borrow the stack (Chapter 04's frames die), so the compiler moves each captured variable into a heap context object — a reference-counted box with one field per captured variable: V8's environment design from Chapter 15, statically laid out. Each field defaults to a strong reference. [weak self] and [unowned self] are capture lists: they change how one field of that box is stored.

escaping closure { [weak self] in ... }

+------------------+     +------------------------------+
| function pointer |     | context object (refcounted   |
| context ptr ------+---> | heap box — Chapter 15's      |
+------------------+     | environment, statically      |
                         | known fields:                |
                         |   [ weak self: Player? ]  ----+--> side table slot
                         |   [ other captured vars ]    |
                         +------------------------------+

Now the leak every iOS app ships once:

final class ScoreBoard {
    var score = 0
    private var token: NSObjectProtocol?

    func start() {
        token = NotificationCenter.default.addObserver(
            forName: .tick, object: nil, queue: nil
        ) { _ in
            self.score += 1        // the context box's field is a STRONG self
        }
    }
    deinit { print("ScoreBoard freed") }   // never prints
}

Walk the graph the Memory Graph debugger would draw:

NotificationCenter (process-global, never deallocates)
      | strong (until removeObserver)
      v
   token --strong--> closure --strong--> ScoreBoard --strong--> token
   CYCLE: closure -> ScoreBoard -> token -> closure

The cruel part is the "fix" everyone writes first: "I'll remove the observer in deinit." deinit never runs — the cycle keeps the object alive, so the cleanup is unreachable. Order matters: break the cycle first, then clean up in deinit:

token = NotificationCenter.default.addObserver(
    forName: .tick, object: nil, queue: nil
) { [weak self] _ in
    guard let self = self else { return }  // weak: slot zeroed at ScoreBoard's death
    self.score += 1
}
deinit {
    if let token { NotificationCenter.default.removeObserver(token) }
    // reachable NOW: weak broke closure -> self, deinit CAN run; removing
    // the token then releases center -> token -> closure in that order
}

Same shape, other costumes: Timer.scheduledTimer(withTimeInterval:repeats:) { self.tick() } (runloop → timer → closure → self, until invalidate()), and delegates — which is why forty years of Cocoa convention declares every delegate property weak. A strong delegate is a cycle with a timescale of "forever."

Value semantics: memcpy, niches, and copy-on-write

Classes are the refcounted world. Swift's structs and enums are Chapter 06's value layouts — and they change the economics completely.

A struct assignment is a memcpy — no refcount, no allocator, no identity; the bytes are duplicated. But the copy is shallow: if a field holds a reference, the memcpy duplicates the pointer and ARC inserts a retain for that field — two structs, one shared pointee (Chapter 06's "copy the pointer, not the pointee"):

struct Tag { var id: Int; var active: Bool }    // size 9, stride 16
var a = Tag(id: 7, active: true)
var b = a              // one 9-byte copy; no heap traffic
b.active = false       // a.active is still true: separate storages

struct ViewTag { var view: UIView; var z: Int } // view is a strong reference
var t1 = ViewTag(view: v, z: 0)
var t2 = t1            // bytes copied AND view's refcount +1: v itself is SHARED

Enums are tagged unions (Chapter 06), compiled to the layouts Chapter 10 derived for Rust: single-payload enums exploit niches — Optional<SomeClass> is exactly 8 bytes on 64-bit (Swift 5.9), nil reusing the all-zero pointer pattern no object can legally have, just like Option<Box<T>>. Payloads without spare bits get a tag byte: enum State { case idle; case running(Double) } has size 9, stride 16, alignment 8 — an 8-byte Double plus a 1-byte discriminant, padded to stride by Chapter 06's rules.

Copy-on-write is the bridge between the two worlds. If structs copy eagerly, Array, String, Dictionary, and Set — all structs — would memcpy megabytes per assignment. Instead each holds one reference to a reference-counted backing store, and copying the struct copies that pointer plus one retain (Chapter 09's shared_ptr move, hidden inside a value type):

var a: [Int] = [1, 2, 3]
var b = a              // O(1): struct memcpy + ONE retain of the buffer
b.append(4)            // isKnownUniquelyReferenced(&b.buffer) == false
                       // -> allocate a NEW buffer [1,2,3,4]; a never notices
a[0] = 99              // a's buffer is unique now: write in place, no allocation
after `var b = a`                    after `b.append(4)`

a [ buf*, 3 ]---+                    a [ buf*, 3 ]----> [ RC 1 | cap 3 | 1 2 3 ]
                |                                         (untouched, now unique)
                v
      +------------------+
      | RC 2 | cap 3     |                b [ buf*, 4 ]----> [ RC 1 | cap 4 | 1 2 3 4 ]
      | 1 | 2 | 3        |                                  (NEW buffer)
      +------------------+
                ^
b [ buf*, 3 ]---+        ONE buffer, two owners, refcount 2

The trick hangs on isKnownUniquelyReferenced(&buffer): a stdlib check that the backing store's strong count is exactly 1, run under the exclusivity a mutating method already guarantees. Unique → mutate in place; shared → copy, then mutate. To you it looks like deep value semantics; to the machine it is one retain plus a rare copy. The trap — hold a second handle across a loop of mutations and the copy runs every iteration:

var a = Array(repeating: 0, count: 100_000)     // 800 KB buffer
for _ in 0..<10_000 {
    let b = a          // +1: buffer shared again
    a.append(0)        // NOT unique -> copies 800 KB... EVERY iteration
    _ = b              // b's last use keeps the alias alive past the append
}
// ~8 GB of memcpy, quadratic where the code looks linear

The rule: never hold an alias across a mutation; copy once, on purpose. The profiler's fingerprint is malloc traffic scaling with loop count, not data size.

String compresses all of this into 16 bytes. MemoryLayout<String>.size == 16, always, on 64-bit Swift (5.9): two words that are either a whole string, or a pointer plus bookkeeping:

Swift 5.9, 64-bit: a String is 16 bytes, two words

small string "ARC" (3 bytes, never touches the heap):
+--------------------------------+--------------------------------+
| 41 52 43 00 00 00 00 00        | 00 00 00 00 00 00 00 [disc]    |
| 'A''R''C' + zero fill          | up to 7 more bytes, then byte |
+--------------------------------+ 15: the DISCRIMINATOR — small- |
  word 0: UTF-8 bytes 0..7          string flag + isASCII flag +  |
  word 1: UTF-8 bytes 8..14         the inline count              |
                                     +--------------------------------+
  15 UTF-8 bytes inline, maximum.

large string (16+ bytes):
+--------------------------------+--------------------------------+
| pointer to _StringStorage ---->| count (bytes) + flags          |
| (heap, refcounted, UTF-8,      | (isASCII, shared/literal, ...) |
|  16-byte aligned)              |                                 |
+--------------------------------+--------------------------------+

Byte 15 is the discriminator — Chapter 06's tag bits deciding the meaning of the other 15 bytes. Since Swift 5 (2019), native strings are stored as UTF-8 in place: the count is a byte count, storage is tail-allocated after the header, and indexing is O(n), because UTF-8 code units are 1–4 bytes — byte position is not character position. UTF-8's self-synchronization (a decoder resyncs within 3 bytes) is what makes arbitrary slicing safe; the cost is that String's subscript takes an opaque String.Index, never an integer.

One honest paragraph on why String is weird: a Swift Character is an extended grapheme cluster — a user-perceived character, not a byte, code unit, or code point. "é" is one Character as one scalar (U+00E9, 2 UTF-8 bytes) or as two (e + combining U+0301, 3 bytes). The family emoji is one Character of 7 scalars and 25 UTF-8 bytes — guaranteed heap. Consequently s.count (Characters) is an O(n) walk with Unicode grapheme-breaking tables, while s.utf8.count is O(1), a stored number; "15" in "15-byte small string" means bytes. That is the price of counting what humans see, and the reason String has no s[i].

No swap, no mercy: the Apple memory contract

Everything above runs inside a memory regime unlike a desktop's (Chapter 03's swap never appears here):

ARC's per-object overhead is small — a 16-byte header and an atomic add per pointer. iOS apps burn their budget by keeping pages dirty: a strong-held cache of decoded images, a repeating timer whose closure captured half the object graph, an observer nobody removed. Those pages count against your limit until you free them, and nothing frees them for you — which is why NSCache exists next to Dictionary: same dictionary-shaped API, but it evicts its contents under memory pressure (enforces totalCostLimit, is thread-safe, does not copy keys). A Dictionary holds its bytes until you say otherwise; on iOS, that is how you meet Jetsam.

What insiders know

See it yourself

1. The layouts, derived from Chapter 06 (any Mac with Xcode, or a swift.org toolchain — the MemoryLayout lines work on Linux too):

cat > layout.swift <<'EOF'
import Foundation              // macOS: NSObject, class_getInstanceSize, malloc_size

class Empty {}                          // pure Swift class, no superclass
class ObjCRoot: NSObject {}             // ObjC-rooted

struct S { var a: UInt32; var b: UInt8 }
enum State { case idle; case running(Double) }

print("Int:    ", MemoryLayout<Int>.size, MemoryLayout<Int>.stride, MemoryLayout<Int>.alignment)
print("String: ", MemoryLayout<String>.size, MemoryLayout<String>.stride, MemoryLayout<String>.alignment)
print("S:      ", MemoryLayout<S>.size, MemoryLayout<S>.stride, MemoryLayout<S>.alignment)
print("State:  ", MemoryLayout<State>.size, MemoryLayout<State>.stride, MemoryLayout<State>.alignment)
print("Empty?: ", MemoryLayout<Empty?>.size)                 // reference + nil niche
print("Empty instance: ", class_getInstanceSize(Empty.self)) // the OBJECT, not the reference
print("ObjC instance:  ", class_getInstanceSize(ObjCRoot.self))
let e = Empty()
print("malloc_size: ", malloc_size(Unmanaged.passUnretained(e).toOpaque()))
EOF
swift layout.swift
Int:     8 8 8
String:  16 16 8
S:       5 8 4
State:   9 16 8
Empty?:  8
Empty instance:  16
ObjC instance:   16
malloc_size:  16

Derive each before running (Chapter 06): Int is one word. String is the two-word representation, small or large, always 16. S is 4+1 bytes, no internal padding, size 5; stride rounds up to the alignment (4) → 8. State is an 8-byte Double plus a 1-byte tag → 9, stride 16 at alignment 8. Empty? is 8: nil occupies the pointer's illegal all-zero pattern (the niche — compare Option<Box<T>>, Chapter 10). And the trap the last lines expose: MemoryLayout<Empty>.size prints 8 — the reference, not the 16-byte instance.

2. Deterministic death, on this thread, right now. Append and rerun:

final class T { deinit { print("T freed") } }
do {
    let t = T()
    print("in scope")
}
print("after scope")
in scope
T freed
after scope

Run the equivalent in a GC'd language (Chapters 12, 15) and the finalizer line may print late or never; here the ordering is a guarantee you can build a debugging technique on.

3. Xcode Memory Graph: see the cycle. Run any app under the debugger, pause it, then press the Debug Memory Graph button (bottom of the debug area, or Debug → Debug Memory Graph). The navigator lists heap objects grouped by class with live instance counts; click an instance and the graph shows its fields and back references — every strong holder, as arrows. A retain cycle is literally a loop: click from the closure's context box to self, from self to the token, from the token back to the closure. Xcode flags suspected leaks with a warning badge; the instance-count column is your leak meter (42 ScoreBoard instances after 42 navigations is a diagnosis, not a hint).

4. Command line: leaks with allocation stacks. No GUI needed on macOS:

MallocStackLogging=1 /path/YourApp.app/Contents/MacOS/YourApp &
leaks $!        # or: leaks YourApp
#   Process:        YourApp [1234]
#   2 leaks for 32 total leaked bytes.
#   Leak: 0x7f8b1c000400  16 bytes
#     Call stack: [allocator] ... -[ScoreBoard start] ...

The stack lands on the allocation site — walk up it to the closure capture. The same engine powers Instruments' Leaks template (Product → Profile), which adds a live allocations track: watch the COW trap's malloc traffic scale with loop count instead of data size.

How to use this knowledge

Connections

Forward links — where the rest of the program picks this chapter up:

Quiz

  1. Concept. In terms of Chapters 14 and 09: what is ARC mechanically, and what did the 2011 compiler change relative to MRC? Derive the two standing costs of the design — and name the pair of costs a tracing collector (Chapter 12) pays instead.
  1. Predict the numbers (Swift 5.9, 64-bit — derive each from Chapter 06): MemoryLayout<String>.size; MemoryLayout<P?>.size for a class P; MemoryLayout<T>.size/stride/alignment for struct T { var a: UInt32; var b: UInt8 }; and for enum E { case off; case dim(Double) }.
  1. Predict the behavior. ``swift var a: [Int] = [1, 2, 3] var b = a b.append(4) a[0] = 99 a.append(5) `` How many backing-store allocations happen in total, how many buffers are live at the end, and which operation triggered each and why?
  1. Predict the legal outputs. ``swift final class P { deinit { print("P gone") } } func f() { let p = P() print("made") use(p) // p's last use print("after last use") } f() print("after f") `` Give two orderings of the four lines that are both valid, state what is guaranteed about where "P gone" can appear, and contrast with the C++ rule (Chapter 09) and the Java rule (Chapter 12).
  1. Bug diagnosis. A view controller's deinit never runs; the Memory Graph shows NotificationCenter -> token -> closure -> self -> token: ``swift final class VC: UIViewController { private var token: NSObjectProtocol? override func viewDidLoad() { token = NotificationCenter.default.addObserver( forName: .refresh, object: nil, queue: nil) { _ in self.reload() // (A) } } deinit { // (B) if let token { NotificationCenter.default.removeObserver(token) } } } `` Why does (B) never fix the problem it was written for, what exactly is at (A), and what is the correct fix — including why its ordering matters?
  1. Bug diagnosis. A field crash report: Fatal error: Attempted to read an unowned reference but the object was already deallocated, inside a dispatch_after callback; unreproducible in debug builds. Explain the lifetime nesting unowned requires, why an async callback violates it, and why the build configuration changes the outcome.
  1. Bug diagnosis. ``swift var rows = Array(repeating: Row.empty, count: 50_000) for i in 0..<10_000 { let snapshot = rows rows[i % 50_000].flag = true _ = snapshot } ` The profiler shows malloc traffic of roughly 10,000 × 1.2 MB. What is snapshot` doing to every mutation, what check decides this, and what are two fixes?
  1. Cross-chapter. CPython ships a cycle detector; Swift and Objective-C ship none. Explain what each ecosystem does instead, why Rc::Weak (Chapters 09/10) cannot zero the weak pointer while Objective-C/Swift weak can — deriving the difference from where the weak bookkeeping lives — and why "the kernel reclaims everything eventually" is a serious answer on iOS (Chapter 22).
Answers
  1. Concept. ARC is Python's runtime mechanism (per-object count, +1 per reference, deallocate at zero) with the increments and decrements inserted by the compiler at compile time, like C++'s scope-exit destructor calls — the MRC rules made automatic. Cost one: cycles leak — a mutual pair at count 2 never reaches zero and no detector exists; breaking cycles is manual weak discipline. Cost two: every pointer copy is an atomic increment (an ldadda on arm64; worst case a cross-core cache-line transfer, Chapter 02). A tracing collector reaches cycles naturally (unrooted means dead) and pays nothing per pointer — but it pays collector threads plus stop-the-world pauses, and nondeterministic finalization instead of a death moment you can print. You choose when you pay: always-but-tiny or batched-but-huge.
  1. Predict. String = 16 (the two-word representation, small or large). P? = 8 — one word; nil reuses the all-zero bit pattern no legal object pointer has (the niche, exactly Option<Box<T>>, Chapter 10). T = size 5, stride 8, alignment 4: 4+1 bytes, no internal padding; stride rounds size to the max member alignment. E = size 9, stride 16, alignment 8: an 8-byte Double payload plus a 1-byte tag (a Double has no spare bits), padded to stride. Bonus trap: MemoryLayout<P>.size is 8 too — the reference, not the 16-byte instance.
  1. Predict. Three allocations; two live buffers. (1) The literal allocates the first buffer. var b = a allocates nothing — struct copy plus one retain (RC 2). (2) b.append(4): mutation while isKnownUniquelyReferenced == false → COW copy for b; a becomes unique again. a[0] = 99: in-place write, nothing allocated. (3) a.append(5): a is unique but full (count 3, capacity 3) → growth reallocation, triggered by capacity, not sharing; the old buffer is freed. False intuition to catch: the copies are lazy and happen only at mutation of a shared buffer.
  1. Predict. Ordering A (release at scope exit, -Onone): made, after last use, P gone, after f. Ordering B (release at last use, optimized): made, P gone, after last use, after f. Guaranteed: deinit runs after p's last use and no later than scope exit — always after made, always before after f. C++ fixes the point by rule: the destructor runs at the closing brace, always (Chapter 09); Java's finalizer has no ordering guarantee and may run never (Chapter 12). Lesson: depend on the bounds, not the point — a deinit print proves death happened, it does not timestamp it to an instruction.
  1. Bug-diagnosis. The cycle is closure → self → token → closure, with the process-global center also holding the token until removeObserver. Line (B) is unreachable cleanup: deinit runs only when the strong count hits zero, and the cycle guarantees it never does — "remove the observer in deinit" is dead code that looks like a fix. Line (A) is the strong capture: the escaping closure's heap context box stores self as a strong field (the default). Fix: [weak self] with guard let self — breaking closure → self lets the count reach zero, deinit runs, and only then does (B) execute, releasing center → token → closure in order. Break the cycle first; clean up in the now-reachable deinit.
  1. Bug-diagnosis. unowned requires the reference's lifetime to be nested strictly inside the target's — the closure must die before the object can. A dispatch_after block's lifetime is scheduled, not nested: the delay can outlive the owner, and the block then reads an object whose deinit already ran; the runtime's unowned check turns that into the fatal error (a checked crash, not a dangling read). Build differences: destruction timing depends on autorelease-pool drains and queue scheduling, which differ between debug and release — in the debug build the object's lazy destruction lands after the callback, hiding the bug. Fixes: [weak self], or cancel the stored DispatchWorkItem in deinit.
  1. Bug-diagnosis. Every rows[i % 50_000].flag = true mutates a shared buffer: snapshot still holds a strong reference (its last use is after the mutation), the uniqueness check returns false, and the COW path copies the entire ~1.2 MB backing store before flipping one flag — 10,000 full copies. The check is the stdlib's isKnownUniquelyReferenced-style strong-count == 1 test, run under the mutating method's exclusivity. Fixes: don't hold the alias across the mutation (move let snapshot = rows out of the loop, or drop it), or build into a separate local and assign once. The law: value-semantics cost scales with mutations while shared, not with copies.
  1. Cross-chapter. Apple's answer is discipline plus containment: weak/unowned capture lists and weak delegates break cycles by hand, autoreleasepool bounds temporary pile-ups, and any cycle that slips through is reclaimed wholesale at process death — Jetsam guarantees that death is bounded and total on iOS (Chapter 22), so "leak until exit" is a bounded failure mode. CPython instead pays a tracing cycle detector: periodic passes over container objects (Chapter 14). The weak difference is location: Rust keeps the weak count in the control block — the same allocation as the object — so a surviving Weak pins the block (the object's bytes persist until the last weak dies) and no outside writer exists to zero the pointer; upgrade checks the dead flag at read time instead. Objective-C/Swift store weak slots in the global side table, outside the object: on dealloc, under the side table's spinlock and before the memory is freed, every slot is set to nil — zeroing is possible precisely because the slots are not inside the dying object. Python's weakref is the middle design: an opt-in wrapper marked dead at target death, while raw borrowed pointers in C extensions stay unprotected.