defer expression schedules work for the exit of the current lexical scope
— the block the defer is written in, not necessarily the whole function.
error IoError { Denied }
struct Handle { id: int }
impl Handle {
fn open(name: string): Handle ! IoError {
if name.is_empty() { error Denied }
return Handle { id: name.len() }
}
fn read(self): string ! IoError { return "payload" }
fn close(self): void { println("closed ${self.id}") }
}
fn process(name: string): int ! IoError {
handle := Handle.open(name)?
defer handle.close()
body := handle.read()?
return body.len()
}close runs on the propagated ? from read and on the return — after the
return value has been evaluated, so the return expression can still read the
resource it is about to release.
Registration is dynamic
A defer registers only when execution reaches it. A defer in a branch that is
not taken never registers, and the cleanup belongs to that branch’s block.
struct Span { name: string }
impl Span {
fn start(name: string): Span { return Span { name: name } }
fn finish(self): void { println("finish ${self.name}") }
}
fn execute(): int { return 7 }
fn run(tracing: bool): int {
if tracing {
span := Span.start("run")
defer span.finish()
return execute()
}
return execute()
}The span finishes when the if branch is left — through the return here, or
at the closing brace if the branch fell through instead.
That also means acquisition and registration must be adjacent. Register immediately after success: before it, there is no valid resource; long after it, a new early exit can slip in between and bypass registration.
error NetError { Refused }
struct Conn { id: int }
impl Conn {
fn connect(address: string): Conn ! NetError {
if address.is_empty() { error Refused }
return Conn { id: address.len() }
}
fn write_all(self, payload: string): int ! NetError { return payload.len() }
fn close(self): void { println("closed ${self.id}") }
}
fn send(address: string, payload: string): int ! NetError {
conn := Conn.connect(address)?
defer conn.close()
return conn.write_all(payload)?
}Ordering
Defers registered in one scope run in reverse order — last in, first out.
fn first_cleanup(): void { println("first") }
fn second_cleanup(): void { println("second") }
fn f(): void {
defer first_cleanup()
defer second_cleanup()
println("body")
}That prints body, second, first.
When several scopes are left at once, the innermost is cleaned first, and within each scope the order is still LIFO.
fn note(message: string): void { println(message) }
fn f(): void {
defer note("outer")
{
defer note("inner first")
defer note("inner second")
note("body")
}
note("after block")
}Output: body, inner second, inner first, after block, outer.
Acquisition order therefore produces the correct reverse release order for free:
error E { Failed }
struct Outer { id: int }
struct Inner { id: int }
impl Outer {
fn acquire(): Outer ! E { return Outer { id: 1 } }
fn acquire_inner(self): Inner ! E { return Inner { id: self.id + 1 } }
fn release(self): void { println("release outer") }
}
impl Inner {
fn release(self): void { println("release inner") }
}
fn work(): int ! E {
outer := Outer.acquire()?
defer outer.release()
inner := outer.acquire_inner()?
defer inner.release()
return inner.id
}inner releases before outer on every exit path.
One defer per iteration
A loop body is a lexical scope, so a defer in it belongs to that iteration —
including on continue and on the break that ends the loop.
error IoError { Denied }
struct File { id: int }
impl File {
fn open(path: string): File ! IoError {
if path.is_empty() { error Denied }
return File { id: path.len() }
}
fn read(self): string ! IoError { return "line" }
fn close(self): void { println("closed ${self.id}") }
}
fn total_size(paths: []string): int ! IoError {
mut total := 0
for path in paths {
file := File.open(path)?
defer file.close()
body := file.read()?
if body.is_empty() {
continue
}
if body == "STOP" {
break
}
total += body.len()
}
return total
}Each file closes before the next iteration opens one. A defer written outside
the loop would instead hold every handle until the surrounding scope ends —
which is exactly the shape that exhausts a file-descriptor budget.
What the deferred expression sees
The expression runs at scope exit and reads the lexical places it names at that moment, not at registration time.
fn f(): int {
mut count := 0
defer println("count=${count}")
count = 3
return count
}That prints count=3. To preserve a specific value, bind an immutable snapshot
and defer on that instead:
fn report_start(value: int): void { println("started at ${value}") }
fn f(count: int): int {
starting_count := count
defer report_start(starting_count)
return count + 1
}The values a deferred expression needs stay live until it runs. Deferring on a large buffer therefore extends that buffer’s lifetime to the end of the scope on purpose; introduce a small snapshot when only a scalar is needed.
A defer may take a block when the cleanup has several steps, though a named
function usually reads better:
fn stop_timer(): void { println("timer stopped") }
fn flush_metrics(): void { println("metrics flushed") }
fn f(): int {
defer {
stop_timer()
flush_metrics()
}
return 1
}Fallible cleanup
Postfix ? is rejected inside a deferred expression. The surrounding exit is
already underway, so there is no caller continuation for a cleanup failure to
propagate to.
error E { Failed }
fn maybe_close(): int ! E { error Failed }
fn f(): int ! E {
defer maybe_close()?
return 1
}The diagnostic is explicit: `?` cannot propagate out of a `defer` — handle errors inline with `match` or `catch`. Pick a policy and write it out:
error E { Failed }
fn maybe_close(): int ! E { error Failed }
fn report_cleanup(message: string): void { println(message) }
fn f(): int {
defer match maybe_close() {
Ok(_) => {}
Err(err) => report_cleanup("cleanup failed")
}
return 1
}For a cleanup whose failure is genuinely ignorable, the Result methods are
shorter:
error E { Failed }
fn maybe_close(): int ! E { error Failed }
fn f(): int {
defer maybe_close().unwrap_or(0)
return 1
}Choose deliberately — log it, record it, retry safely, or ignore a documented idempotent close failure. Do not silently discard a transactional error.
Suspension
A deferred call participates in ordinary effect inference. If it can suspend, the enclosing function must permit that effect and the compiler preserves the deferred state across the suspension.
fn flush(): void { println("flushed") }
fn f(): int {
defer flush()
return 1
}Prefer synchronous, idempotent cleanup. Long-running work in a defer delays every exit path and makes cancellation behaviour harder to reason about.
When not to use defer
The compiler already inserts value destruction, reference-count maintenance,
weak-reference updates, and user Drop dispatch at the right exit points.
Ordinary memory hygiene needs no defer.
Use defer when the paired action is a semantic operation you want visible in
the source:
- close a host handle;
- finish a tracing span;
- restore temporary process or task state;
- release a manually acquired external lease;
- run a best-effort compensating action.
Use a transaction block for database commit/rollback rather than
reimplementing it with deferred SQL. And do not treat defer as a durability
guarantee: hard runtime teardown and host-process failure are outside ordinary
source control flow.
If ownership of a resource transfers to another value, make that transfer explicit so two cleanup paths do not both claim responsibility for it.
Putting it together
error JobError {
Unreachable { host: string }
Rejected { code: int }
}
struct Lease { host: string }
struct Span { name: string }
impl Lease {
fn acquire(host: string): Lease ! JobError {
if host.is_empty() { error Unreachable { host: host } }
return Lease { host: host }
}
fn release(self): void { println("released ${self.host}") }
fn submit(self, item: int): int ! JobError {
if item < 0 { error Rejected { code: item } }
return item
}
}
impl Span {
fn start(name: string): Span { return Span { name: name } }
fn finish(self): void { println("span ${self.name} done") }
}
fn run_job(host: string, items: []int, budget: int): int ! JobError {
span := Span.start("run_job")
defer span.finish()
lease := Lease.acquire(host)?
defer lease.release()
mut accepted := 0
for item in items {
item_span := Span.start("item")
defer item_span.finish()
if item == 0 {
continue
}
if accepted >= budget {
break
}
accepted += lease.submit(item)?
}
return accepted
}The per-item span finishes once per iteration — on continue, on break, and
on the propagated error from submit. When that error propagates, cleanup runs
innermost-first: the item span, then the lease, then the job span, and only then
does the JobError reach the caller.