Rust Ownership Debugging Case Study | Fixing "the borrow checker says no"

Rust Ownership Debugging Case Study | Fixing "the borrow checker says no"

이 글의 핵심

Practical fixes for Rust ownership errors: borrow checker basics, RefCell, Rc, and Arc patterns.

Introduction

“It works in C++—why not in Rust?” is something you hear a lot when learning Rust. This article uses real ownership errors to show how to understand the borrow checker and fix them.

What you will learn

  • How to read borrow checker error messages
  • How ownership, borrowing, and lifetimes play out in practice
  • Patterns with RefCell, Rc, Arc, and more
  • How to work with Rust’s memory safety guarantees

Table of contents

  1. Case 1: “cannot move out of borrowed content”
  2. Case 2: “cannot borrow as mutable more than once”
  3. Case 3: “lifetime may not live long enough”
  4. Case 4: “cannot return reference to local variable”
  5. Case 5: Shared state across threads
  6. Pattern cheat sheet: what to use when
  7. Conclusion

1. Case 1: “cannot move out of borrowed content”

Problem code

struct User {
    name: String,
    email: String,
}

fn process_users(users: &Vec<User>) {
    for user in users {
        let name = user.name; // ❌ cannot move out of `user.name`
        println!("Processing: {}", name);
    }
}

Error message

error[E0507]: cannot move out of `user.name` which is behind a shared reference
  --> src/main.rs:8:20
   |
8  |         let name = user.name;
   |                    ^^^^^^^^^ move occurs because `user.name` has type `String`, which does not implement the `Copy` trait

Why is this an error?

  • users is an immutable borrow (&Vec<User>)
  • user.name is a String (owned type)
  • let name = user.name tries to move ownership out
  • You cannot move out of borrowed data!

Fixes

// Option 1: use references only
fn process_users(users: &Vec<User>) {
    for user in users {
        let name = &user.name; // ✅ borrow
        println!("Processing: {}", name);
    }
}

// Option 2: clone
fn process_users(users: &Vec<User>) {
    for user in users {
        let name = user.name.clone(); // ✅ copy the string
        println!("Processing: {}", name);
    }
}

// Option 3: take ownership
fn process_users(users: Vec<User>) { // remove &
    for user in users {
        let name = user.name; // ✅ move is allowed
        println!("Processing: {}", name);
    }
}

2. Case 2: “cannot borrow as mutable more than once”

Problem code

struct ChatRoom {
    users: Vec<User>,
    messages: Vec<String>,
}

impl ChatRoom {
    fn broadcast(&mut self, sender: &User) {
        let msg = format!("{}: Hello", sender.name);
        
        // ❌ cannot borrow `self` as mutable more than once
        for user in &mut self.users {
            self.messages.push(msg.clone()); // 💥 second mutable borrow!
        }
    }
}

Error message

error[E0499]: cannot borrow `self.messages` as mutable more than once at a time
  --> src/main.rs:12:13
   |
10 |         for user in &mut self.users {
   |                     --------------- first mutable borrow occurs here
11 |             self.messages.push(msg.clone());
   |             ^^^^^^^^^^^^^ second mutable borrow occurs here

Why is this an error?

  • &mut self.users is the first mutable borrow
  • self.messages.push() tries a second mutable borrow
  • Rust allows only one mutable reference at a time

Fixes

// Option 1: split borrows across fields
impl ChatRoom {
    fn broadcast(&mut self, sender: &User) {
        let msg = format!("{}: Hello", sender.name);
        
        let users = &mut self.users;
        let messages = &mut self.messages;
        
        for user in users {
            messages.push(msg.clone()); // ✅ different fields
        }
    }
}

// Option 2: push the message before the loop
impl ChatRoom {
    fn broadcast(&mut self, sender: &User) {
        let msg = format!("{}: Hello", sender.name);
        self.messages.push(msg.clone()); // add first
        
        for user in &mut self.users {
            // no longer borrowing messages here
            user.notify();
        }
    }
}

// Option 3: use indices
impl ChatRoom {
    fn broadcast(&mut self, sender: &User) {
        let msg = format!("{}: Hello", sender.name);
        
        for i in 0..self.users.len() {
            self.messages.push(msg.clone()); // ✅ indexing is not a borrow conflict
        }
    }
}

3. Case 3: “lifetime may not live long enough”

Problem code

struct UserCache {
    users: Vec<User>,
}

impl UserCache {
    fn find(&self, name: &str) -> Option<&User> {
        self.users.iter().find(|u| u.name == name)
    }
    
    fn get_or_create(&mut self, name: &str) -> &User {
        // ❌ lifetime error
        if let Some(user) = self.find(name) {
            return user; // 💥 borrow active but &mut self needed below
        }
        
        self.users.push(User { name: name.to_string(), email: String::new() });
        self.users.last().unwrap()
    }
}

Error message

error[E0502]: cannot borrow `self.users` as mutable because it is also borrowed as immutable
  --> src/main.rs:15:9
   |
12 |         if let Some(user) = self.find(name) {
   |                             ---- immutable borrow occurs here
13 |             return user;
   |                    ---- returning this value requires that `*self` is borrowed for `'1`
...
15 |         self.users.push(...);
   |         ^^^^^^^^^^^ mutable borrow occurs here

Fixes

// Option 1: use an index
impl UserCache {
    fn get_or_create(&mut self, name: &str) -> &User {
        if let Some(idx) = self.users.iter().position(|u| u.name == name) {
            return &self.users[idx]; // ✅ fresh borrow
        }
        
        self.users.push(User { name: name.to_string(), email: String::new() });
        self.users.last().unwrap()
    }
}

// Option 2: Entry API (HashMap)
use std::collections::HashMap;

struct UserCache {
    users: HashMap<String, User>,
}

impl UserCache {
    fn get_or_create(&mut self, name: &str) -> &User {
        self.users.entry(name.to_string())
            .or_insert_with(|| User { name: name.to_string(), email: String::new() })
    }
}

4. Case 4: “cannot return reference to local variable”

Problem code

fn get_default_user() -> &User {
    let user = User {
        name: "Guest".to_string(),
        email: "[email protected]".to_string(),
    };
    &user // ❌ `user` is dropped at the end of the function
}

Error message

error[E0515]: cannot return reference to local variable `user`
  --> src/main.rs:8:5
   |
8  |     &user
   |     ^^^^^ returns a reference to data owned by the current function

Fixes

// Option 1: return ownership
fn get_default_user() -> User {
    User {
        name: "Guest".to_string(),
        email: "[email protected]".to_string(),
    }
}

// Option 2: 'static lifetime (illustrative; String cannot be const here)
fn get_default_user() -> &'static User {
    static DEFAULT: User = User {
        name: String::new(), // ❌ not valid in const—example only
    };
    &DEFAULT
}

// Option 2b: lazy_static
use lazy_static::lazy_static;

lazy_static! {
    static ref DEFAULT_USER: User = User {
        name: "Guest".to_string(),
        email: "[email protected]".to_string(),
    };
}

fn get_default_user() -> &'static User {
    &DEFAULT_USER
}

// Option 3: heap allocation with Box
fn get_default_user() -> Box<User> {
    Box::new(User {
        name: "Guest".to_string(),
        email: "[email protected]".to_string(),
    })
}

5. Case 5: Shared state across threads

Problem code

use std::thread;

struct Counter {
    count: i32,
}

fn main() {
    let counter = Counter { count: 0 };
    
    let handle = thread::spawn(|| {
        counter.count += 1; // ❌ closure may outlive the current function
    });
    
    handle.join().unwrap();
}

Error message

error[E0373]: closure may outlive the current function, but it borrows `counter`, which is owned by the current function
  --> src/main.rs:9:31
   |
9  |     let handle = thread::spawn(|| {
   |                                ^^ may outlive borrowed value `counter`
10 |         counter.count += 1;
   |         ------- `counter` is borrowed here

Fixes

// Option 1: Arc + Mutex (thread-safe sharing)
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let counter_clone = Arc::clone(&counter);
    
    let handle = thread::spawn(move || {
        let mut count = counter_clone.lock().unwrap();
        *count += 1;
    });
    
    handle.join().unwrap();
    println!("Count: {}", *counter.lock().unwrap());
}

// Option 2: channels (message passing)
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
    
    thread::spawn(move || {
        tx.send(1).unwrap();
    });
    
    let count = rx.recv().unwrap();
    println!("Count: {}", count);
}

// Option 3: atomic type (AtomicI32)
use std::sync::atomic::{AtomicI32, Ordering};

fn main() {
    let counter = Arc::new(AtomicI32::new(0));
    let counter_clone = Arc::clone(&counter);
    
    let handle = thread::spawn(move || {
        counter_clone.fetch_add(1, Ordering::SeqCst);
    });
    
    handle.join().unwrap();
    println!("Count: {}", counter.load(Ordering::SeqCst));
}

6. Pattern cheat sheet: what to use when

Ownership vs borrowing

SituationApproachExample
Read-onlyImmutable reference &Tfn print(user: &User)
Mutation neededMutable reference &mut Tfn update(user: &mut User)
Need ownershipValue Tfn consume(user: User)
Copy typesCopy traitfn calc(x: i32)

Interior mutability

TypeUse caseThread-safe
Cell<T>Interior mutability for Copy typesNo
RefCell<T>Runtime borrow checksNo
Mutex<T>Shared mutation across threadsYes
RwLock<T>Read-heavy workloadsYes

Shared ownership

TypeUse caseThread-safe
Rc<T>Single-threaded sharingNo
Arc<T>Multi-threaded sharingYes
Weak<T>Break reference cyclesDepends

Common combinations

// Single-threaded: Rc + RefCell
use std::rc::Rc;
use std::cell::RefCell;

let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
let clone = Rc::clone(&shared);

shared.borrow_mut().push(4);
println!("{:?}", clone.borrow()); // [1, 2, 3, 4]

// Multi-threaded: Arc + Mutex
use std::sync::{Arc, Mutex};

let shared = Arc::new(Mutex::new(vec![1, 2, 3]));
let clone = Arc::clone(&shared);

thread::spawn(move || {
    clone.lock().unwrap().push(4);
});

7. Hands-on example: event system

C++-style (does not compile)

struct EventManager {
    listeners: Vec<Box<dyn Fn(&Event)>>,
}

impl EventManager {
    fn subscribe(&mut self, callback: impl Fn(&Event) + 'static) {
        self.listeners.push(Box::new(callback));
    }
    
    fn publish(&self, event: &Event) {
        for listener in &self.listeners {
            listener(event); // ✅ this part is fine
        }
    }
}

// Problem: what if a subscriber wants to mutate the EventManager?
fn main() {
    let mut mgr = EventManager { listeners: vec![] };
    
    mgr.subscribe(|event| {
        mgr.publish(event); // ❌ cannot borrow `mgr` as mutable
    });
}

Rust-style (Rc + RefCell)

use std::rc::Rc;
use std::cell::RefCell;

struct EventManager {
    listeners: Vec<Box<dyn Fn(&Event)>>,
}

fn main() {
    let mgr = Rc::new(RefCell::new(EventManager { listeners: vec![] }));
    let mgr_clone = Rc::clone(&mgr);
    
    mgr.borrow_mut().subscribe(Box::new(move |event| {
        // use mgr_clone inside the closure
        mgr_clone.borrow().publish(event);
    }));
}

8. Debugging tips

How to read the error message

error[E0502]: cannot borrow `x` as mutable because it is also borrowed as immutable
  --> src/main.rs:10:5
   |
8  |     let r = &x;
   |             -- immutable borrow occurs here
9  |     
10 |     x.push(1);
   |     ^^^^^^^^^ mutable borrow occurs here
11 |     println!("{}", r);
   |                    - immutable borrow later used here

Reading order:

  1. Error code: E0502 (conflicting borrows)
  2. Where it breaks: line 10
  3. Cause: immutable borrow on line 8
  4. Where it is still used: through line 11

rustc —explain

$ rustc --explain E0502

This error indicates that you are trying to borrow a value as mutable when it
is already borrowed as immutable.
...

Clippy

$ cargo clippy

warning: this `RefCell` Ref is held across an 'await' point
  --> src/main.rs:10:9
   |
10 |     let data = cell.borrow();
   |         ^^^^
   |
   = help: consider using a `Mutex` instead

Conclusion

The borrow checker feels restrictive at first, but it guarantees memory safety at compile time. With these cases you have:

  1. Learned to read error messages carefully
  2. Understood how ownership, borrowing, and lifetimes interact
  3. Picked up RefCell, Rc, Arc, and related patterns
  4. Started thinking in a way that differs from C++

Takeaway: Do not fight the borrow checker—learn Rust’s rules and lean into them.


FAQ

Q1. Is Rust harder than C++?

The early learning curve is steep, but once you are comfortable you can prevent entire classes of memory bugs.

Q2. Should I avoid overusing RefCell?

RefCell checks borrows at runtime and can panic. Prefer compile-time borrowing when possible.

Q3. Does Arc<Mutex<T>> hurt performance?

Yes, there is overhead. Use it only where needed; if reads dominate, consider RwLock.


  • Rust ownership guide
  • Rust borrowing and references
  • Rust lifetimes
  • Rust smart pointers

Practical checklist

Borrow checker troubleshooting

  • Read the full error message
  • Trace borrow scopes (where they start and end)
  • Decide whether you need ownership or only a reference
  • Separate mutable vs immutable borrows
  • Check lifetime relationships
  • Pick the right pattern (Rc, RefCell, Arc, Mutex)
  • Run Clippy for extra hints

Multi-threaded safety

  • Verify Send / Sync
  • Consider Arc + Mutex
  • Watch for deadlocks
  • Consider channels
  • Use atomics when appropriate

Keywords

Rust, Ownership, Borrow Checker, Borrowing, Lifetime, RefCell, Rc, Arc, Mutex, Case Study, Debugging, Error Resolution