Skip to content
academia.sh

Lesson 19 / 24

Automatic Reference Counting

Measures the point at which deinit runs once the last strong reference is released along four release paths, and shows that a second reference postpones that moment.

Published Updated

In this lesson

  • Reads the point where deinit runs after the last strong reference is released from the print order
  • Measures the deinit moment at scope end, nil assignment, array removal and loop iteration
  • Shows that a second reference postpones deinit until after the first release
  • Measures that the deinit of an object held by a top-level let does not run
Contents

The previous lesson measured that an access level changes who can see a name but not the moment: a hidden lazy cache was built on first use even when it was private. This lesson moves from visibility to the question of when an object is destroyed. Class instances are managed by automatic reference counting (ARC): the number of strong references to each instance is tracked, and the instance is destroyed at the moment that number drops to zero. While it is being destroyed, the class’s deinit body runs. The question is the question of this course: when is the deinit body evaluated?

Earlier courses asked where a lifetime ends and who owns the memory. This lesson makes neither of those the category. The lifetime boundary and the owner are not measured; what is measured is after which line, and how many times, the deinit body runs, that is, the moment. The core comes first. Probe records how many times an expression was evaluated and whether that happened before or after use; tally counts the four moments.

// Probe.swift — records when an expression is evaluated
final class Probe {
    let label: String
    private(set) var evaluations = 0
    private(set) var uses = 0
    private var evaluatedBeforeUse = false

    init(_ label: String) {
        self.label = label
    }

    func eval<T>(_ value: T) -> T {
        evaluations += 1
        if uses == 0 { evaluatedBeforeUse = true }
        return value
    }

    func use<T>(_ access: () -> T) -> T {
        uses += 1
        return access()
    }

    var moment: String {
        if evaluations == 0 { return "never" }
        if evaluations > 1 { return "on-every-use" }
        return evaluatedBeforeUse ? "immediately" : "on-first-use"
    }

    func report() {
        print("\(label): \(evaluations) evaluations, \(uses) uses -> \(moment)")
    }
}

func tally(_ probes: [Probe]) {
    for moment in ["immediately", "on-first-use", "on-every-use", "never"] {
        let count = probes.filter { $0.moment == moment }.count
        print("\(moment): \(count)")
    }
}

The class under measurement is Session. Its deinit body first prints a line and then notifies the owning Probe through eval. That way both the print order and the Probe counter show the same event.

// Session.swift — class that writes to a Probe in its deinit
final class Session {
    let name: String
    let owner: Probe

    init(_ name: String, owner: Probe) {
        self.name = name
        self.owner = owner
    }

    deinit {
        print("deinit \(name)")
        _ = owner.eval(name)
    }
}

Four Release Paths

The first measurement releases a Session instance in four different ways. The first path is the end of a do scope. The second is assigning nil to an optional variable. The third is removing the instance from an array with removeLast(). The fourth is building a new instance in each iteration of a for loop. Each path runs at least twice, because the Probe rule asks for at least two uses for every observation other than never. The rule counts, it does not read intent.

// main.swift — which release runs deinit
let scopeEnd = Probe("scope-end")
let niled = Probe("nil-assignment")
let removed = Probe("array-removal")
let looped = Probe("loop-iteration")

for round in 1...2 {
    _ = scopeEnd.use {
        do {
            let temp = Session("scope-\(round)", owner: scopeEnd)
            print("used \(temp.name)")
        }
    }
}

for round in 1...2 {
    var held: Session? = Session("nil-\(round)", owner: niled)
    _ = niled.use { held = nil }
    print("after \(held == nil)")
}

var list = [Session("array-1", owner: removed), Session("array-2", owner: removed)]
_ = removed.use { list.removeLast() }
_ = removed.use { list.removeLast() }
print("remaining in array \(list.count)")

for round in 1...3 {
    let item = Session("loop-\(round)", owner: looped)
    _ = looped.use { item.name }
}

let probes = [scopeEnd, niled, removed, looped]
for probe in probes {
    probe.report()
}
tally(probes)
used scope-1
deinit scope-1
used scope-2
deinit scope-2
deinit nil-1
after true
deinit nil-2
after true
deinit array-2
deinit array-1
remaining in array 0
deinit loop-1
deinit loop-2
deinit loop-3
scope-end: 2 evaluations, 2 uses -> on-every-use
nil-assignment: 2 evaluations, 2 uses -> on-every-use
array-removal: 2 evaluations, 2 uses -> on-every-use
loop-iteration: 3 evaluations, 3 uses -> on-every-use
immediately: 0
on-first-use: 0
on-every-use: 4
never: 0

Start with the order of the print lines. The line used scope-1 comes before deinit scope-1: in the measured run the instance was destroyed at the end of the scope, and the deinit line appeared on scope exit. With nil assignment, deinit runs inside the assignment, and the after true line that follows confirms it. The array holds two instances; removeLast() takes out the last one, so array-2 is destroyed before array-1. In the loop, three iterations produce three separate deinit runs, and each one finishes before the next iteration.

All four Probe objects say on-every-use. The first three count two evaluations because there are two rounds, and the loop counts three because it has three rounds. The deinit body runs again every time an instance is destroyed, and every destruction belongs to a new instance. This lesson’s moment is therefore on every use. In the tally output, immediately, on-first-use and never are zero.

The Function Boundary

References are not held only by variables; an instance passed to a function as a parameter is also alive for the duration of the call. The second measurement separates two cases. In one, a function produces the instance and the caller holds it. In the other, the produced instance is bound to no variable and lives only in the middle of an expression.

// main.swift — a reference at the function boundary
func inspect(_ session: Session) -> String {
    session.name
}

func make(_ name: String, owner: Probe) -> Session {
    Session(name, owner: owner)
}

let crossing = Probe("function-boundary")

for round in 1...2 {
    let made = make("made-\(round)", owner: crossing)
    _ = crossing.use { inspect(made) }
    print("round done \(round)")
}

for round in 1...2 {
    _ = crossing.use { inspect(make("transient-\(round)", owner: crossing)) }
    print("transient round done \(round)")
}

crossing.report()
round done 1
deinit made-1
round done 2
deinit made-2
deinit transient-1
transient round done 1
deinit transient-2
transient round done 2
function-boundary: 4 evaluations, 4 uses -> on-every-use

In the first loop, the line round done 1 comes before deinit made-1. The instance is alive during the inspect call, and the call does not extend its lifetime; the owner of the instance is still the made constant, and deinit runs at the end of the iteration, as made leaves its scope. In the second loop, the instance produced by make is bound to no name. In the measured run the instance was released as the calling expression finished, and the line deinit transient-1 appears before transient round done 1. Together the two loops record four evaluations and four uses for function-boundary. In other words, passing an instance to a function does not extend its lifetime; what extends it is storing the instance in a name or a property.

Chained Release

An instance can hold another instance in a property. In that case, destroying the outer instance also releases the last reference to the inner one. The third measurement builds two chains together: Holder holds a Session, and Car inherits from Vehicle, and both classes have a deinit body. The question is which deinit body runs first.

// main.swift — an instance in a property and a subclass chain
final class Holder {
    let name: String
    let child: Session

    init(_ name: String, owner: Probe) {
        self.name = name
        child = Session("child-\(name)", owner: owner)
    }

    deinit {
        print("deinit \(name)")
    }
}

class Vehicle {
    let owner: Probe

    init(owner: Probe) {
        self.owner = owner
    }

    deinit {
        print("deinit superclass")
        _ = owner.eval("super")
    }
}

final class Car: Vehicle {
    deinit {
        print("deinit subclass")
        _ = owner.eval("sub")
    }
}

let nested = Probe("property-instance")
let inherited = Probe("superclass-chain")

for round in 1...2 {
    var holder: Holder? = Holder("holder-\(round)", owner: nested)
    _ = nested.use { holder = nil }
}

for _ in 1...2 {
    var car: Car? = Car(owner: inherited)
    _ = inherited.use { car = nil }
}

nested.report()
inherited.report()
deinit holder-1
deinit child-holder-1
deinit holder-2
deinit child-holder-2
deinit subclass
deinit superclass
deinit subclass
deinit superclass
property-instance: 2 evaluations, 2 uses -> on-every-use
superclass-chain: 4 evaluations, 2 uses -> on-every-use

The output shows deinit holder-1 first and then deinit child-holder-1: the outer instance’s deinit body runs first, its properties are released as soon as that body finishes, and the inner instance’s deinit follows. This is why the outer deinit body can still reach the inner instance. In the subclass chain, the subclass’s deinit body runs first and the superclass’s follows. property-instance counts two evaluations over two rounds, because only the inner Session calls it. superclass-chain sees two eval calls per round and so counts four evaluations over two rounds. Both say on-every-use: the number of evaluations can be greater than or equal to the number of uses, and the rule only looks at whether the expression ran more than once. deinit cannot be called explicitly and takes no parameters; it is run by the runtime exactly once, when the counter drops to zero.

Overwriting Releases the Old Value

Assigning nil is not the only form of release. Assigning a new instance to a var property also releases the reference to the old instance the property was holding. This measurement assigns a second Session to the session property of a Slot class and prints evaluations on both sides of the assignment. The new instance is bound to a separate Probe, so the counted deinit belongs only to the old instance.

// main.swift — overwriting a property
final class Slot {
    var session: Session?

    init(_ session: Session?) {
        self.session = session
    }
}

let overwritten = Probe("overwrite")
let spare = Probe("spare")

for round in 1...2 {
    let slot = Slot(Session("old-\(round)", owner: overwritten))
    print("before write \(overwritten.evaluations)")
    _ = overwritten.use { slot.session = Session("new-\(round)", owner: spare) }
    print("after write \(overwritten.evaluations)")
}

overwritten.report()
before write 0
deinit old-1
after write 1
deinit new-1
before write 1
deinit old-2
after write 2
deinit new-2
overwrite: 2 evaluations, 2 uses -> on-every-use

In each round, deinit old-1 appears between the lines before write 0 and after write 1: while the assignment places the new value, it releases the last strong reference to the old value, and the old instance is destroyed before the assignment completes. deinit new-1 comes at the end of the round, because slot leaves its scope at that point and releases the instance inside it. So the same property held two separate instances in sequence during the round and produced two separate deinit runs. overwrite counts two evaluations over two rounds and says on-every-use. The rule has not changed: deinit is not tied to a release line, it is tied to the counter dropping to zero. Overwriting subtracts one from that counter, and so do nil assignment and scope end.

Bulk Clearing

The instances held by an array are not released one by one but as the array is emptied. This measurement builds two separate arrays and empties each with removeAll(). The question is how many deinit bodies a single call runs.

// main.swift — bulk release with removeAll
let cleared = Probe("bulk-clear")

for round in 1...2 {
    var batch = [
        Session("bulk-\(round)-a", owner: cleared),
        Session("bulk-\(round)-b", owner: cleared)
    ]
    print("before clear \(cleared.evaluations)")
    _ = cleared.use { batch.removeAll() }
    print("after clear \(cleared.evaluations), in array \(batch.count)")
}

cleared.report()
before clear 0
deinit bulk-1-a
deinit bulk-1-b
after clear 2, in array 0
before clear 2
deinit bulk-2-a
deinit bulk-2-b
after clear 4, in array 0
bulk-clear: 4 evaluations, 2 uses -> on-every-use

The removeAll() call is a single line, but because the array holds two instances, two deinit bodies run: between before clear 0 and after clear 2, deinit bulk-1-a and deinit bulk-1-b appear. In the second round the counter goes from 2 to 4. Here Probe counts four evaluations for two uses and again says on-every-use; one call running several deinit bodies does not contradict the on-every-use rule, because the rule looks not at the number of evaluations but at whether the expression ran more than once. Each instance the array releases has its own counter. If one of them were also held by another variable, removeAll() would release only the array’s share, and that instance’s deinit would wait until its counter dropped to zero; the bounding measurement shows this case.

The Bounding Measurement

The rule above may give the impression that deinit runs at every release. The last measurement shows that this is wrong. Two variables, first and second, hold the same instance. First first is released, then second. shared.evaluations is read after each step.

// main.swift — a second reference and a top-level let
let shared = Probe("second-reference")
let outlives = Probe("top-level-let")

for round in 1...2 {
    var first: Session? = Session("shared-\(round)", owner: shared)
    var second = first
    _ = shared.use { first = nil }
    print("first released: \(shared.evaluations) deinit, second \(second == nil ? "absent" : "present")")
    _ = shared.use { second = nil }
    print("second released: \(shared.evaluations) deinit")
}

let pinned = Session("top-level", owner: outlives)
_ = outlives.use { pinned.name }
_ = outlives.use { pinned.name }

shared.report()
outlives.report()
first released: 0 deinit, second present
deinit shared-1
second released: 1 deinit
first released: 1 deinit, second present
deinit shared-2
second released: 2 deinit
second-reference: 2 evaluations, 4 uses -> on-every-use
top-level-let: 0 evaluations, 2 uses -> never

After the first release the deinit count is zero, and second still holds the instance. The line deinit shared-1 appears only at the second release. The same sequence repeats in the second round. The counter reaching zero is the condition for deinit to run; the release line itself is not the condition. The second-reference Probe records two evaluations and four uses and says on-every-use, but it does not say this alone: which release triggers deinit can be seen only from the print order and the evaluations reads placed in between.

The last two lines show the second limit. pinned, held by a top-level let, is used twice, but the top-level-let Probe records zero evaluations and says never. A top-level let is held until the end of the process, and in this measurement deinit does not run at process end. The absence of deinit is not an error; the reference count never dropped to zero. Relying on the deinit body of such an instance to close a resource would be a mistake.

MA1. Only one instance was measured for each release path; that the rule also holds on other release paths was not shown in this lesson. Choosing four paths is a design choice.

Summary

  • Class instances are managed by automatic reference counting (ARC); deinit runs at the moment the last strong reference is released.
  • Scope end, nil assignment, array removal and loop iteration follow the same rule: deinit runs at the release point.
  • A second strong reference postpones deinit; deinit does not run until the counter drops to zero.
  • An instance held by a top-level let is not released until the end of the process, and in this measurement deinit does not run (never).
  • This lesson’s moment: on every use. The destruction of each new instance runs the deinit body again.

Next Step

In this lesson the counter had to drop to zero, and it did. If two classes hold each other strongly, the counter never drops to zero and neither deinit runs. The next lesson measures how weak and unowned break this cycle, and why the deinit body is never evaluated in it.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close