ownership and move semantics
α1ownership and move semantics
values live in regions. the region owns the memory. move semantics determine what happens when you assign or pass values within and between scopes.
the core insight: you don't need a borrow checker if references can't escape their region.
the rules
- values live in regions: stack, arena, heap, or otherwise
- regions are scoped: region allocations can't escape their region
- to escape, copy: if you need data to leave a scope, you copy it
this gives you memory safety without the complexity of lifetime annotations.
move semantics
when you assign a value, one of two things happens:
copy types duplicate the value:
const a: i32 = 42;
const b = a; // a is copied
io.println(a); // ok, a still valid
io.println(b); // ok, b has its own copymove types transfer ownership:
const s: String = "hello";
const t = s; // s is moved to t
// io.println(s); // error: s was moved
io.println(t); // ok, t owns the data nowthe key difference: copy duplicates, move transfers. after a move, the original is invalid.
which types are copy vs move
by default:
- primitives (i32, f64, bool, etc) are copy
- small structs (roughly < 64 bytes) are copy
- heap-backed types (String, Vec, Box) are move
- unique resources (File, Socket) are move
- large structs are move
you can annotate explicitly:
type SmallBuffer = copy { data: Array<u8, 64> };
type Handle = move { id: u64 };use after move
the compiler tracks what's been moved:
const data: String = "hello";
process(data); // data is moved into process
io.println(data); // error: data was moved on line 2the error message tells you where the move happened. use-after-move is always a compile error.
conditional moves
if a move might happen in one branch, the variable is invalid afterward:
const data: String = "hello";
if condition {
consume(data); // moves data
}
// here, data might have been moved (if condition was true)
// or might still be valid (if condition was false)
use(data); // error: data might have been movedthe compiler is conservative. if any path moves the value, it's invalid on all paths after the conditional.
safe patterns:
// move in all branches
if condition {
consume(data);
} else {
other(data);
}
// data invalid everywhere, that's fine
// or use clone
if condition {
consume(data.clone());
}
use(data); // ok, only the clone was movedloop moves
you can't move a value inside a loop:
const data: String = "hello";
for i in 0..3 {
process(data); // error: would move data multiple times
}the fix is explicit cloning:
for i in 0..3 {
process(data.clone()); // explicit copy each iteration
}this makes the cost visible. cloning in a hot loop is something you want to think about.
no partial moves
you can't move a single field out of a struct:
type User = { name: String, email: String };
const user = User { name: "alice", email: "alice@example.com" };
const name = user.name; // what's the state of user now?ferrule doesn't allow this. if you need one field, destructure the whole thing:
const User { name, email } = user;
// now both name and email are separate values
// user is fully invalidthis keeps the rules simple. no tracking of "partially valid" structs.
clone
to copy a move type, use clone:
const s: String = "hello";
const copy = s.clone(); // explicit copy
io.println(s); // ok, s still valid
io.println(copy); // ok, copy is independentclone is explicit because copying can be expensive. you should think about whether you really need a copy.
types that can be cloned implement a Clone record:
type Clone<T> = {
clone: (T) -> T
};why not a borrow checker
a borrow checker (like rust's) can prove more programs safe. but it has costs:
- complex error messages
- fighting the compiler
- lifetime annotations infect your code
ferrule's model is simpler:
- easy to understand rules
- predictable behavior
- explicit copies when needed
the tradeoff is you copy more. for most programs, this is fine. the copies are explicit so you can optimize hot paths.
safety guarantees
this model, combined with regions, eliminates:
- double free: regions free everything at once, no individual free calls to get wrong
- use after free: the compiler tracks region lifetimes, views can't outlive their region
- dangling pointers: compile-time region tracking ensures references are always valid
these bugs are compile errors, not runtime crashes.