15Ownership, Copy, and Drop
Cryo implements a static ownership model that is enforced at compile time. The model is deliberately small: it is built around three notions (Copy, Drop, and a flow-sensitive move check). It has no borrow checker, no lifetimes, and does not track aliasing of raw pointers - but the move check is a hard error, not a warning: using a value after it has been moved is rejected at compile time (E0452, see section 15.3).
15.1 The Copy Trait
A type is Copy if it can be duplicated by a bitwise copy of its bytes. The compiler infers Copy for:
- All primitive types (integers, floats,
boolean,char,string(the rawu8*view), pointers, references, function pointers). - Struct, enum, and tuple aggregates iff every field is
Copyand the type does not implementDrop. - Generic parameters are conservatively non-
Copyunless bound byT: Copy.
Copy is a marker trait declared in stdlib/core/marker.cryo; you do not implement it explicitly. Implementing Drop automatically makes a type non-Copy.
15.2 The Drop Trait
A type implements Drop to attach a destructor:
import std::core::intrinsics::{ free };
// The trait, as `std::core::drop` declares it:
//
// type trait Drop {
// drop(mut &this) -> void;
// }
type struct Buffer { data: u8*; }
implement trait Drop for Buffer {
drop(mut &this) -> void {
free(this.data as void*);
}
}
Implementing Drop declares "I own resources that must be released." The compiler automatically synthesises drop calls for non-Copy const/mut bindings - the analyzer + synthesizer run unconditionally between MoveCheck and TypeLowering. A value is released at two points:
- Scope exit, in reverse declaration order, at every exit from the declaring scope (block end, early
return,break,continue). - Assignment over a live value:
x = new_valuereleases whateverxheld. Without this every reassignment of an owning type leaks the previous value, without bound in a loop.
Auto-drop covers const x: T = ... and mut x: T = ... declarations, and members of such a local reached by field or constant-index access (x.f, x[0]). It does not yet cover pattern bindings (match arms).
What assignment releases
Assignment releases the destination only where the compiler can establish that it holds a live value. That means a place carved out of a local's own storage: the local itself, or a chain of field projections and constant indices over it. The destinations below do not release what they overwrite, because nothing visible in the frame distinguishes a live value from reserved, never-written memory:
| destination | why not |
|---|---|
*p = v | p may address storage that has been reserved but never written |
p[i] = v where p is a pointer | the same - this is exactly how a collection fills fresh capacity |
r.f = v where r is a reference or pointer, including this.f = v | the callee cannot see whether the caller initialized the pointee |
xs[i] = v with a run-time i | no single member for the compiler to track |
| a module-level global | globals are never auto-dropped |
A consequence worth knowing: a local of an async function that survives an await is lowered into a field of the generated future, so reassigning it becomes a store through this and does not release the displaced value. A by-value async parameter is materialized as a real local per state and does release it.
At those destinations, release the old value yourself. Array::set is the canonical example: it drops the previous occupant before reusing the slot, precisely because this.ptr[index] = value does not.
Members are tracked separately
A local filled member by member has no single moment at which the whole value becomes live, so each member is tracked on its own:
mut p: Pair; // nothing live yet
p.a = make(); // p.a live; p.b still uninitialized
// scope exit releases p.a, and only p.a
Releasing p as a whole here would run Drop over p.b's uninitialized stack memory - a free of whatever the stack happened to hold, not a leak.
A user drop runs in addition to field drop-glue, not instead of it. After a type's own drop body returns, its droppable fields are released for it, exactly as they would be for the same type with no Drop impl at all. A destructor therefore releases only what the type itself owns beyond its fields - a raw handle, an OS resource - and never has to release a field by hand:
type struct Owner { inner: Buffer; } // Buffer implements Drop
implement trait Drop for Owner {
drop(mut &this) -> void {
shutdown(); // `inner` is released after this returns
}
}
Releasing a field by hand releases it twice, which is why the next rule is a requirement rather than advice.
A destructor cannot move a field of this out (E0453). Handing a field to mem::drop, matching on it by value, or binding it to a local copies its owning handle into a second owner, and field glue releases the field again when drop returns. A destructor that must release a field before the rest of its body runs swaps a replacement in and drops what comes out:
implement trait Drop for Session {
drop(mut &this) -> void {
mem::drop(this.socket); // E0453: released again after the body
mut closing: Option<Socket> = Option::None;
mem::swap(&closing, &this.socket); // the field now holds None
mem::drop(closing); // released here, once
log_closed();
}
}
A
dropimplementation must be idempotent. Releasing an already-released value must be a no-op, not a second release. Null the handle you free and guard on it:import std::core::intrinsics::{ free }; implement trait Drop for Buffer { drop(mut &this) -> void { if (this.data != null) { free(this.data as void*); } this.data = null; // REQUIRED, not defensive style } }The compiler does not check this. A
dropthat callsfree(this.data)and leaves the pointer dangling is a double-free the moment the value is released along two paths, and both the field-glue rule above and the drop of a partially-consumed aggregate depend on the no-op behaviour.
Do not call drop by hand. An explicit x.drop() warns (W0015) and will
become an error. To release early, use mem::drop(x), which takes the
value - so the move checker rejects any later use and the scope-exit drop is
suppressed. On a local, x.drop() does the same: it is read as a move, so the
local is released once, there, and a later use is refused (E0452). On a
field, h.a.drop() releases the field and the owner releases it again when it
is released itself - a second release, harmless only while every destructor
leaves what it freed empty.
Only implement trait Drop defines a destructor. A method merely named drop - declared in a struct body or an inherent implement block - is an ordinary method: it is not run at scope exit, it does not make the type satisfy where T: Drop, and calling it releases nothing else.
Releasing a value behind a pointer. Code that owns a T through a raw T* (a container's storage, a box) releases it with the drop_in_place intrinsic from std::core::ptr. After monomorphisation it runs that type's destructor and field drop-glue, or nothing for a Copy type, so T needs no bound. Calling .drop() on an unbounded T is refused, and the error says what to write:
import std::core::ptr;
function release<T>(p: T*) -> void {
ptr::drop_in_place::<T>(p); // fine for every T
}
function release_wrong<T>(p: T*) -> void {
(*p).drop(); // error[E0358]: no method named `drop` found on type `T`
// help: use `std::core::ptr::drop_in_place(p)`
}
15.3 Move Checking
Non-Copy bindings are tracked by a flow-sensitive analysis (passes/move_check.cryo). A storage-duplicating move transfers ownership; using the original afterward is a hard error (E0452). Move sites are:
- binding a non-
Copyvalue to a new name (const b: T = a;), - assigning a non-
Copyvalue into a slot (x = a;), - placing a non-
Copyvalue into an aggregate literal, - passing a non-
Copyvalue to a function or method parameter that is not a reference type (fn f(t: T)moves;fn f(t: &T)borrows), and - calling
binding.drop()or any method whose receiver consumes (mut thisor![sink]).
References (&T / mut &T) and raw pointers borrow; passing &x keeps x usable. Reassigning a binding re-initialises it, so consume(x); x = make(); is legal and x is live again after the assignment. A moved-from binding holds nothing, so that second assignment releases nothing - the callee owns what it took (see section 15.2 for what assignment does release).
const a: Buffer = make_buffer(1024);
const b: Buffer = a; // moves a -> b
use(a); // error E0452: use of moved value 'a'
Two move/ownership hazards that are unambiguous memory errors, called out as their own hard-error classes for clearer diagnostics, are:
- Loop-carried move (
E0452) - a value moved inside a loop and re-read on the next iteration would be freed twice. A loop's condition, and afor's update, run again at every back edge, so a value or field moved in them is refused, even when the body assigns it a new one, unless the body always leaves the loop withbreakorreturn. - Returning the address of a local (
E0455) -return &local;hands back a pointer into the stack frame that is freed when the function returns. (return &this/return ¶mis fine - those are caller-backed.)
Cryo has no borrow checker. References and raw pointers are unchecked: aliasing, validity, and lifetimes are the programmer's responsibility (see section 2 and section 14). Move tracking enforces the moved-set above and nothing more: it does not prove that a reference or pointer outlives what it points to.
15.4 The Send and Sync Traits
Send means a value may be moved to another thread; Sync means it may be
shared between threads by reference. Like Copy, they are decided by the
compiler, never declared - implement trait Send for T { ... } is not how a
type becomes Send, and writing one has no effect. The declarations in
stdlib/core/marker.cryo are empty marker traits that exist so bounds can
name them.
The rule is structural, and identical for both traits today (they are separate
predicates so a future &T: Send if T: Sync distinction can land without an
API change):
- Primitives, references, function pointers, and raw pointers are
SendandSync. - Arrays, optionals, and tuples are
Send/Syncif every component is. - Structs and enums are
Send/Syncif every field - or every variant payload - is, and the type is not on the deny-list below. - Generic parameters and unresolved positions are conservatively not
Send/Sync; saywhere T: Sendwhen a generic needs it.
A small deny-list overrides the structural answer for types that are
genuinely thread-unsafe despite being built from Send parts. It is matched
on the fully-qualified name, so a type of your own merely named Rc is not
caught by it:
| Type | Why |
|---|---|
std::alloc::rc::Rc | non-atomic refcount; use Arc across threads |
std::sync::mutex::MutexGuard | POSIX mutexes must be released by the acquiring thread |
std::sync::rwlock::RwLockReadGuard | as above |
std::sync::rwlock::RwLockWriteGuard | as above |
Send is advisory at the edges
Bounds are genuinely enforced: Send/Sync are checked at every explicit
where T: Send / where T: Sync, and the thread entry points carry them -
thread::spawn, try_spawn, and spawn_with_attr require C: Send, T: Send,
Scope::spawn requires C: Send. Moving an Rc<T> or a lock guard into
another thread is a compile error.
What the rule does not do is make Send a safety guarantee:
- Raw pointers are unconditionally
Send + Sync. This is deliberate - Cryo has no borrow checker, and the containers built on raw pointers (Box,Arc,Array,String,mpsc::Sender) are structurallySendbecause of it. A struct that hides aT*and reasons about it correctly does not have to fight the type system; a struct that hides aT*and reasons about it incorrectly isSendall the same. - The deny-list is a fixed list, not a derivation. A new thread-unsafe
type is
Senduntil someone adds it. - Lock constructors do not yet carry
T: Sendbounds, so aMutex<Rc<_>>is still constructible. That is the known remaining edge.
Treat a Send bound as "the compiler checked the parts it can see", not as
proof that a value is safe to move across a thread. Where a type wraps a raw
pointer whose target is not thread-safe, the obligation is the author's.
import a::b;
import a::b::{ X, Y };
import a::b as c;
import a::b::*;Brings a module, or selected items from it, into scope. One path per declaration: use the brace form for several items, as to alias, and * for everything public (prefer the brace form; wildcards invite name collisions).
intrinsic function free(ptr: void*) -> voidtype struct Name { ... }
type enum Name { ... }
type trait Name { ... }
type union Name { ... }Begins a type declaration; the keyword that follows says which kind. On its own, type Name = Existing; declares an alias.
type struct Name {
field: Type;
method(&this) -> Ret { ... }
}A value type with named fields and optional methods. Structs live on the stack and are passed by value. Fields are public by default and may carry = default values; methods take their receiver as &this, mut &this, or this.
type struct BufferBuffer.data: u8*implement struct Name { ... }
implement trait Trait for Type { ... }
implement u64 { ... }Adds methods to a type outside its declaration. implement struct T (or enum, or a bare primitive) opens an inherent block; implement trait Tr for T supplies a trait's methods for one type. Resolution is at compile time - no dynamic dispatch.
implement trait Trait for Type { ... }Introduces a trait implementation: the block supplies, for one concrete type, the method bodies the trait requires.
language referenceimplement trait Trait for Type { ... }Names the type a trait implementation is for. The block that follows supplies the bodies the trait requires.
language referencetype struct BufferBuffer::drop(mut &this) -> voidmut &this
mut &TypeAn exclusive, mutating borrow. As a receiver, mut &this lets the method modify the fields - and callers can see that from the signature alone. As a parameter type, mut &T lets the callee mutate through the reference while the caller keeps ownership.
&this
mut &this
thisThe receiver of a method. Its first parameter declares how the method takes it: &this borrows read-only, mut &this borrows exclusively, and this / mut this move the value in and consume it. In the body, this.field reaches the members.
Buffer.data: u8*value as TypeExplicit conversion: between numeric types, or reinterpreting one pointer type as another. Cryo never converts implicitly, and as inserts no range checks - a narrowing cast is the programmer's responsibility.
mut name: Type = value;A mutable binding, reassignable after declaration - mutability is opt-in, const is the default. At module level it declares mutable global state. Before & it marks an exclusive reference or receiver instead.
mut p: Pairtype struct Pair<A, B> {
first: A;
second: B;
}Pair<A, B>: an owned two-element tuple.
std::collections::pairmut p: Pairtype struct OwnerOwner.inner: Buffertype struct Buffertype struct OwnerOwner::drop(mut &this) -> voidextern "C" function shutdown(sockfd: i32, how: ShutdownHow) -> i32shutdown is also declared in std::sys::syscall
std::ffi::libctype struct SessionSession::drop(mut &this) -> voidmut closing: Option<Socket>Option::Nonemut closing: Option<Socket>if (condition) { ... } else if (condition) { ... } else { ... }Branches on a parenthesised boolean condition. Bodies are always braced; there is no single-statement form. Used as an expression, both arms are required and must produce the same type: const sign = if (n < 0) { -1 } else { 1 };.
Buffer.data: u8*nullThe null pointer literal, valid in any pointer context - including the primitive string, which is a NUL-terminated u8*. Compare with == null; it is not an Option.
function name<T>(param: Type) -> Ret { ... }Declares a function. Parameters are written name: Type and are never inferred; the return type follows -> and defaults to void. Declaration order does not matter: every signature is collected before any body is checked, so functions may call each other freely and recurse.
function release<T>(p: T*) -> voidtype parameter T(parameter) p: T*type parameter Tintrinsic function drop_in_place<T>(p: T*) -> void(parameter) p: T*function release_wrong<T>(p: T*) -> voidtype parameter T(parameter) p: T*const NAME: Type = value;At module level, a true compile-time constant, conventionally SCREAMING_SNAKE_CASE. (mut at module level declares mutable global state instead.)
const a: Bufferconst b: Bufferconst a: Buffertype trait Copy {}Types whose values can be duplicated by a bitwise copy. Integers, floats, pointers, and pure-POD structs qualify; anything that owns resources (heap storage, file handles, refcount) does not.
std::core::markerprimitive stringNUL-terminated raw string (u8*), matching the C ABI. Length-typed text is Str / String.
const name: Type = value;An immutable binding: it cannot be reassigned after initialisation. The annotation may be omitted when an initialiser is present - the binding takes the initialiser's concrete type, inferred locally. Mutability is opt-in through mut.
return value;Leaves the current function with the given value. A function whose return type is not void must return one; a bare return; is for void. In an async function it completes the future with the value.
break;Exits the innermost enclosing loop immediately - for, while, loop, or do. Inside for (x in ..) it leaves the loop the compiler synthesised around the iterator.
continue;Skips the rest of the current iteration: the innermost loop's condition (or the iterator's next()) runs again.
const x: Tfunction name(count: i32, args...)A variadic parameter: the trailing bucket receives any number of further arguments with C's calling convention, so the function stays ABI-compatible with the printf family. Wrap the bucket in VaArgs to read typed values from it.
mut x: Tmatch (subject) {
Pattern(x) => { ... }
_ => { ... }
}The primary discriminator: branches on enum variants, integers, ranges, and destructuring patterns, and the compiler rejects a match that misses a case. There is no fallthrough. As an expression it evaluates to the chosen arm's value, so every arm must produce the same type.
async function name(param: Type) -> T { ... }Compiles the function into a stackless state machine. Calling it builds a future whose output is the declared return type; nothing runs until that future is polled. Allowed on free functions, methods, and trait methods.
language referenceawait futureSuspends the enclosing async body until the operand - which must implement Future - completes, and evaluates to its output. A prefix operator allowed anywhere an expression is (conditions, arms, guards), but only inside async code.
type struct Array<T, A = GlobalAlloc> where A: Allocator {
ptr: T*;
length: i64;
capacity: i64;
alloc: A;
}Array<T, A>: growable, heap-backed, contiguous sequence.
std::collections::arrayArray::set(mut &this, index: u64, value: T) -> voidOverwrite the element at index, taking ownership of value. The previous occupant is released in place before the slot is reused: a raw slot store (this.ptr[index] = value) does NOT drop the destination, so for owning T (String, Box<T>, ...) the old element's resources would otherwise leak.
where T: Ord + Clone, U: HashConstrains generic parameters to types that implement the listed traits, which is what makes those traits' methods callable on T. Bounds on one parameter join with +; parameters separate with ,. A where on a single method narrows the type's parameter for that method only.
const b: Tconst a: Buffer(parameter) t: T(parameter) t: &Tmut thisA by-value receiver the body may also reassign. The receiver is moved into the method and consumed; the caller's value may not be used afterwards.
language referencefor (mut i: i32 = 0; i < n; i++) { ... }
for (item in iterable) { ... }Two forms. The C-style for (init; condition; step) scopes its loop variable to the body. for (x in expr) iterates anything with next() -> Option<T>: an iterator, a range such as 0..n, an Array or Slice (their iter() is inserted for you), or a fixed-size array.
type trait Send {}Types that are safe to transfer between threads. An owning value of a Send type may cross a thread boundary without violating memory safety.
type trait Sync {}Types that are safe to share between threads by shared reference. T: Sync if &T: Send.
type struct Ttype struct Rc<T, A = GlobalAlloc> where A: Allocator {
inner: RcInner<T, A>*;
}Rc<T, A>: single-threaded reference counting, with weak references.
std::alloc::rcnamespace std::alloc::rc;Rc<T, A>: single-threaded reference counting, with weak references.
module pagetype struct Arc<T, A = GlobalAlloc> where A: Allocator {
inner: ArcInner<T, A>*;
}Arc<T, A>: atomic reference counting, with weak references.
std::alloc::arctype struct MutexGuard<T, A> {
inner: MutexInner<T, A>*;
}RAII guard returned by Mutex::lock. Carries a raw pointer to the inner so it can release the lock on drop. Deref via as_ptr() to read or mutate the guarded value.
type struct RwLockReadGuard<T, A> {
inner: RwLockInner<T, A>*;
}Shared-read guard. Many of these may exist concurrently on the same RwLock. Calling .as_ptr() yields a pointer that should be treated as &T (read-only); writing through it is a logic error the type system doesn't catch (Cryo has no borrow checker).
type struct RwLockWriteGuard<T, A> {
inner: RwLockInner<T, A>*;
}Exclusive write guard. At most one of these exists on a given RwLock, and no RwLockReadGuard can co-exist with it.
function spawn<C, T>(ctx: C, body: (C) -> T) -> JoinHandle<T>
where C: Send, T: SendSpawn a thread that runs body(ctx) and returns a handle to its T result. ctx is moved into the new thread. body must be a function pointer or non-capturing lambda. Panics on allocation or pthread_create failure; use try_spawn for a fallible spawn.
type struct Scope {
tids: [u64];
stack_bytes: u64;
}A thread scope. Every thread spawned through it is guaranteed to be joined before the scope is torn down (explicitly via join_all() or automatically when the Scope drops), so scoped bodies may safely borrow data that outlives the scope - pass a pointer to it through the body's context:
Scope::spawn<C>(mut &this, ctx: C, body: (C) -> void) -> void
where C: SendSpawn a scoped thread running body(ctx). ctx is moved in (often a pointer into scope-outliving state). The thread is recorded and joined by join_all() / the scope's drop. Panics on allocation or thread-creation failure.
type struct Box<T, A = GlobalAlloc> where A: Allocator {
ptr: T*;
alloc: A;
}Box<T, A>: single-owner heap allocation.
std::alloc::boxtype struct String<A = GlobalAlloc> {
buffer: RawBuffer<u8, A>;
length: u64;
}String: owned, heap-allocated, length-typed UTF-8.
std::collections::stringtype struct Mutex<T, A = GlobalAlloc> where A: Allocator {
inner: MutexInner<T, A>*;
}Mutex<T>: mutual-exclusion lock around a value.
std::sync::mutex