RAII and Drop

Deterministic resource cleanup with drop, defer, with, and manual destruction.

RAII in Zynx means that an owning value is responsible for releasing the resource it represents. The compiler tracks moves, inserts deterministic cleanup for owners, and rejects use after ownership has been transferred.

Use Move, Copy, and Clone for the ownership introduction. This page focuses on cleanup behavior.

Destructors

A struct destructor is declared with the special drop(self) member. It has no fn keyword and no return type.

import std.os;

struct Tracer {
    id: i32,

    drop(self) {
        _ = os.write("drop {self.id}\n");
    }
}

fn main() {
    let first = Tracer { id: 1 };
    let second = Tracer { id: 2 };

    _ = os.write("body\n");
}

When the function exits, the two owners are cleaned up in reverse order: second, then first.

The destructor form is drop(self) { ... }. A normal method named fn drop(self) is just a method; it is not the language destructor that automatic cleanup calls.

Cleanup Points

An owner is dropped when its lifetime ends. In everyday code that usually means the end of the scope, including early exits through return, break, and continue.

import std.os;

struct Guard {
    label: str,

    drop(self) {
        _ = os.write("leave {self.label}\n");
    }
}

fn open() -> i32 {
    let guard = Guard { label: "open" };

    _ = guard.label;
    return 1;
}

Cleanup is deterministic and path-sensitive, not garbage-collected. The current implementation uses liveness analysis to place cleanup where an owner is no longer live, so do not rely on a destructor being delayed until a closing brace when the value is already dead.

Moves Transfer Cleanup

Moving a resource transfers the eventual cleanup to the new owner. The moved-from binding is no longer usable and is not dropped separately.

import std.os;

struct Handle {
    id: i32,

    drop(self) {
        _ = os.write("close {self.id}\n");
    }
}

fn main() {
    let first = Handle { id: 7 };
    let second = first;

    _ = os.write("using {second.id}\n");
}

Only second is dropped. Reading first.id after the assignment is rejected as use after move.

Copy values do not participate in ownership transfer. A struct that declares drop cannot implement Copy, and every field of a Copy struct must also be copyable.

Reassignment

Reassigning an owning binding drops the old value before the new value is stored.

import std.os;

struct Tracer {
    id: i32,

    drop(self) {
        _ = os.write("drop {self.id}\n");
    }
}

fn main() {
    let value = Tracer { id: 1 };
    value = Tracer { id: 2 };

    _ = os.write("end\n");
}

The previous value is dropped during the reassignment. The replacement is dropped when its lifetime ends.

Nullable owners only drop a payload when the value is not null. Assigning null to a nullable owner drops the previous payload first.

Nested Owners

When a struct owns fields that also need cleanup, the outer destructor body runs first. Owned fields are cleaned up after that body returns.

import std.os;

struct Inner {
    id: i32,

    drop(self) {
        _ = os.write("drop inner {self.id}\n");
    }
}

struct Outer {
    inner: Inner,

    drop(self) {
        _ = os.write("drop outer {self.inner.id}\n");
    }
}

fn main() {
    let value = Outer { inner: Inner { id: 1 } };
    std.drop(value);
}

This prints the outer message before the inner message.

Manual Drop

Use std.drop(value) when a resource must be destroyed before its natural cleanup point.

import std.os;

struct Temp {
    id: i32,

    drop(self) {
        _ = os.write("drop temp {self.id}\n");
    }
}

fn main() {
    let temp = Temp { id: 1 };

    std.drop(temp);
}

std.drop takes one local variable name and is a statement. After std.drop(temp), temp has been moved and cannot be used again. The lower-level @drop(temp) spelling is legacy/internal; public code should use std.drop(temp).

Defer

defer is for ad hoc scope cleanup. Deferred actions run in last-in, first-out order when the current scope exits.

import std.os;

fn main() {
    defer _ = os.write("last\n");
    defer _ = os.write("first\n");

    _ = os.write("body\n");
}

For a scope that has both defer statements and live owners, registered defers run before automatic owner cleanup for that scope. This lets deferred code inspect locals that are still live.

Use drop(self) for type-owned resource invariants. Use defer for one-off actions such as temporary allocation cleanup.

return, break, and continue are not allowed inside defer. In async fn, await is not allowed inside defer, and a live defer may not cross await or select.

With

with expr as name { ... } creates a scoped resource binding and cleans it up when the block exits.

import std.os;

struct Resource {
    id: i32,

    drop(self) {
        _ = os.write("drop {self.id}\n");
    }
}

fn make_resource(id: i32) -> Resource {
    return Resource { id };
}

fn main() {
    with make_resource(1) as resource {
        _ = os.write("inside {resource.id}\n");
    }

    _ = os.write("after\n");
}

with is the public shape for scoped APIs such as future.Group<T, E>. The binding does not escape the block.

Manual Slots

Raw byte operations do not run destructors. Use std.mem slot helpers only when you are managing initialized storage yourself.

import std.mem;

fn replace<T>(dst: *T, src: *T) {
    mem.drop_at<T>(dst);
    mem.move_at<T>(dst, src);
}

read_at, move_at, and drop_at move values out of typed storage. After using them, treat the source slot as uninitialized unless you write a new value into it.

Traps And Async Cancellation

Language exits such as return, break, continue, throw, and failed try unwind source scopes and run cleanup. Runtime traps do not. Bounds failures, checked arithmetic overflow, invalid casts, null dereference, assertion failures, and explicit trap builtins abort immediately without running defer, automatic drops, with cleanup, or async frame cleanup.

Async cancellation is cooperative task completion, not source-level unwinding. A defer may not live across await, so it is not a cancellation hook. Owned future frames are still destroyed exactly once by their owning runtime.

Rules Of Thumb

  • Put resource release in drop(self), not in a normal fn drop method.
  • Keep destructors local and non-fallible; handle or discard cleanup failures inside the destructor.
  • Do not make resource owners Copy.
  • Use defer for local cleanup actions and with for APIs that require a scoped owner.
  • Use std.drop sparingly, because it consumes the binding immediately.
  • Prefer references for checked borrowing and raw pointers only for interop or manual memory.