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 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; // 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 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.

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.

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.