Ownership, Borrowing, And Drop
This chapter documents Zynx move-only ownership, owning values and views, Copy, Unique<T>, Handle<T>, Capsule<T>, ThreadLocal<T>, automatic drop, and the ownership diagnostics.
Status: current public API unless a rule is marked unsupported or unspecified.
Ownership, Borrowing, And Drop
Move-only values have a single owner. Moving a move-only value consumes the source; using the source afterward is illegal.
Host-loan entry bindings
An executable composition root may declare exact host-provided shared loans:
// RFC 0066 target; implementation pending.
@host let project: fs.Dir in _;
@host let outbound: net.Network in _;
fn main() {
run(project, outbound);
}The host retains each backing owner from atomic publication before entry until main, structured children, retained I/O, callbacks, cancellation, and cleanup have reached true quiescence. in _ is the required explicit loan binding and infers a private instance region; it does not promise process-global or program-lifetime storage.
Only the direct body of main may resolve a host-loan name. Passing the value to run creates an ordinary explicit shared parameter under the normal region, Send, and Sync rules. A helper cannot read the root binding directly, and the loan cannot be moved, dropped, exclusively borrowed, stored beyond the instance, or captured by unbounded work. Filesystem code that needs a separate owner uses explicit Dir.duplicate().
RFC 0068 fixes how a host backs these declarations: one explicitly selected zynx-launch document must match the root artifact's ordered names and canonical kinds exactly before any loan is published. A binding name has no hidden source meaning; in particular, the process-start directory is selected by an explicit process_start source, not by naming a Dir binding start.
RFC 0069 makes each backing policy closed and allow-only. Directory loans carry an explicit operation-right set; Network loans independently authorize raw-name resolution and numeric endpoint operations. Omitted policy is invalid, explicit empty policy denies the operation, and derived resources can retain or narrow authority but never widen it. RFC 0070 preserves version 1 unchanged and lets a version-2 directory policy separately authorize one-level creation, exact-kind removal, and the source and replace-capable destination halves of rename.
Copy
Copy is the sole compiler-known duplication marker. It has no methods and is protected by its canonical builtin identity. A Copy value may be duplicated without consuming the source by assignment, argument passing, return, capture, generic instantiation, or explicit std.copy(value). Copying must not transfer ownership or trigger cleanup side effects. Raw pointer copies may alias the same address and follow the raw-pointer contract in Memory, Unsafe, And FFI.
The removed spellings Copyable and ImplicitCopy are ordinary identifiers, not aliases or reserved tombstones. User code may declare, import, and shadow either name in any normal scope. Such a symbol resolves normally and never gains Copy meaning. Only a truly unresolved Copyable bound may receive a diagnostic suggestion to write Copy; a visible user symbol suppresses that suggestion.
Implicitly copied types are void, numeric types, bool, enums, vectors, matrices, raw pointers, null, nullable values whose payload type is implicitly copied, and structs marked Copy. References, owning dynamic slices, ^str, callables, error unions, Unique<T>, and unmarked structs are move-only. Bare str and [T] are views; ^str and ^[T] are owners; see owning values and views for how each one owns or views its storage.
void() has no cleanup obligation, resource, provenance, physical slot, or address identity. It still follows ordinary logical initialization and move checking, and its position is preserved inside aggregates and containers.
Structs opt in to copying with struct Name: Copy. That marker is valid only when every stored field is Copy and the struct does not declare drop. Generic Copy structs must put Copy bounds on any type parameters used in stored fields, for example struct Box<T: Copy>: Copy { let value: T }. Users cannot implement this marker with methods or override the structural checks.
Fixed-array duplication
Under the accepted single-Copy contract, [T; N] is structurally Copy when T: Copy. Assignment, argument passing, return, capture, and std.copy use the ordinary infallible copy operation, leave the source usable, and do not call element clone. The compiler does not synthesize a redundant fallible Clone implementation for a Copy fixed array.
When T is non-Copy and T: Clone, [T; N] instead has structural Clone. try source.clone() returns owned ^Self whose value type is still exactly [T; N]. It clones elements once in increasing index order. If element k returns AllocError, the initialized result prefix [0, k) is dropped in reverse order; the source is neither moved nor modified and remains usable.
Fixed-array clone never constructs the dynamic owner ^[T]. Build an owning slice with the explicit ^[...] literal. Nonempty construction reports AllocError, while ^[] is allocation-free and infallible. There is no implicit or clone-based fixed-array-to-owning-slice conversion and no arity-specific std.slice constructor.
Owning values and views
^str owners use the three-word shape { ptr, size, capacity }, while ^[T] owners use { ptr, length, capacity }. str/&str views use { ptr, size } and [T]/&[T] views use { ptr, length }. Bare roles are shared, & roles mutable, and ^ roles own and free their allocation. The static role decides cleanup; capacity and pointer bits do not. Cloning returns a deep owned copy.
The cleanup domain for built-in owners is static rather than stored. One private allocation domain belongs to each compatible runtime instance; no public std.allocator, allocator value, callback, provider field, generic parameter, or ambient replacement exists. Checked allocation failure is std.mem.AllocError.OutOfMemory. Foreign, secret, mapped, pinned, device, and other special storage remains in its own nominal resource type and cannot be adopted as ^str, ^[T], or another built-in owner.
A hole-free "text" literal is always a program-lifetime shared str view; expected type never promotes it into an owner. The closed ^"text" form is the only owning-string literal. A hole-free owning literal whose decoded content is statically empty is infallible, while every statically nonempty or interpolated owning literal reports AllocError and must appear under try or catch. Interpolation is accepted only in the owning form. Logical string capacity counts UTF-8 bytes. No str role promises a trailing NUL; ordinary text may contain U+0000. Borrowed str and &str views carry only pointer and size. Only an owning ^str exposes the readonly capacity field; borrowing it as either view intentionally forgets the owner descriptor.
Owner growth therefore uses consuming receiver methods rather than an &str mutation exception:
// RFC 0051 target; consuming owner-growth implementation pending.
import std.mem.{ AllocError };
fn extend(text: ^str, suffix: str) throws(AllocError) -> ^str {
let reserved = try (<-text).reserve(64);
return try (<-reserved).append(suffix);
}A named receiver must be written as (<-text). append and reserve return a successor owner and may reuse its allocation; clear returns an empty successor while retaining capacity. If a checked consuming call fails, the taken owner is dropped exactly once and the caller binding remains moved. It is not the usual non-consuming replacement case in which a failed right-hand side leaves the old value installed. Empty clone, repeat, and join results use the canonical allocation-free owner; test emptiness with size == 0, not is_empty().
Owning slices use the same explicit successor-owner pattern:
// RFC 0052 target; consuming slice implementation pending.
fn add(values: ^[Item], item: ^Item) throws(AllocError) -> ^[Item] {
let reserved = try (<-values).reserve(64);
return try (<-reserved).push(<-item);
}Only ^[T], not [T] or &[T], exposes readonly logical capacity or the push, reserve, clear, insert, pop, and remove methods. Growth reuses sufficient capacity under a private amortized strategy. clear reverse-drops the initialized elements while retaining the exact storage. A checked failure after a written receiver or element take drops each taken owner exactly once; their bindings remain moved.
pop returns ^(^[T], ^T?), and remove returns ^(^[T], ^T). The caller destructures the tuple and reinstalls the successor owner; there is no Taken wrapper:
// RFC 0052 target; consuming slice implementation pending.
let (rest, value) = (<-values).pop();
values = <-rest;An empty pop returns the exact input owner plus null. Invalid dynamic insert and remove indices are defined bounds traps. Zero-sized elements retain logical length, capacity, ownership, and destructor counts without a physical allocation.
An OutputSpan<T> is a temporary owning initialization capability for one bounded suffix of ^[T]. Creating it consumes the slice owner; safe writes can only extend one contiguous initialized prefix. commit publishes that prefix, while cancel reverse-drops it and restores the old logical length. Dropping the session without either operation destroys the entire collection, including the original prefix. There is no implicit rollback owner.
The type is sealed, affine, and compiler/SDK-trusted, with no public fields, initializer, Copy, or Clone. It is Send exactly when the contained owner and T are Send, and never Sync. commit and cancel consume the complete span and atomically disarm its cleanup; they do not expose a general partial move from a type with a destructor.
// RFC 0054 target; implementation pending.
var output = try (<-values).output(32);
(&output).push(<-value);
values = (<-output).commit();The session's readonly length, capacity, and remaining describe only the new suffix. Overflowing the admitted suffix traps. This is distinct from a normal &[T] loan, which can overwrite existing live elements but cannot change initialized length, and from sparse raw storage, whose hole-bearing state is not representable as OutputSpan<T>.
The accepted sparse-container target represents such storage with a sealed, SDK-internal affine RawStorage<T>, never with an owning or borrowed slice. The raw owner controls exactly one allocation but is liveness-blind: it neither contains a second bitmap nor decides which slots own T. An enclosing nominal container is the owner of every live element and keeps one authoritative runtime fact per slot — the dense Arena.occupied state or a RawTable control byte. Its drop first destroys precisely the slots marked live and then lets the raw owner release the empty block. RawStorage itself never scans or drops elements.
Slot initialization arms authoritative metadata only after the complete value is live. Taking transfers one owner out before disarming the slot, while destruction disarms it before invoking user cleanup. These are short trusted container transitions with no checked error, callback, suspension, or other user-code edge between the payload and metadata updates. Any checked slot loan is anchored in the whole container, so it also prevents removal, reallocation, or liveness-metadata mutation. RawStorage<T> is not public language or standard-library API, and the checked-in containers have not yet migrated to this accepted representation.
For one staged value outside those container protocols, the accepted public boundary is MaybeUninit<T>. Unsafe initialize performs a no-drop move into one caller-proven uninitialized slot; unsafe take returns exactly one owner from a caller-proven complete valid slot and makes it uninitialized again. A single role-directed .ptr projection changes no state: shared slot.ptr yields *T, while explicit exclusive (&slot).ptr yields *mut T; there is no ptr_mut. The wrapper has no liveness tag and does not drop a possible payload, so its caller must preserve exact ownership across every unsafe transition.
Ordinary aggregates and array or slice literals may contain explicit wrappers, but each element remains an independent single-slot state machine. There is no dedicated raw-range constructor, fresh-allocation retyping through mem.view, range-wide publication/conversion, ownership adoption, uninitialized-buffer API, ArrayBuilder, assume_init_range, from_raw_parts, or generic raw output API. A contiguous initialized suffix belongs to OutputSpan<T>, sparse container slots belong to SDK-internal RawStorage<T>, and completed built-in allocation publication is private compiler/SDK machinery. General live-value cleanup suppression is absent: there is no ManuallyDrop<T> or generic forget/leak operation. A nominal consuming method uses only the type-local discard self rule described below.
Range slicing always creates a loan:
- A range slice of a named place — a local, field, element, or dereferenced owner — borrows the base. The base still owns and drops the elements, and the view is valid only while that base remains live.
- A range slice of an owning temporary, such as
(mk())[0..<length], remains a loan from the anonymous owner. It is valid only through the full expression, for example as a direct call argument. Binding, storing, returning, or capturing it is rejected. - A subscript
xs[i]reads or moves a single element; it does not produce a view.
import std.io;
import std.mem.{ AllocError };
fn build() throws(AllocError) -> usize {
let xs: ^[i32] = try ^[1, 2, 3]; // explicit fallible owning literal
let view = xs[0..<2]; // view borrows xs and frees nothing
let owned = try xs.clone(); // deep independent owner
return view.length + owned.length;
}
fn main() throws(AllocError) {
let n = build() catch { _ => 0 as usize };
io.println(try ^"{n}");
}Because ^str and ^[T] are moved, a second owning binding consumes the first, and reading the source afterward is rejected:
import std.mem.{ AllocError };
fn main() throws(AllocError) {
let a: ^str = try ^"hello";
let b = a; // moves a into b
_ = a.size; // rejected: use of moved value `a`
_ = b.size;
}Why ownership forms exist
Unique<T>, Handle<T>, Capsule<T>, and ThreadLocal<T> solve different ownership problems without hidden garbage collection, hidden reference counting, or implicit cross-thread aliasing.
Unique<T> is for tree-shaped ownership: one owner controls one heap value, and moving the owner makes the transfer explicit. Use it when a local value must escape by ownership, when the value is too large or too lifetime-sensitive to return as a borrow, or when code needs a nullable owner through Unique<T>?.
Handle<T> is for graph-shaped data stored in an arena-like owner. A graph often needs stable identities, cycles, back-edges, or external references to entries; checked borrows cannot represent those identities without keeping long borrows alive. A handle is therefore just a copyable identity token. It does not keep the target alive; every lookup revalidates generation and presence and returns a short ordinary borrow.
Capsule<T> is for isolated ownership transfer. It lets an owned object graph cross a channel, worker, or runtime boundary only when the graph has no external checked references, raw-pointer hazards, erased callable captures, futures/tasks, or user-defined drop behavior that would make cleanup ambiguous. This gives concurrency a move-only transfer unit without turning ordinary references or owners into thread-shared aliases.
ThreadLocal<T> is for isolated per-OS-thread ownership. Its copyable handle contains only a static key identity; every thread lazily owns and drops a separate T. Scoped callbacks prevent a borrow of that payload from escaping the originating OS thread. They are synchronously scoped by definition because an await may resume the task on another worker; this is not a temporary compiler limitation.
unique
Unique<T> is a language-recognized move-only owning handle, not an ordinary stdlib struct. The bare symbol name unique is reserved so user declarations cannot collide with the ownership type or constructor.
Unique(value) moves value into uniquely owned heap storage and returns Unique<T>. Moving a Unique<T> transfers ownership and consumes the source; copying it is rejected. Dropping the final owner drops the contained T and releases the storage.
Unique<T> is non-null. Use Unique<T>? for an optional owner. Unique<T> can be borrowed as bare T or mutable &T, and member or subscript access auto-dereferences through the owner. Those borrows obey the same lifetime and aliasing rules as other checked references.
Unique<Unique<T>> and Unique<&T> are rejected. Return Unique<T> when a local value needs to escape by ownership. Return bare T for shared borrows and &T for mutable borrows whose source lifetime is valid for the caller.
The checked-reference lifetime rules in Memory, Unsafe, And FFI also apply to Unique<T> storage: borrowing through a Unique<T> does not move the owner, but moving the owner while any overlapping borrow is active is rejected.
handle
Handle<T> is a language-recognized checked identity for a value stored in an owning arena-like container such as Arena<T>. It requires exactly one type argument. Handle<Handle<T>>, Handle<&T>, and malformed generic arity are rejected.
A Handle<T> is Copy regardless of whether T is Copy. Copying a handle copies only the identity token; it does not own the target, keep the target alive, or grant access by itself.
Lookup through Arena<T> validates both generation and presence. A generation mismatch returns Stale; an absent slot returns Missing. A successful lookup returns an ordinary checked borrow tied to the arena owner. Arena mutation that may invalidate slots conflicts with any active borrow produced from such a lookup.
The handle token's index and generation are implementation details. User code must not construct, decompose, persist across arena owners, or compare handles as proof of object liveness. The only supported liveness test is lookup through the owning arena. A copied handle may become stale after removal and slot reuse.
capsule
Capsule<T> is a language-recognized, move-only, non-null isolated owner. It requires exactly one type argument. Nullable capsule forms are not supported.
A capsule may not hold Capsule<&T>, Capsule<*T>, Capsule<Future<T>>, Capsule<Task<T>>, erased callable values, raw-pointer-containing aggregates, or values containing checked references from outside the capsule boundary.
Future qualification does not change this policy: both unqualified Future<T> and Future<T> where Send remain excluded. Use a qualified Future directly at an accepted worker, Scope, or channel boundary.
A capsule may hold only conservative owned values: scalar values, enums, tuples, fixed arrays, plain owned structs, and language-recognized std-owned containers such as ^str and ^[T] when their element types are also isolatable. User-defined drop types are not allowed inside a capsule.
Capsule<T> is thread-send-safe when T satisfies these capsule content rules. Moving a capsule across a channel or runtime boundary transfers the isolated owned payload; cleanup may run on the receiving or runtime owner side because valid capsule contents have only language-recognized isolatable/drop behavior.
thread-local ownership
ThreadLocal<T> is a language-recognized, Copy descriptor rather than an owner of one shared T. Constructing it at module scope establishes a static key. The first read or update on each OS thread invokes the key's factory and creates that thread's payload.
read exposes a shared bare T for one synchronous callback; update exposes an exclusive &T. The callback may return an independent owned value, but it cannot return or store a reference, view, closure, task, future, or pointer provenance that keeps access to the TLS slot. The descriptor itself can be copied or sent because no payload moves with it.
Payload ownership ends at thread exit. Cleanup-bearing payloads are registered after successful initialization and dropped in reverse initialization order. The descriptor itself has no drop behavior. See ThreadLocal<T> for the lifecycle and reentrancy contract.
Replacement
Every assignment to an initialized place is replacement. The destination place is evaluated once, then the complete RHS is evaluated once into a fresh temporary. A checked failure leaves the old destination intact. On success, the old value is dropped, the temporary is moved in, and its cleanup obligation is disarmed.
That preservation rule assumes the RHS does not itself take the destination. An explicitly consuming self-replacement such as text = try (<-text).append(suffix) or values = try (<-values).push(<-item) visibly moves the destination while evaluating the RHS. If that call fails, every taken argument is dropped exactly once and its source remains moved; assignment never receives a successor to install. The compiler does not reinterpret a written take as a transactional borrow, delay it, or synthesize rollback.
This includes unsafe *p = rhs through writable *mut T. The caller must prove that p denotes writable, live, properly aligned storage containing one fully initialized T, with exclusive destructive access and no partial move or active destruction. Raw-pointer writability does not authorize initializing uninitialized memory with ordinary assignment. Trusted staged construction uses an explicit single-slot MaybeUninit<T>.initialize; the language does not silently select it. Taking the staged value is the separate unsafe MaybeUninit<T>.take transition. If the old value of an ordinary initialized place is needed instead of dropped, that requires a separate explicit replace API.
Drop
A drop(&self) method is a destructor for owned values of that struct type. It is called exactly once for each owned value that remains live until a cleanup point. drop(&self) receives exclusive access to the complete initialized value; after cleanup starts, the caller has no usable source value, and the destructor cannot move fields out of self.
Drop follows the ownership graph for owned contents:
- A struct's own
drop(&self)body runs before cleanup of its still-owned fields. - Still-owned fields drop in reverse declaration order.
- Tuple elements drop from the highest index to zero. Fixed-array and owning slice elements drop from
length - 1to zero. Moved elements are skipped; still-owned siblings still drop. - The active payload of an enum or error drops its fields in reverse payload declaration order. Compiler-generated closure environments use reverse capture order.
- Borrowed fields, raw-pointer fields, and copied handle tokens are non-owning and are not dropped as targets.
Owned dynamic interface values follow this graph without a special container rule. ^I is one move-only owner whose vtable cleanup destroys and deallocates its concrete box. A shared [^I] slice view owns neither its buffer nor the stored owners and drops nothing. An owning ^[^I] slice drops each still-armed element from the last index to the first through its vtable, then frees the buffer exactly once. Reads and iteration borrow elements. Static partial moves or remove/pop and consuming iteration transfer one element's cleanup obligation and disarm that source slot before residual container cleanup.
Automatic cleanup of a directly recursive owner tree is deconstructed iteratively. In particular, self-recursive Unique chains and trees represented by an owning [Node] child slice use typed pointer reversal: storage that is already being destroyed temporarily carries the continuation to its parent. Drop therefore uses constant native stack space and performs no allocation, independently of tree depth. The graph still has to obey exclusive ownership; this is not cycle collection for graphs manufactured through raw pointers. The typed helper is retained across erased ownership boundaries: dynamic-interface vtable drops, callable capture thunks, and async-frame destruction all enter the same iterative state machine for a recursive root.
A custom drop(&self) may inspect or borrow its fields before automatic cleanup, but it may not take over manual destruction of the owner edge that makes its type recursive. Such an edge must remain under automatic drop glue so the constant-stack guarantee cannot be bypassed.
Nominal consuming finalization
Zynx has no public or SDK-internal generic ManuallyDrop<T>, NoDrop<T>, forget, or leak operation. An initialized value cannot be wrapped into a second state whose cleanup has been generically suppressed, and manually destroying a payload never leaves behind a readable zombie value.
A concrete struct may instead define an alternative consuming operation in its original declaration and end that operation's ^self receiver explicitly:
struct Lease {
let value: ^i32,
drop(&self) {
record_release(self.value);
}
fn into_value(^self) -> ^i32 {
let value = <-self.value;
discard self;
return value;
}
}discard self; is terminal for the receiver but does not return from the method. It skips only Lease.drop; every still-initialized field is immediately dropped in reverse declaration order. Fields moved out before the statement remain owned by their destinations. self and every projection of it are unavailable afterward, while unrelated locals and extracted owners remain usable.
The statement is legal only in a method declared with ^self in the original concrete struct declaration. It is rejected in drop(&self), extensions, free functions, closures, interface defaults, current enums, C ABI unions, and every borrowed receiver. If one path in a method uses discard self, every non-diverging path must explicitly end the receiver through a whole-value move, ordinary consuming drop, or discard self; implicit receiver cleanup on return or checked failure is not accepted. A registered defer or live loan that still observes self blocks discard, exactly as it blocks an ordinary move.
The ordinary ban on a partial move from a type with a user destructor has one closed exception: inside such a ^self method, a statically named field may be moved or explicitly consumed only when the resulting Partial receiver is resolved by discard self on that path. From the first such field consumption until discard, no suspension, propagating checked failure, nonlocal control edge, or nested body may observe or carry the partial receiver. A direct recursive owner edge remains ineligible and must stay under the compiler's stack-safe automatic glue. Before self becomes partial, fallible work must handle every failure path by explicitly resolving the complete receiver. After discard, suspension and checked failure operate only on extracted values and unrelated locals.
This is not a source spelling for compiler drop flags. A conditional payload that remains observable after an operation uses an ordinary tagged enum or other nominal valid state. C ABI unions remain drop-free raw storage, and compiler-owned async frames, OutputSpan, RawStorage, and MaybeUninit keep their own sealed state transitions.
Moving a value transfers its drop obligation to the destination. The moved-from source is marked unavailable and has no later drop at its origin. Partial moves use the same rule at field or element granularity: the moved subplace drops through its destination or consumer, while still-owned sibling subplaces remain the residual source's responsibility. Moving out through a self receiver is supported only when the method body leaves a complete state for ordinary destruction or explicitly reaches discard self, which drops the residual fields without invoking the nominal destructor.
for item in <-items transfers the collection into its consuming iter(^self) overload. The iterator owns storage and every unyielded armed element; each successful next() disarms one slot before transferring its owner to item. For ordinary value elements the slice is ^[T] and the item is ^T. ^[^T] instead means that the stored element role is itself ^T; it is not an interchangeable spelling and consuming iteration never creates ^^T.
Early exits first clean the current body/item, then the still-armed tail in ordinary reverse collection order, then storage once. This uses the existing drop(&self) destructor contract over complete iterator state; consuming iteration adds no drop(^self) receiver. Traps remain non-unwinding. There is no consume-all std.slice.drain and no recovered remainder.
That rule applies to the temporary owning cursor created by for item in <-items. A separately named cursor is not implicitly consumed by for: the loop exclusively borrows it. break ends that loan and leaves the cursor owning its exact unyielded tail, so a later loop or direct next resumes from the current position. Writing <-cursor explicitly moves it into the loop and restores the loop-owned cleanup behavior. Direct cursor-shaped next wins over any iter method on the same type; cursors are not copied or restarted.
All accepted cursor exhaustion is sticky. A normal null result creates no owner or loan, and every later next is an observationally effect-free null.
The opaque text cursors are affine borrowed cursors, not owning-item Iterator<T> implementations. SplitCursor<region Life> keeps the source and separator in one common Life and returns str? in Life; LinesCursor<region Source> returns str? in Source. Their next<region Step>(&self in Step) borrow only advances cursor state: a yielded shared text view is tied to the underlying text region, not to the shorter step loan. Neither cursor owns or materializes its fragments, and constructing or advancing one allocates nothing.
for item: &T in _ in &items does not transfer ownership. The concrete cursor holds one exclusive loan of items for the complete loop and owns no element; each item is a fresh explicit local exclusive loan that ends before the next iteration. Replacing item drops the old referent and installs the new value without rebinding the loan. Field mutation is legal, but <-item is not: the cursor and item loan never acquire an element's cleanup obligation. On a language exit the item loan ends before cursor cleanup and the source loan; the collection remains owned by its original owner with completed changes intact.
A projection-lending cursor follows the same ownership rule with an affine region-typed aggregate instead of &T:
struct View<region Step> {
let shared: Key in Step,
let exclusive: &Value in Step,
}
for view: View in _ in &items {
view.exclusive.enabled = true;
}The view owns neither referent. An eligible view contains only shared or exclusive loans plus copy metadata; it has no owned field and no destructor. Its declared fields preserve their exact access roles: exclusive access to the cursor or view cannot upgrade shared into a mutable key. The complete affine view ends before the next step, and a present view blocks another next; null carries no loan. This rule is structural for every eligible region-typed view and cursor signature. It does not recognize HashMapExclusiveEntry or add another ownership category.
Temporaries and discarded values are owned until the end of the expression or statement that consumes them, unless they are moved earlier. This includes discarded successful try results, discarded closure values with owned captures, and aggregate temporaries materialized for a nonescaping borrow.
Types with a drop method are automatically dropped at the lexical boundary of the scope that owns the value. At block exit, registered defer bodies run before automatic drops for locals in reverse declaration order. Manual std.drop(value) consumes the value and runs the existing explicit-drop behavior; @drop(value) is the compiler builtin form and is restricted to a local variable statement. There is no @forget counterpart. std.move(value) also consumes its source and returns the moved value; unlike ordinary argument passing, it consumes even Copy locals, which makes it useful when the programmer wants an explicit ownership boundary. With region-typed aggregates and the reference/raw-pointer contract in Memory, Unsafe, And FFI specified, ownership and drop rules are fully checked at compile time.
Raw (pointer, length) pairs never manufacture ownership. The only public raw-to-checked slice boundary is unsafe std.mem.view(ptr, length, anchor), whose result is a shared or exclusive view in the real anchor's region. In particular, ^[T] always carries a genuine storage cleanup obligation, and ^[^T] always owns its slice storage plus the ^T element obligations; neither spelling is a non-owning header over caller-managed memory.
Likewise, a MaybeUninit<T> pointer proves neither an initialized range nor an allocation cleanup obligation. It stages one slot only. Foreign allocations remain nominal resource owners with anchored views, and built-in slice/text owners are published only through private trusted construction after their complete initialization and layout obligations have been established.
Ordinary with expr as name { body } is not an ownership construct and is a hard error. Its explicit spelling is { let name = expr; ... body ... }, with a written defer only when cleanup beyond the value's ordinary destructor is required. On exit the body finishes or transfers control, registered defers run in LIFO order, and then the local drops at the lexical boundary. The retained [try] await with async.Scope... syntax belongs solely to structured concurrency and performs mandatory cancel-and-join rather than generic resource drop.
Cleanup cannot take ownership of the surrounding control outcome. Ordinary defer is synchronous and defer await may suspend, but both must return normally after handling every checked error locally. An outward return, escaping throw, propagating failure, or break/continue targeting outside cleanup is rejected. Internal loop/match control and control inside a called function or closure remain local to those boundaries. Defers still run LIFO before reverse-declaration lexical drops; a runtime trap aborts immediately and skips the remaining obligations.
Ownership diagnostics
A program is rejected, at compile time, if it would double-free, use storage after it is freed, move an owned value out of a place that does not own it, or leave an owned value without a single cleanup point. These are the specific rules:
- Use after move. Using a value after it was moved is illegal. Moving consumes the source; read it before the move, borrow it, or clone it.
- Borrow conflicts. A value may not be accessed while an incompatible live borrow of it exists, and a borrow may not be captured by something that outlives the borrowed value. See Checked Reference Lifetimes.
- Move out of a shared borrow. You cannot move an owned field or element out of a bare shared borrow. Take the value by
^ownership to transfer ownership, or borrow or clone the field. - Move a drop-bearing element out of a container. An owned drop-bearing element may be moved out of a container only by a constant-index move out of a direct owning slice. Moving one out at a runtime or non-constant index, out of a view element, or out of a nested or projected container is illegal; use
(<-xs).remove(i)or(<-xs).pop(), then reinstall the returned successor owner; alternatively clone the element. - Nested owning arrays. Array and slice element types may themselves be arrays or owning slices. Each owning layer moves as one value and recursively drops its still-armed children exactly once; the same restrictions on moving a projected drop-bearing element apply at every nesting depth.
- View into an owning slot. A range-slice view of a named place may not be coerced into an owning
^[T]slot when the element type owns a drop, whether at a bind, reassignment, return, or call argument. A view owns nothing, so the owning slot would double-free the base. A non-drop element view such as[i32]and an owning-temporary range slice are allowed. - Partial-slice return. Returning a provably partial range slice of an owning temporary, such as
(mk())[1..<2], is illegal: a mid-buffer pointer cannot be handed out as an owning value. A full-cover[0..<length]return transfers ownership to the caller.
Each rejection has a stable diagnostic code; see the diagnostics reference.
For example, coercing a view of a named owning slice into an owning slot is rejected, because both the view's owning slot and the still-live base would free the same buffer:
import std.slice;
import std.mem.{ AllocError };
struct E {
let p: ^str,
drop(&self) { _ = self.p.size; }
}
fn mk() throws(AllocError) -> ^[E] {
return try ^[E { p: try ("x").clone() }, E { p: try ("y").clone() }];
}
fn f() throws(AllocError) -> usize {
let ev = try mk();
let v: ^[E] = ev[0..<2]; // rejected: view coerced into owning `^[E]`
return v.length;
}Ownership also crosses function boundaries, following the borrow-by-default parameter rules. Bare [T] is shared and &[T] mutable: the callee owns nothing and the caller keeps ownership and drops the value. An ^[T] parameter takes ownership, moving the argument into the callee; write <-argument at the call site to spell the transfer, after which the source is consumed. Ownership leaves through an explicit ^ return such as ^[T]. The parameter, reference, and view borrow rules are specified in Passing Arguments and Checked Reference Lifetimes.