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.
Move and Destruction
Section titled “Move and Destruction”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 union
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; // copyAn 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 invalidRuntime 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 union 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.
Reference Rules
Section titled “Reference Rules”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();For local bindings, ref _ creates a shared borrow with an inferred pointee type. Use ref! or
mut_ref() to create a unique mutable T&! borrow from a writable place:
Int value = 10;ref _ ref = value; // same lifetime rule as value$.ref()
Int first! = 20;ref! _ mutable_ref = first;
Int second! = 30;Int&! another_mutable_ref = second$.mut_ref();Inside optional and union payload patterns, use ref T name to borrow the payload instead of
copying or moving it. The type after ref is required; it may also be _ as a type placeholder:
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);}Jiang distinguishes shared readonly and unique mutable loans. Any number of non-overlapping or
shared T& loans may coexist. A live T&! loan must be exclusive for every overlapping place, and
cannot be implicitly copied. While any reference loan is live, overlapping source storage cannot be
overwritten, moved, or dropped.
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. Domain scheduling never
relaxes shared/unique rules, so safe references remain data-race free across serial and concurrent
Domains.
Lifetime Annotations
Section titled “Lifetime Annotations”Borrowing struct and union types declare their named lifetime regions with @region. Every
declared region must be used by at least one borrowed field or union payload. A field with one
lifetime uses @life(a):
@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, b: a) means a must live at least as long as b. Both names must be declared in the
same annotation, and the same name cannot be constrained twice. Mutual constraints are allowed:
@region(a, b, b: a, a: b) 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.
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 annotation 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)() 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.