# Learning Rust #2 — Making Friends with the Borrow Checker
Table of Contents
If you’ve built anything non‑trivial with threads, async, or FFI, you’ve debugged spooky lifetime bugs. Rust drags those to compile time. Today, we’ll get comfortable with moves vs borrows, immutable vs mutable references, and when explicit lifetimes appear.
Goals for Part 2
- Internalize move vs borrow using simple, realistic snippets
- Understand exclusive mutability and why it prevents data races
- Learn when explicit lifetimes are necessary (and how to read them)
- Practice design patterns that keep the borrow checker happy
Moving vs Borrowing
A move transfers ownership. A borrow (&T or &mut T) temporarily lends access without taking ownership.
fn takes_ownership(s: String) { println!("{s}");}
fn borrows(s: &String) { println!("{s}");}
fn main() { let a = String::from("move me"); takes_ownership(a); // a is moved // println!("{a}"); // ❌ use after move
let b = String::from("borrow me"); borrows(&b); // borrow keeps b alive println!("{b}"); // ✅ still accessible}Heuristic: Borrow when you can, own when you must. It leads to clearer APIs and fewer clones.
The Mutability Rule (Single Writer or Many Readers)
At any instant, you can have either:
- one
&mut T(exclusive, writable), or - any number of
&T(shared, read‑only).
fn mutate_in_place(s: &mut String) { s.push_str(" world"); }
fn main() { let mut s = String::from("hello"); mutate_in_place(&mut s); // temporary exclusive access println!("{s}");}This rule eliminates data races by construction. If the borrow checker blocks you, ask: Where does exclusive access actually start and end? Tighten scopes to make it obvious.
Slices and String vs &str
String= owned, growable UTF‑8 buffer&str= borrowed view (slice) into UTF‑8 bytes
Prefer APIs that accept &str unless you need ownership or mutation.
fn first_word(s: &str) -> &str { match s.find(' ') { Some(i) => &s[..i], None => s, }}
fn main() { let owned = String::from("rust language"); let slice = first_word(&owned); let literal = first_word("hello"); println!("{slice}, {literal}");}When Lifetimes Show Up
Most lifetimes are inferred. You write them explicitly when returning references that depend on input references, or when a struct holds references.
// Returns a slice that lives as long as the input slice.fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() >= y.len() { x } else { y }}Read it as: x and y must live at least as long as 'a, and we return a reference tied to that same 'a.
Lifetimes in Structs
struct Header<'a> { name: &'a str, value: &'a str,}
impl<'a> Header<'a> { fn render(&self) -> String { format!("{}: {}", self.name, self.value) }}If holding owned data avoids juggling lifetimes (e.g., store String instead of &str), prefer ownership at the boundary and borrow internally.
Common Borrow Checker Errors (and Fixes)
1) Borrow of moved value
let s = String::from("x");let t = s; // move// println!("{s}"); // ❌Fix: don’t use s after move; if both are needed, let t = s.clone(); or borrow &s.
2) Cannot borrow as mutable because it is also borrowed as immutable
let mut s = String::from("abc");let r1 = &s; // immutable borrowlet r2 = &s; // immutable borrow// let r3 = &mut s; // ❌ while r1/r2 are in scopeprintln!("{r1} {r2}");Fix: end immutable borrows before taking &mut using a smaller scope.
let mut s = String::from("abc");{ let r = &s; println!("{r}");} // r ends herelet m = &mut s; // ✅m.push('d');3) Borrowed value does not live long enough
fn dangling() -> &String { // ❌ returning reference to temporary let s = String::from("hi"); &s}Fix: return an owned value instead, or ensure the reference points to data owned by the caller.
fn owned() -> String { String::from("hi")}Practical Patterns
Clone at Boundaries
Cloning everywhere defeats Rust’s guarantees and performance. Cloning at boundaries (thread handoffs, caches) keeps internals borrow‑friendly.
fn spawn_logger(line: String) { std::thread::spawn(move || { // line moved into thread; no shared mutation issues eprintln!("{line}"); });}Short, Focused Scopes
Use inner blocks to end borrows early and unlock later mutation.
let mut data = vec![1, 2, 3];{ let head = &data[0]; println!("head={head}");}// the borrow of `data` endeddata.push(4); // ✅ now allowedBorrow‑Friendly APIs
Accept &str / slices and iterators of references instead of owned collections.
fn total<'a, I>(nums: I) -> i64where I: IntoIterator<Item = &'a i64>,{ nums.into_iter().copied().sum()}Mini Project: A Safe, Borrow‑Oriented Parser
Let’s parse lines like key=value without allocating new Strings for keys/values.
#[derive(Debug, PartialEq)]pub struct Pair<'a> { pub key: &'a str, pub value: &'a str,}
pub fn parse_line(line: &str) -> Option<Pair<'_>> { let eq = line.find('=')?; let (k, v) = line.split_at(eq); let v = &v[1..]; // skip '=' Some(Pair { key: k.trim(), value: v.trim() })}
#[cfg(test)]mod tests { use super::*;
#[test] fn parses_ok() { let p = parse_line(" host = example.com ").unwrap(); assert_eq!(p, Pair { key: "host", value: "example.com" }); }
#[test] fn rejects_missing_eq() { assert!(parse_line("nope").is_none()); }}No heap allocations for substrings; everything’s borrowed from the input line.
Debugging the Right Way
- Use
dbg!(expr)while refactoring; it returns the value, so you can leave it in expressions temporarily. - If a borrow seems to last “too long,” introduce a new scope or move work into a helper function.
- Read error messages top‑down—Rust often tells you exactly which borrow conflicts with which.
Exercises (15–25 minutes)
- Borrowing practice: Implement
last_word(&str) -> &strthat returns the substring after the last space. - Lifetime reading: Write a function
pick<'a>(a: &'a str, b: &'a str, left: bool) -> &'a strand explain the lifetime relation. - Borrow‑friendly API: Write
count_long<'a, I>(it: I, min: usize) -> usizewhereI: IntoIterator<Item = &'a str>. - Ownership boundary: Refactor a function that returns
Stringto instead accept a buffer&mut Stringand write into it; compare allocations.
What surprised me today
- Most lifetime annotations disappear once I shape APIs around borrowing inputs.
- Exclusive mutability felt strict at first, but it removes whole categories of bugs.
- Compiler error messages are a design conversation—lean into them.
Next up (Part 3): enums, pattern matching, traits, and generics—how Rust models domains precisely without runtime overhead.