Files
atelier/languages/rs-ownership.md
T
Jon Chery 4e433158cd docs(P03): complete language-derived extension — v0.4
---ci---
project: atelier
phase: 3
milestone: v0.4
status: complete
phase_role: execution
phase_tag: v0.3.3
requirements:
  covered: [ATELIER-102, ATELIER-103, ATELIER-104, ATELIER-105]
  partial: []
---/ci---
2026-08-05 16:07:21 +00:00

7.7 KiB

Rust Ownership — Derived Application

Applies Atelier's domain principles to Rust's ownership model specifically. Rust's distinctive strength (Send/Sync, lifetimes, borrowing) earns a dedicated ownership doc rather than an rs-types.md. Derives from domains/ docs; introduces no new P-rules (D-063). See languages/rust.md for the language first-principles stub.

Ownership and Move Semantics (Concurrency P1 Immutability by Default, C1 Correctness)

  • Ownership is unique: at any time, exactly one owner holds a value. Assignment passes ownership (let y = x;x is moved, not copied). The compiler rejects use-after-move.
  • Copy types (integers, bool, &T) duplicate on assignment; everything else moves. A struct is Copy only if all fields are; opt in via #[derive(Copy, Clone)] only for small, cheap-to-copy types.
  • Pass by &T for read-only, &mut T for mutation: a borrow does not transfer ownership; the caller retains the value after the callee returns.
  • Applies concurrency/P1 (immutability by default): &T is shared and immutable; &mut T is exclusive and mutable. The compiler enforces "one or many, never both" — aliasing XOR mutation, statically.
let s = String::from("hello");
let t = s;          // s moved into t
// println!("{}", s); // error: use of moved value

let n = 5;
let m = n;          // i32 is Copy: n still usable
println!("{} {}", n, m);

Borrowing and Lifetimes (C1 Correctness, Data P7 Type Fidelity, Concurrency P3 Boundaries are Locks)

  • &'a T ties a borrow to a lifetime 'a: the borrow cannot outlive the owner. Lifetimes are static — the compiler rejects dangling references.
  • Lifetime elision when unambiguous: fn first<'a>(s: &'a str) -> &'a str is elided to fn first(s: &str) -> &str (one input → output lifetime). When ambiguous, name the lifetime.
  • 'static is the longest lifetime (the whole program): not "until I drop it." Use 'static only for values that genuinely live forever (string literals, consts); leaking to 'static to satisfy the checker is a bug.
  • Ref<'a, T> and RefMut<'a, T> from RefCell are runtime-checked borrows: the borrow rules still apply, checked at runtime instead of compile time. A second RefMut panics.
  • Applies concurrency/P3 (boundaries are locks): &mut T is the compile-time lock — exclusive access is the boundary; no runtime mutex needed for single-threaded aliasing discipline.
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() { a } else { b }   // borrow tied to both inputs
}

fn dangling() -> &str {       // compile error: missing lifetime
    let s = String::from("local");
    &s                          // error: s drops at end of fn
}

Send and Sync (Concurrency P1 Immutability by Default, Concurrency P3 Boundaries are Locks, C1 Correctness)

  • Send: a type T: Send may be moved across thread boundaries. Most types are Send; Rc<T> is not (shared non-atomically refcounted).
  • Sync: a type T: Sync may be shared (&T) across threads. RefCell<T> is !Sync (interior mutability without atomics); Mutex<T> is Sync (it synchronizes).
  • The compiler enforces Send/Sync at the thread-spawn boundary: std::thread::spawn(move || { ... }) requires the closure's captures to be Send.
  • Applies concurrency/P1 and concurrency/P3: Send is the move-across-boundary contract; Sync is the share-across-boundary contract. Data races are a compile error, not a runtime detector. This is Rust's distinctive strength over Go's race detector.
use std::rc::Rc;
use std::sync::Arc;

let rc = Rc::new(5);
// std::thread::spawn(move || { println!("{}", rc) }); // error: Rc is !Send

let arc = Arc::new(5);
std::thread::spawn(move || { println!("{}", arc) });    // ok: Arc<T> is Send+Sync

Shared Mutation: Arc, Mutex, RwLock (Concurrency P3 Boundaries are Locks, Concurrency P5 Lock Minimization)

  • Arc<T> for shared ownership across threads: atomic refcounted. Clone increases the count; the last drop frees T.
  • Mutex<T> for exclusive mutation across threads: lock() blocks until exclusive; the guard MutexGuard<T> derefs to &mut T and releases on drop.
  • RwLock<T> for read-heavy, Mutex<T> for write-heavy: RwLock allows multiple readers or one writer. For most cases, Mutex is simpler and faster; prefer it unless reads dominate by 10x+.
  • Hold the lock for the smallest scope: let g = m.lock().unwrap(); then drop g before I/O. RAII releases on scope exit; explicit drop(g) clarifies intent.
  • Applies concurrency/P5 (lock minimization): prefer message passing (mpsc channels) over locks. When a lock is needed, scope it minimally.
use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
    let c = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        let mut g = c.lock().unwrap();
        *g += 1;
        // g drops here, lock released
    }));
}
for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap());

Interior Mutability (Concurrency P1 Immutability by Default, C1 Correctness)

  • Cell<T> for Copy types, RefCell<T> for non-Copy: interior mutability moves the borrow check from compile time to runtime. RefCell::borrow_mut() panics on a second mutable borrow.
  • Mutex<T>/RwLock<T> for thread-safe interior mutability: the runtime check is the lock, not a panic. Use these across threads; RefCell only single-threaded.
  • UnsafeCell<T> is the primitive; never use directly: Cell, RefCell, Mutex are safe wrappers. Direct UnsafeCell is unsafe and opts out of the aliasing guarantee.
  • Applies concurrency/P1: interior mutability is the exception, not the default. Reach for it when an API must present &self while mutating internally (e.g., a cache); document why.
use std::cell::RefCell;

struct Cache {
    inner: RefCell<HashMap<String, User>>,
}
impl Cache {
    fn get(&self, id: &str) -> Option<User> {
        // &self (immutable) but mutates internally
        self.inner.borrow_mut().entry(id.to_string()).or_insert_with(|| fetch()).clone()
    }
}

Drop and RAII (C1 Correctness, Concurrency P3 Boundaries are Locks)

  • Drop runs when the owner goes out of scope: no defer, no finally. A MutexGuard releases, a File closes, a JoinHandle... does not join (a dropped JoinHandle detaches).
  • Drop is deterministic: it runs at scope exit, not GC time. This is why Arc's refcount is precise and Mutex release is timely.
  • ManuallyDrop<T> to opt out: for FFI types whose destructor you must call manually. Rare in application code; common in unsafe bindings.
  • Drop order: fields in declaration order, then the struct itself. A field that another field's Drop depends on must be declared last.
struct Resource { name: String }
impl Drop for Resource {
    fn drop(&mut self) {
        println!("dropping {}", self.name);   // runs at scope end
    }
}

fn main() {
    let _r = Resource { name: "x".into() };
    // _r drops here, prints "dropping x"
}

Cross-References

  • domains/concurrency/first-principles.md — Concurrency P1 Immutability, P3 Boundaries are Locks, P5 Lock Minimization.
  • domains/data/first-principles.md — Data P7 Type Fidelity (lifetimes are the type-level fidelity for references).
  • domains/concurrency/patterns.md — message-passing vs lock patterns applied via Arc/Mutex/mpsc.
  • domains/errors/patterns.md? propagation relies on ownership transfer of the error.
  • languages/rs-async.md — async borrows (Pin/&mut) build on the lifetime model here.
  • languages/rs-testing.mdSend/Sync tests and ownership-based property tests.