Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Dfr

dfr is scope-exit cleanup sugar. edf is its error-only V3 counterpart.

The shipped forms are intentionally narrow:

  • only the dfr { ... } and edf { ... } block forms are supported
  • each block registers work on the current lexical scope
  • dfr runs when that scope exits by fallthrough or break, and before a return, recoverable report, or panic leaves it
  • multiple deferred blocks run in reverse registration order
  • edf runs only on a recoverable report exit, not on normal return or panic

This is control-flow sugar.

It is not a destructor system, user-defined drop hook, object system, or async cleanup system.

Model reminder:

  • memo alone does not expose the hosted .echo(...) substrate

  • the printing examples below use fol_model = "memo", declare the bundled standard library explicitly in build.fol, and call its std.io wrappers:

    build.add_dep({ alias = "std", source = "internal", target = "standard" });
    
  • source files then import that dependency with use std: pkg = {"std"};

  • the explicit dependency upgrades the effective tier to hosted; std is not a third fol_model

  • dfr and edf are not std-only; both remain valid in core and memo

  • a core or unhosted memo program using them can still run; bundled std appears here only because these examples print from source

Basic form

use std: pkg = {"std"};

pro[] main(): int = {
    dfr {
        std::io::echo_str("closing");
    };

    std::io::echo_str("work");
    return 7;
}

The deferred body runs before control leaves main.

Reverse order

use std: pkg = {"std"};

pro[] main(): non = {
    dfr { std::io::echo_int(1); };
    dfr { std::io::echo_int(2); };
}

This executes as:

  • 2
  • then 1

Nested scopes

Deferred bodies belong to the scope where they are declared:

use std: pkg = {"std"};

pro[] main(flag: bol): non = {
    dfr { std::io::echo_str("outer"); };

    when(flag) {
        case(true) {
            dfr { std::io::echo_str("inner"); };
            return;
        }
        * { }
    }
}

If the inner scope exits first, its deferred bodies run before the outer scope’s deferred bodies.

Error-only cleanup

edf registers a body for recoverable error exits only:

use std: pkg = {"std"};

fun[] load(fail: bol): int / int = {
    edf {
        std::io::echo_int(2);
    };
    dfr {
        std::io::echo_int(1);
    };
    when(fail) {
        case(true) { report 7; }
        * { return 0; }
    }
};

On success, only dfr runs. On report, both registrations run in reverse registration order. On panic, dfr runs but edf does not, because panic is not a recoverable error-shell exit.

Deferred-body limits

The registered body is checked now but executed later. V3 therefore rejects effects that the current lowering cannot replay safely:

  • return, break, report, and panic cannot appear inside a dfr or edf body; deferred cleanup cannot initiate another exit while the surrounding exit is being replayed
  • a moved binding cannot be reinitialized there
  • channel sender or receiver endpoints cannot be acquired there
  • | await and access to an existing eventual binding cannot occur inside edf; an error-only body cannot discharge eventual ownership on normal exits, and the restriction remains active inside a dfr nested within that edf
  • a mux[T] handle cannot be locked, unlocked, used for guarded field access, or forwarded to another mux[T] parameter there

These are compile-time boundaries, not cleanup operations that are silently skipped. An outer report still triggers registered dfr and edf bodies; only a new report from inside one of those bodies is rejected. An all-exit dfr may await and handle a recoverable eventual, because it runs on every modeled exit. Error-only edf cannot. Put the other affected control flow, channel acquisition, owner reinitialization, or mutex work in ordinary code.

Ownership ordering

At lexical scope exit FOL applies this order:

  1. select the registered blocks for the exit kind (dfr, plus edf for a recoverable error)
  2. run those blocks in reverse registration order
  3. release any still-active borrows
  4. free still-owned heap values by ordinary scope drop

A moved value is no longer in the source binding’s owned set, so it is not freed twice. Deferred work always runs before the scope drops its remaining owned heap values.

A dfr or edf body that references a move-only binding from an enclosing scope reserves that binding through the exit of the registration scope. The binding cannot be transferred after the deferred block is registered, because the deferred body still needs its value. Moving the value before registration instead makes the reference inside the deferred body an ordinary use-after-move error. A reservation registered in a nested block ends after that block’s deferred work has run, so the owner may be transferred afterward.

Mutex guard effects are not deferred in V3. A dfr or edf body cannot call .lock() or .unlock() on a mux[T] parameter, and it cannot access mutex-guarded fields even when the surrounding scope currently holds the guard. It also cannot forward the mutex handle to another mux[T] routine, because that would hide delayed guard transitions behind the call. Perform guarded work in ordinary control flow before registering deferred cleanup.

Channel endpoint acquisition is similarly rejected inside dfr and edf. Acquire any sender handle before registration and perform receiver operations in ordinary control flow.

The same delayed-effect boundary applies to owner reinitialization. A moved binding cannot be reinitialized inside dfr or edf: typechecking that write as immediate would make the binding look usable before the deferred assignment actually runs. Reinitialize the binding in ordinary control flow before the deferred block is registered.

Explicit captures

A delayed block declares how outer locals enter it with a capture list — every routine-local binding the body uses must appear in it. Each entry names an outer binding and states its operation:

var seen: int = 2;
var owned: ptr[int] = [ref]seed;
dfr[seen[bor], owned[mov]] {
    std::io::echo_int(seen + [drf]owned);
};

[bor] observes the binding — the owner stays restricted while the block can still run, and the loan is read-only: assigning through a [bor] capture is rejected. A block that mutates its capture states the composite [mut, bor] mutable loan instead:

var[mut] total: int = 5;
dfr[total[mut, bor]] { total = total + 37; };

mut composes only with bor. [mov] transfers the value into the block’s environment: the outer binding is invalidated for the rest of the scope, and the block still owns the value when scope-exit cleanup executes it. [cpy] and [cln] duplicate the value under the same capability rules as spawn captures. A bare capture name without an operation is rejected, as is a channel endpoint capture — endpoints belong to spawned tasks, not delayed blocks. Using an undeclared outer local inside the block is an ownership error; module-level values and routine names are not captures and need no declaration.

Current milestone boundary

The basic dfr form belongs to V1. Ownership-aware ordering and edf are the shipped V3 memory contract. V3 processor resources do not become generalized deferred-cleanup hooks: outstanding tasks join at process exit, channels close through endpoint ownership/lifecycle, and an eventual can be consumed at most once by | await. Recoverable eventuals must be awaited and handled on every exit path; an all-exit dfr may do so, while an error-only edf may not. Infallible eventuals may instead remain outstanding for the process-exit join. Await inside edf—including inside a nested dfr—channel endpoint acquisition, and mutex guard effects inside dfr/edf are rejected. Native/foreign resource cleanup remains V4 interop work.