Skip to content
CryoCryo home
LanguageProgram structure

16Ownership, Copy, and Drop

Cryo implements a static ownership model that is enforced at compile time. The model is deliberately weaker than Rust's: 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 16.3).

16.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, class, 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.

16.2 The Drop Trait

A type implements Drop to attach a destructor:

type trait Drop {
    drop(mut &this) -> void;
}

implement trait Drop for Buffer {
    drop(mut &this) -> void {
        free(this.data);
    }
}

Implementing Drop declares "I own resources that must be released." The compiler automatically synthesises drop calls at scope exit for non-Copy const/mut bindings - the analyzer + synthesizer run unconditionally between MoveCheck and TypeLowering. Drops fire in reverse declaration order at every scope-exit point (block end, early return, break, continue). Manual binding.drop() remains valid and idiomatic: the analyzer treats the call as a move, the synthesizer skips bindings already consumed, and a second binding.drop() (or any other use of the binding after .drop()) is rejected as use-after-move (E0452).

Auto-drop covers const x: T = ... and mut x: T = ... declarations. It does not yet cover pattern bindings (match arms) or members reached by field/index access - explicit .drop() is still required in those positions.

16.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.

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.
  • 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, as in C++ (see section 2 and section 15). Move tracking enforces the moved-set above; it is not a full Rust-style soundness boundary.


16.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, classes, 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.