Skip to content
CryoCryo home
LanguageProgram structure

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 raw u8* view), pointers, references, function pointers).
  • Struct, enum, and tuple aggregates iff every field is Copy and the type does not implement Drop.
  • Generic parameters are conservatively non-Copy unless bound by T: 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_value releases whatever x held. 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:

destinationwhy not
*p = vp may address storage that has been reserved but never written
p[i] = v where p is a pointerthe same - this is exactly how a collection fills fresh capacity
r.f = v where r is a reference or pointer, including this.f = vthe callee cannot see whether the caller initialized the pointee
xs[i] = v with a run-time ino single member for the compiler to track
a module-level globalglobals 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 drop implementation 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 drop that calls free(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-Copy value to a new name (const b: T = a;),
  • assigning a non-Copy value into a slot (x = a;),
  • placing a non-Copy value into an aggregate literal,
  • passing a non-Copy value 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 this or ![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 a for'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 with break or return.
  • 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 &param is 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 Send and Sync.
  • Arrays, optionals, and tuples are Send/Sync if every component is.
  • Structs and enums are Send/Sync if 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; say where T: Send when 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:

TypeWhy
std::alloc::rc::Rcnon-atomic refcount; use Arc across threads
std::sync::mutex::MutexGuardPOSIX mutexes must be released by the acquiring thread
std::sync::rwlock::RwLockReadGuardas above
std::sync::rwlock::RwLockWriteGuardas 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 structurally Send because of it. A struct that hides a T* and reasons about it correctly does not have to fight the type system; a struct that hides a T* and reasons about it incorrectly is Send all the same.
  • The deny-list is a fixed list, not a derivation. A new thread-unsafe type is Send until someone adds it.
  • Lock constructors do not yet carry T: Send bounds, so a Mutex<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.