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
- Case 1: “cannot move out of borrowed content”
- Case 2: “cannot borrow as mutable more than once”
- Case 3: “lifetime may not live long enough”
- Case 4: “cannot return reference to local variable”
- Case 5: Shared state across threads
- Pattern cheat sheet: what to use when
- 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?
usersis an immutable borrow (&Vec<User>)user.nameis aString(owned type)let name = user.nametries 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.usersis the first mutable borrowself.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
| Situation | Approach | Example |
|---|---|---|
| Read-only | Immutable reference &T | fn print(user: &User) |
| Mutation needed | Mutable reference &mut T | fn update(user: &mut User) |
| Need ownership | Value T | fn consume(user: User) |
Copy types | Copy trait | fn calc(x: i32) |
Interior mutability
| Type | Use case | Thread-safe |
|---|---|---|
Cell<T> | Interior mutability for Copy types | No |
RefCell<T> | Runtime borrow checks | No |
Mutex<T> | Shared mutation across threads | Yes |
RwLock<T> | Read-heavy workloads | Yes |
Shared ownership
| Type | Use case | Thread-safe |
|---|---|---|
Rc<T> | Single-threaded sharing | No |
Arc<T> | Multi-threaded sharing | Yes |
Weak<T> | Break reference cycles | Depends |
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:
- Error code:
E0502(conflicting borrows) - Where it breaks: line 10
- Cause: immutable borrow on line 8
- 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:
- Learned to read error messages carefully
- Understood how ownership, borrowing, and lifetimes interact
- Picked up RefCell, Rc, Arc, and related patterns
- 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.
Related posts
- 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