Ownership, Borrowing, Slices
Goal: you can predict which log-parser designs the borrow checker accepts — and explain why — without compiling first.
The three rules
- Each value has exactly one owner; when the owner goes out of scope, the value is dropped (freed). No GC, no manual free.
- At any time you may have either one mutable reference (
&mut T) or any number of shared references (&T) — never both. - 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,Nonefor garbage (a log must skip junk, not crash)..to_string()exactly where data must escape the borrow.parse(&str) -> Vec<LogEntry>— the inputStringinmainis never moved; the function borrows it, then hands back fully-owned entries.counts_by_level(&[LogEntry])— borrows the vec to count;mainkeeps 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
- Change
parse_lineto takeline: String(owned). Doesparsestill compile? Why doesinput.lines()force you back to borrows? - Make
counts_by_leveltakeVec<LogEntry>by value. What breaks inmain, and why is the&[LogEntry]version strictly better? - 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.