# 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.

Github

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 borrow
let r2 = &s; // immutable borrow
// let r3 = &mut s; // ❌ while r1/r2 are in scope
println!("{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 here
let 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` ended
data.push(4); // ✅ now allowed

Borrow‑Friendly APIs

Accept &str / slices and iterators of references instead of owned collections.

fn total<'a, I>(nums: I) -> i64
where
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)

  1. Borrowing practice: Implement last_word(&str) -> &str that returns the substring after the last space.
  2. Lifetime reading: Write a function pick<'a>(a: &'a str, b: &'a str, left: bool) -> &'a str and explain the lifetime relation.
  3. Borrow‑friendly API: Write count_long<'a, I>(it: I, min: usize) -> usize where I: IntoIterator<Item = &'a str>.
  4. Ownership boundary: Refactor a function that returns String to instead accept a buffer &mut String and 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.

Next: Learning Rust #3 — Enums, Pattern Matching, Traits, and Generics
My avatar

Thanks for reading my blog post! Feel free to check out my other posts or contact me via the social links in the footer.


Learning Rust Series

Comments