ferrule

ownership and move semantics

α1
move-on-assignuse-after-move-detectionconditional-move-trackingloop-move-detectionclone-traitescape-analysis (α2)

ownership 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

  1. values live in regions: stack, arena, heap, or otherwise
  2. regions are scoped: region allocations can't escape their region
  3. 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 copy

move 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 now

the 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 2

the 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 moved

the 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 moved

loop 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 invalid

this 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 independent

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

On this page