Cleanup code has a placement problem. The close belongs at the end of the
function, but the open that requires it is at the top — thirty lines apart,
with every early return in between obliged to remember. C solves it with
goto cleanup and discipline. C++ solves it with destructors and a book of
rules about them. Go introduced defer and got the placement right: write
the cleanup next to the thing that needs cleaning, and let the exit run it.
Mica has defer, and this series has been using it since
the first task cancelled. What it has not done is state the
rules — and defer is only a mechanism if its rules are exact. There are
four.
The example is
examples/Defer.
Build and run it:
make -C examples/Defer runRule one: newest-first
WriteLn(" acquire a");
defer WriteLn(" release a");
WriteLn(" acquire b");
defer WriteLn(" release b");
WriteLn(" body runs between"); acquire a
acquire b
body runs between
release b
release aTeardown mirrors setup — b released before a, because b was acquired after a
— and nobody wrote that order. Each defer stacks on the one before it, and
the exit unwinds the stack. If b’s acquisition depended on a (a file in a
directory, a lock under a lock), the release order is the only correct one,
and it fell out of the source order for free.
Rule two: exit-time values
done := 0;
defer WriteLn(" finished %lld items", done);
done := 3;
WriteLn(" worked"); worked
finished 3 itemsThe deferred statement printed 3, not 0. A defer registers the
statement, not a snapshot of its operands: when it runs at the exit, it
reads the values that hold there. That is what makes the outcome-reporting
idiom possible — one line, registered early, describing what the body
eventually did. You saw it in the carriers article:
defer if ScopeTimedOut() then WriteLn("timed out") else WriteLn("completed");The query runs at the exit, after the drain — which is the only moment it could answer correctly.
Rule three: an early leave runs what it reached
defer WriteLn(" cleanup one");
if stop then
begin
WriteLn(" leaving early");
leave;
end;
defer WriteLn(" cleanup two");
WriteLn(" ran to the end"); leaving early
cleanup one
--
ran to the end
cleanup two
cleanup oneOn the early path, only cleanup one ran — the second registration stands
below the leave, so on that path it never happened. On the full path,
both ran, newest-first. A defer is not “code that runs at exit”; it is a
registration statement, and like any statement, paths that do not execute
it do not get its effect.
This is why placement is checked. A defer must stand unconditionally in
its body:
for i := 1 to 3 do
begin
defer WriteLn("deferred %lld", i);
end;analyzer error 5192: defer statement must be placed unconditionally in the
body of its function, but found it inside one 'for statement'A per-iteration registration would either accumulate unboundedly or mean
something loop-shaped that defer does not mean — so the question is closed
at compile time, and cleanup that belongs to one iteration belongs in
a routine the iteration calls, which is where its
resources belonged anyway.
Rule four: loop exits are not activation exits
defer WriteLn(" the activation's cleanup, last");
scan : for round := 1 to 5 do
begin
WriteLn(" round %lld", round);
leave scan when round = 2;
end;
WriteLn(" after the loop, activation still alive"); round 1
round 2
after the loop, activation still alive
the activation's cleanup, lastThe targeted leave scan ended the loop. The activation lived on — the
post-loop line proves it — and the deferred statement waited for the real
exit. Registrations belong to activations, never to loops: a leave that
exits a loop fires nothing, a leave that exits the activation fires
everything reached, and the two spellings make the difference visible at the
exit site.
One mechanism, the whole language
The reason defer deserved the last article of this series is that you have
already seen it everywhere, and it was the same defer each time:
- The heap:
defer dispose scratchis road two of the obligation ledger — and the analysis knows a deferred dispose discharges the debt on every path. - Generators: an abandoned body is resumed once with its cancellation set, and its deferred statements run — the cleanup promise that made instances safe to freeze and drop.
- Tasks: a cancelled task unwinds like any early exit, so its defers run — cancellation without a special cleanup path.
- Scopes and drains:
until Poolitems drain at the owner’s exit, interleaved with the owner’s own defers in registration order.
Exit paths in Mica come in many spellings — fall-through, leave, a fail
on the failure channel, a cancellation delivered at a suspension point — and
there is exactly one kind of exit as far as cleanup is concerned. That is the
design: not that defer exists, but that nothing else does.
What this does not do
No per-iteration defer. Shown above, refused as 5192. A loop’s cleanup lives in the routine the loop calls.
A deferred statement with an until owner must be a dispose. defer until is the heap’s owner-binding road, not a general at-exit hook for
arbitrary code on someone else’s activation:
analyzer error 5197: a deferred statement with an 'until' owner must be a
dispose statementNo program-exit hook in a program body. A library may register finalizers that run at program end; a program’s own last breath is its final statements. What runs at every activation exit is bounded and visible in that activation’s source.
defer does not catch failures. A deferred statement runs during an
unwind; it does not stop one. Consuming a failure is on fail’s job — the
error-handling material — and the two compose: the defer runs, then the
failure continues to its handler.
What the compiler proved
Teardown mirrored setup without an ordering in the source. A cleanup line reported a value its registration had never seen. Two exit paths each ran exactly the registrations they reached. A loop ended without firing an activation’s cleanup. And the one shape that would have made registrations unbounded was refused at compile time.
The series, complete
This was the fourteenth pair of the advanced series, and the last. The full path now runs: values → aggregates → dynamic arrays → conformant arrays → sets → memory classes → the heap → ownership → the proofs → defer → tasks → generators → carriers → data races. Every example compiles and runs in the tutorial repository, on both architectures, today.