Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Ownership, Borrowing, Slices

Goal: you can predict which log-parser designs the borrow checker accepts — and explain why — without compiling first.

The three rules

  1. Each value has exactly one owner; when the owner goes out of scope, the value is dropped (freed). No GC, no manual free.
  2. At any time you may have either one mutable reference (&mut T) or any number of shared references (&T) — never both.
  3. References must never outlive the data they point at (no dangling).

That is the entire model. Everything that feels restrictive in week one becomes a concurrency-safety guarantee by month two: data races are compile errors.

Move, clone, borrow

#![allow(unused)]
fn main() {
let s1 = String::from("boot");
let s2 = s1;            // MOVE: s1 is now invalid (String owns heap memory)
// println!("{s1}");    // ← compile error: borrow of moved value
let s3 = s1.clone();    // explicit deep copy — visibility over hidden cost
let len = strlen(&s3);  // BORROW: s3 still usable afterwards

fn strlen(s: &str) -> usize { s.len() }
}

i64, bool, char and friends are Copy types — assignment copies, no move semantics. Strings and Vecs move. That asymmetry is the #1 week-one confusion; name it early.

Slices: borrow a view, not a copy

#![allow(unused)]
fn main() {
let line = String::from("2026-09-19T09:30:00Z INFO boot complete");
let ts: &str = &line[..20];        // borrowed view into line's buffer
let msg: &str = line.split_once(' ').unwrap().1; // same buffer, different window
}

A slice is a pointer + length — no allocation. The parser lab leans on this: split a line into &str views, and only to_string() the pieces that must outlive the input.

The lab: what moves where

labs/m1-logpar — parse 2026-09-19T09:30:00Z INFO boot complete lines and count levels. The ownership decisions, annotated:

#![allow(unused)]
fn main() {
struct LogEntry {
    ts: String,   // OWNED — entries outlive the parse() borrow of the input
    level: Level, // COPY — a tiny enum, no heap
    msg: String,  // OWNED
}

fn parse_line(line: &str) -> Option<LogEntry> {  // borrows the line...
    let mut parts = line.splitn(3, ' ');
    let ts = parts.next()?;                      // ...slices are views into it
    let msg = parts.next()?.trim().to_string();  // ...only this outlives the fn
    Some(LogEntry { ts: ts.to_string(), level, msg })
}

fn parse(input: &str) -> Vec<LogEntry> {         // one borrow of the whole doc
    input.lines().filter_map(parse_line).collect()
}
}

Design logic:

  • parse_line(&str) -> Option — borrow in, None for garbage (a log must skip junk, not crash). .to_string() exactly where data must escape the borrow.
  • parse(&str) -> Vec<LogEntry> — the input String in main is never moved; the function borrows it, then hands back fully-owned entries.
  • counts_by_level(&[LogEntry]) — borrows the vec to count; main keeps ownership and prints error lines afterwards. Two borrows, zero copies.

Run it and watch the exit criterion:

cargo run -p m1-logpar sample.log
# INFO 2 / WARN 1 / ERROR 1 / total 4, then each ERROR line, exit code 1

ExitCode instead of a bare exit() keeps main honest and testable — the same reason Axum handlers return values instead of writing to the response directly.

Exercise

  1. Change parse_line to take line: String (owned). Does parse still compile? Why does input.lines() force you back to borrows?
  2. Make counts_by_level take Vec<LogEntry> by value. What breaks in main, and why is the &[LogEntry] version strictly better?
  3. Add a Debug-print of one entry ({:?}). Which derive made that possible?

Done when: cargo test -p m1-logpar is green (4 tests) and you can say, for each struct field, whether it is owned or borrowed — and why.