Files
Sprimo/skills/m14-mental-model/patterns/thinking-in-rust.md
2026-02-12 22:58:33 +08:00

6.6 KiB

Thinking in Rust: Mental Models

Core Mental Models

1. Ownership as Resource Management

Traditional: "Who has a pointer to this data?"
Rust:        "Who OWNS this data and is responsible for freeing it?"

Key insight: Every value has exactly one owner. When the owner goes out of scope, the value is dropped.

{
    let s = String::from("hello");  // s owns the String
    // use s...
}  // s goes out of scope, String is dropped (memory freed)

2. Borrowing as Temporary Access

Traditional: "I'll just read from this pointer"
Rust:        "I'm borrowing this value, owner still responsible for it"

Key insight: Borrows are like library books - you can read them, but must return them.

fn print_length(s: &String) {  // borrows s
    println!("{}", s.len());
}  // borrow ends, caller still owns s

let my_string = String::from("hello");
print_length(&my_string);  // lend to function
println!("{}", my_string);  // still have it

3. Lifetimes as Validity Scopes

Traditional: "Hope this pointer is still valid"
Rust:        "Compiler tracks exactly how long references are valid"

Key insight: A reference can't outlive the data it points to.

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    // 'a means: the returned reference is valid as long as BOTH inputs are valid
    if x.len() > y.len() { x } else { y }
}

Shifting Perspectives

From "Everything is a Reference" (Java/C#)

Java mental model:

// Everything is implicitly a reference
User user = new User("Alice");  // user is a reference
List<User> users = new ArrayList<>();
users.add(user);  // shares the reference
user.setName("Bob");  // affects the list too!

Rust mental model:

// Values are owned, sharing is explicit
let user = User::new("Alice");  // user is owned
let mut users = vec![];
users.push(user);  // user moved into vec, can't use user anymore
// user.set_name("Bob");  // ERROR: user was moved

// If you need sharing:
use std::rc::Rc;
let user = Rc::new(User::new("Alice"));
let user2 = Rc::clone(&user);  // explicit shared ownership

From "Manual Memory Management" (C/C++)

C mental model:

char* s = malloc(100);
// ... must remember to free(s) ...
// ... what if we return early? ...
// ... what if an exception occurs? ...
free(s);

Rust mental model:

let s = String::with_capacity(100);
// ... use s ...
// No need to free - Rust drops s automatically when scope ends
// Even with early returns, panics, or any control flow

From "Garbage Collection" (Go/Python)

GC mental model:

# Create objects, GC will figure it out
users = []
for name in names:
    users.append(User(name))
# GC runs sometime later, when it feels like it

Rust mental model:

let users: Vec<User> = names
    .iter()
    .map(|name| User::new(name))
    .collect();
// Memory is freed EXACTLY when users goes out of scope
// Deterministic, no GC pauses, no unpredictable memory usage

Key Questions to Ask

When Designing Functions

  1. Does this function need to own the data, or just read it?

    • Need to keep it: take ownership (fn process(data: Vec<T>))
    • Just reading: borrow (fn process(data: &[T]))
    • Need to modify: mutable borrow (fn process(data: &mut Vec<T>))
  2. Does the return value contain references to inputs?

    • Yes: need lifetime annotations
    • No: lifetime elision usually works

When Designing Structs

  1. Should this struct own its data or reference it?

    • Long-lived, independent: own (name: String)
    • Short-lived view: reference (name: &'a str)
  2. Do multiple parts need to access the same data?

    • Single-threaded: Rc<T> or Rc<RefCell<T>>
    • Multi-threaded: Arc<T> or Arc<Mutex<T>>

When Hitting Borrow Checker Errors

  1. Am I trying to use a value after moving it?

    • Clone it, borrow it, or restructure the code
  2. Am I trying to have multiple mutable references?

    • Scope the mutations, use interior mutability, or redesign
  3. Does a reference outlive its source?

    • Return owned data instead, or use 'static

Common Patterns

The Clone Escape Hatch

When fighting the borrow checker, .clone() often works:

// Can't do this - double borrow
let mut map = HashMap::new();
for key in map.keys() {
    map.insert(key.clone(), process(key));  // ERROR: map borrowed twice
}

// Clone to escape
let keys: Vec<_> = map.keys().cloned().collect();
for key in keys {
    map.insert(key.clone(), process(&key));  // OK
}

But ask: "Is there a better design?" Often, restructuring is better than cloning.

The "Make It Own" Pattern

When lifetimes get complex, make the struct own its data:

// Complex: struct with references
struct Parser<'a> {
    input: &'a str,
    current: &'a str,
}

// Simpler: struct owns data
struct Parser {
    input: String,
    position: usize,
}

The "Split the Borrow" Pattern

struct Data {
    field_a: Vec<i32>,
    field_b: Vec<i32>,
}

// Can't borrow self mutably twice
fn process(&mut self) {
    // for a in &self.field_a {
    //     self.field_b.push(*a);  // ERROR
    // }

    // Split the borrow
    let Data { field_a, field_b } = self;
    for a in field_a.iter() {
        field_b.push(*a);  // OK: separate borrows
    }
}

The Rust Way

Embrace the Type System

// Don't: stringly-typed
fn connect(host: &str, port: &str) { ... }
connect("8080", "localhost");  // oops, wrong order

// Do: strongly-typed
struct Host(String);
struct Port(u16);
fn connect(host: Host, port: Port) { ... }
// connect(Port(8080), Host("localhost".into()));  // compile error!

Make Invalid States Unrepresentable

// Don't: runtime checks
struct Connection {
    socket: Option<Socket>,
    connected: bool,
}

// Do: types enforce states
enum Connection {
    Disconnected,
    Connected { socket: Socket },
}

Let the Compiler Guide You

// Start with what you want
fn process(data: ???) -> ???

// Let compiler errors tell you:
// - What types are needed
// - What lifetimes are needed
// - What bounds are needed

// The error messages are documentation!

Summary: The Rust Mental Model

  1. Values have owners - exactly one at a time
  2. Borrowing is lending - temporary access, owner retains responsibility
  3. Lifetimes are scopes - compiler tracks validity
  4. Types encode constraints - use them to prevent bugs
  5. The compiler is your friend - work with it, not against it

When stuck:

  • Clone to make progress
  • Restructure to own instead of borrow
  • Ask: "What is the compiler trying to tell me?"