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 rawu8*view), pointers, references, function pointers). - Struct, class, 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.
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-
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.
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 ¶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, 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
SendandSync. - Arrays, optionals, and tuples are
Send/Syncif every component is. - Structs, classes, 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.