Skip to content

Ownership, Borrowing & Lifetimes

Jiang uses T^ for owned values and T& / T[]& for non-owning borrowed views. This chapter explains how resources move, when they are destroyed, and how references avoid dangling.

Movable is a default auto trait: values may change storage address unless their nominal type explicitly opts out with !Movable. A !Movable value must be initialized directly in its final place; it cannot be passed or returned by value, assigned to a new place, captured, or moved. Direct Task<T> and Mutex<T> values use this rule to remain address-stable.

Copyable extends Movable and decides whether ordinary value use copies or moves. Scalars, enums, shared references, raw pointers, and RawFn values are copyable. User-defined struct and payload enum types are not copyable unless they explicitly implement Copyable and every field or payload is also copyable. A type with a custom deinit cannot be Copyable.

Non-copyable but movable values move by default in assignments, arguments, returns, and captures:

Int^ a = new Int(42);
Int^ b = a;

Int value = b$.get();

The source is invalid after the move:

Int^ a = new Int(42);
Int^ b = a;
Int value = a$.get(); // error: a has been moved.

$.move() remains available as an explicit spelling. It can also force a move of a copyable value:

Int a = 1;
Int b = a;
Int c = b$.move();
Int invalid = b; // error: the explicit move invalidated b.

Declare copyable value types explicitly:

struct Point: Copyable {
    Int x;
    Int y;
}

Point a = Point(x = 1, y = 2);
Point b = a; // copy

An ordinary resource-owning struct is movable but not copyable, so plain assignment transfers it:

struct Box {
    Int^ value;
}

Box a = Box(value = new Int(1));
Box b = a; // move; a is now invalid

Runtime destruction follows ownership, fields, payloads, and custom deinit; it is not enabled by the Movable marker. Dropping T^ first drops the pointee and then frees its heap storage. Tuple, array, optional, errorable, struct, and payload enum values recursively drop owned contents. Non-owning views such as T&, T*, and T[]& do not free their targets. A custom deinit runs before fields are dropped in reverse declaration order.

References do not own their target. A reference must not outlive the value it points to:

Int value = 10;
Int& ref = value$.ref();
Int copied = ref$.get();

Ordinary local declarations create references with .ref() or .mut_ref(). ref and ref! are reserved for destructuring, match payloads, and lambda captures:

Int value = 10;
Int& ref = value$.ref();

Int first! = 20;
Int&! mutable_ref = first$.mut_ref();

Int second! = 30;
Int&! another_mutable_ref = second$.mut_ref();

(ref borrowed) = value;
(ref! another_borrow) = second;

Inside optional and enum payload patterns, use ref name to borrow the payload instead of copying or moving it. The omitted type position means _; explicit ref T name remains available:

Int read_ref(Int& value) {
    value$.get()
}

Int read_optional(Int? value) {
    if value is .some(ref Int item) {
        return read_ref(item);
    }
    return 0;
}

Returning a reference to a local variable is not allowed:

Int& bad_ref() {
    Int value = 10;
    return value$.ref(); // error: returns a reference to a local variable.
}

Global variables can also be borrowed, but a global borrow does not get a special static lifetime. It is still an ordinary borrow that must stay within the current call stack:

Int global_value = 10;

Int read_ref(Int& value) {
    value$.get()
}

Int read_global() {
    return read_ref(global_value$.ref()); // OK: consumed by this call.
}

Int& bad_global_ref() {
    return global_value$.ref(); // error: global borrows do not escape as static references.
}

Use public when a global value should be accessed from another module. Do not return a global borrow just to make the value reachable.

When an API seems to need a static lifetime, prefer naming the global value directly or exposing a function that takes a fresh local borrow at each call site:

public Int global_limit = 10;

Int current_limit() {
    global_limit
}

Int read_limit_ref() {
    return read_ref(global_limit$.ref());
}

Return the value itself when you want a value:

Int make_value() {
    Int value = 10;
    return value;
}

Return T^ when you want to transfer ownership of a heap object:

Int^ make_owner() {
    return new Int(10);
}

T^ owns the allocation and the T stored in it. If T contains non-owning references, the owner does not extend the lifetime of their sources; the resulting T^ retains those lifetime requirements.

Jiang distinguishes shared readonly and unique mutable loans. Any number of shared T& views may coexist. A T& does not grant write capability through that reference, but an ordinary shared view does not by itself freeze updates through the owner. A live T&! loan must be exclusive for every overlapping place and cannot be implicitly copied. While such a unique loan is live, overlapping source storage cannot be overwritten, moved, or dropped.

Reborrowing T&! as T& creates a frozen shared view. The original unique reference may still be read, but it cannot be used to write until the shared reborrow’s last use. Several shared reborrows may coexist.

Loan lifetimes are non-lexical: a loan ends after its last reachable use, so sequential mutable reborrows are allowed. Struct sibling fields and distinct constant array indexes can be borrowed independently; dynamic indexes and subslices conservatively overlap.

If an async call captures a borrow, its direct structured Task holds the loan until the Task has finished and its scope cleanup is complete. cancel() only requests cancellation; use await() or cancel_and_await() when the caller must wait for terminal completion. A heap Task that crosses a scope or Domain must satisfy the normal lifetime and Sendable rules.

Cross-Domain transfer and lifetime validity are separate checks. A shared T& may be passed, returned, or captured across Domains when T: Sendable, but it still cannot outlive its source owner. T&! cannot cross directly; a call that needs T& may establish the frozen shared reborrow described above. Raw pointers carry neither ownership nor lifetime proof and are not automatically Sendable.

Borrowing struct and payload enum types declare their public lifetime regions with @region. Every declared region must be used by a field or enum payload. A reference has an outer borrow plus the lifetime shape of its pointee. For a simple field, @life(a) binds the outer borrow and uses a for every pointee lifetime as well:

@region(a)
struct ByteView {
    @life(a)
    UInt8[]& data;
    Int length;
}

Declaration order matters when the type is used with positional lifetime bindings. A clause such as @region(a, b: a) declares both names and means a must live at least as long as b. Each target is written once. Mutual constraints are allowed: @region(a: b, b: a) gives a and b the same lifetime requirement.

Fields with several lifetimes can bind them in declaration order, as in @life(a, b), or by names provided by the field type, as in @life(left: a, right: b). The two forms cannot be mixed. Every name must be bound exactly once, and field bindings cannot use self.

A scalar source fills the complete lifetime shape of one top-level field or payload position. A parenthesized scalar such as (a) means the same thing. Use a tuple source only when nested lifetime positions need different sources. Every top-level position must still be present in the binding.

For example, if each Cell& position has two lifetime slots, these three payload bindings are equivalent:

@life(a, a)
// or: @life(left: a, right: a)
// or: @life(left: (a, a), right: (a, a))
pair(Cell& left, Cell& right)

They are alternative spellings for one case declaration, not three attributes to write together.

For an exact binding of a reference whose pointee has several regions, keep its pointee shape grouped. If PairRef has regions left and right, use @life(a, (left, right)); do not flatten it to @life(a, left, right) or bind only one pointee region.

Generic regions name their source type explicitly. a: T means that a has the complete lifetime shape of T; = anchor supplies a default for every slot when a field omits that generic binding:

@region(anchor, value: T = anchor)
struct BorrowedBox<T> {
    @life(anchor)
    T& value;
}

Use @region(a: T) when a type stores T directly. A generic parameter must be used by a member type; Jiang does not permit phantom nominal type parameters.

Functions and methods use target: source: a borrow named on the right must live long enough for the value named on the left:

@life(return: data)
UInt8[]& first_two(UInt8[]& data) {
    return data[0..2];
}

When a returned value contains a borrow and @life is omitted, a readonly self or Self&! self receiver is the default source. Without such a receiver, the only parameter that carries borrowed data becomes the default source. A parameter that carries several lifetimes still counts as one parameter. This rule depends only on the function declaration, not on which branch the body returns:

Int& first(Int& value) {
    return value; // defaults to @life(return: value)
}

struct Pair {
    Int left;
    Int right;

    Int& choose(self, Bool use_left) {
        if use_left {
            return self.left$.ref();
        }
        return self.right$.ref(); // defaults to @life(return: self)
    }
}

A by-value Self self receiver has no priority and is treated like an ordinary parameter. If no parameter carries borrowed data, write @life() to confirm that the result does not borrow from a parameter. If several parameters carry borrowed data, or the source and result have different lifetime positions, write @life explicitly. An explicit attribute defines all allowed lifetime relationships; no default relationship is added. Each left-hand name may appear only once. Use & when the result may borrow from either of several compatible sources:

@life(return: left & right)
Int& pick(Int& left, Int& right, Bool use_left) {
    if use_left {
        return left;
    }
    return right;
}

Parameter-to-parameter relationships use the same syntax. @life(left: right) means every borrowed part of right must live long enough for the corresponding borrowed part of left:

@life(left: right)
Void relate(Int& left, Int& right) {}

When a result carries several lifetimes, map the complete return once with a tuple expression:

@region(a, b)
struct PairRef {
    @life(a)
    Int& left;

    @life(b)
    Int& right;
}

@life(return: (left, right))
PairRef make_pair(Int& left, Int& right) {
    PairRef(left = left, right = right)
}

Do not split that mapping into return.a and return.b, repeat the same left-hand name, or use positional paths such as value[0]. Tuple elements and Fn / RawFn result and parameter positions must be named before @life can refer to them. @life() explicitly states that there are no lifetime relationships to parameters.

For example, a callable relationship uses its declared result and parameter names:

@life(callback.result: callback.value, return: value)
Int& apply(Fn<Int& result, Int& value> callback, Int& value) {
    callback(value)
}

If there is no sufficiently long-lived input, return an owning value instead of a borrowed view.