Introduction
If you are learning Rust, you have undoubtedly encountered the frustrating errors from the Borrow Checker. Many beginners feel disheartened: why does the compiler reject my code when the logic seems correct?
This article will delve into the core mechanisms of Rust’s Borrow Checker, helping you understand the design philosophy behind it and providing a series of practical solutions. Whether you are a Rust novice or an experienced developer, you will find effective strategies to deal with the Borrow Checker.
Three Core Facts of Rust’s Ownership System
1. Tree-like Ownership Structure
In Rust’s ownership system, an object can own multiple child objects or no child objects, but it must be owned by exactly one parent object. The ownership relationships form a tree structure.
2. Exclusivity of Mutable Borrowing
If there is a mutable borrow of an object, no other borrows of that object can exist simultaneously. Mutable borrowing is exclusive.
3. Contagious Borrowing
If you borrow a child object, you will indirectly borrow its parent object (and the parent’s parent, and so on). Mutably borrowing a wheel of a car will lead to borrowing the entire car, preventing another wheel from being borrowed. This can be avoided through split borrowing, but it only works within a single scope.
Detailed Explanation of Contagious Borrowing Issues
The contagious borrowing issue is a common source of frustration for Rust beginners, especially when dealing with structs.
Typical Problem Case
Let’s look at a simple example:
pub struct Parent {
total_score: u32,
children: Vec<Child>
}
pub struct Child {
score: u32
}
impl Parent {
fn get_children(&self) -> &Vec<Child> {
&self.children
}
fn add_score(&mut self, score: u32) {
self.total_score += score;
}
}
fn main() {
let mut parent = Parent{
total_score: 0,
children: vec![Child{score: 2}]
};
// Compilation error!
for child in parent.get_children() {
parent.add_score(child.score);
}
}
The compiler will report the error:
error[E0502]: cannot borrow `parent` as mutable because it is also borrowed as immutable
Why Does This Error Occur?
Although <span>get_children()</span> only borrows the <span>children</span> field, and <span>add_score()</span> only modifies the <span>total_score</span> field, they operate on different data, but the Borrow Checker considers them overlapping. The reasons are:
- In
<span>fn get_children(&self) -> &Vec<Child></span>, although the method body only borrows the<span>children</span>field, the return value indirectly borrows the entire<span>self</span>, not just a field. - In
<span>fn add_score(&mut self, score: u32)</span>, the parameter<span>&mut self</span>borrows the entire<span>Parent</span>, not just the<span>total_score</span>field. - Inside the loop, the immutable borrow of the entire
<span>Parent</span>and the mutable borrow overlap in their lifetimes.
Why Does Inlining Work?
If we inline these methods directly:
fn main() {
let mut parent = Parent{
total_score: 0,
children: vec![Child{score: 2}]
};
// Compiles successfully!
for child in &parent.children {
let score = child.score;
parent.total_score += score;
}
}
This code compiles successfully because the compiler performs split borrowing: it sees separate borrows of individual fields within a single function (<span>main()</span>), thus avoiding contagious borrowing.
Deep Reasons
The Borrow Checker operates in a local manner: when it sees a function call, it only checks the function signature and does not analyze the internal code of the function.
This design is due to:
- If it checks the method body, it would require whole-program analysis, which is both complex and slow.
- It increases decoupling. You do not need to worry that changes in the function body of a library will cause your code to fail to compile.
- It supports the need for dynamic linking.
However, this leads to information loss in the function signature: borrowing information becomes coarse-grained. The type system cannot express “only borrowing one field”; it can only express “borrowing the entire object”.
Practical Solutions to Contagious Borrowing
Solution 1: Remove Unnecessary Getters and Setters
The simplest solution is to make the fields public (or module-private):
pub struct Parent {
pub total_score: u32, // Directly public field
pub children: Vec<Child>
}
fn main() {
let mut parent = Parent{
total_score: 0,
children: vec![Child{score: 2}]
};
// Now it compiles successfully
for child in &parent.children {
parent.total_score += child.score;
}
}
This allows split borrowing in external code. If encapsulation is needed, you can use the ID/handle scheme introduced later.
Solution 2: Defer Mutation
Transform mutation operations into data, place them in a command queue, and execute them collectively:
pub enum Command {
AddTotalScore(u32),
// More command types can be added
}
fn main() {
let mut parent = Parent{
total_score: 0,
children: vec![Child{score: 2}]
};
let mut commands: Vec<Command> = Vec::new();
// Generate commands with only immutable borrows
for child in parent.get_children() {
commands.push(Command::AddTotalScore(child.score));
}
// Execute commands with only mutable borrows
for command in commands {
match command {
Command::AddTotalScore(num) => {
parent.add_score(num);
}
};
}
}
Defer mutation is not just a “workaround for the Borrow Checker”; it has other benefits:
- Changes can be serialized, sent over the network, or saved to disk.
- Changes can be inspected for debugging and logging.
- Post-processing can be performed on the command list, such as sorting or filtering.
- It is easier to implement parallel processing.
Solution 3: Avoid In-Place Mutation
Adopt the functional programming idea of “changing by rebuilding”: all data is immutable, and when a change is needed, create a new version.
use rpds::HashTrieMap;
let mut map: HashTrieMap<i32, i32> = HashTrieMap::new();
map = map.insert(2, 3);
// Can modify the original map while iterating over a clone
for (k, v) in &map.clone() {
if *v > 2 {
map = map.insert(*k * 2, *v / 2);
}
}
Persistent data structures improve the efficiency of “changing by rebuilding” through structural sharing.
Solution 4: Manual Index Management
Avoid using iterators and manage loop indices manually:
fn main() {
let mut parent = Parent{
total_score: 0,
children: vec![Child{score: 2}]
};
let mut i: usize = 0;
while i < parent.get_children().len() {
// Temporary borrow, stop borrowing immediately after getting data
let score = parent.get_children()[i].score;
parent.add_score(score);
i += 1;
}
}
This way, each borrow of <span>get_children()</span> is very brief, stopping borrowing right after copying the <span>score</span> field.
Using ID/Handles Instead of Borrowing
Data-Oriented Design
The limitations of the Borrow Checker encourage us to adopt a more data-oriented design:
- Pack data into contiguous arrays (rather than objects managed by a scattered allocator).
- Use handles (like array indices) or IDs instead of references.
- Decouple object IDs from memory addresses.
Slotmap Example
<span>slotmap</span> is a popular arena allocator implementation:
use slotmap::{SlotMap, new_key_type};
new_key_type! {
pub struct EntityKey;
}
pub struct Entity {
pub name: String,
pub position: (f32, f32)
}
fn main() {
let mut entities = SlotMap::new();
// Insert entities, returning Copy keys
let player_key = entities.insert(Entity {
name: "Player".to_string(),
position: (0.0, 0.0)
});
let enemy_key = entities.insert(Entity {
name: "Enemy".to_string(),
position: (10.0, 10.0)
});
// Can access multiple entities simultaneously, unaffected by the Borrow Checker
if let Some(player) = entities.get_mut(player_key) {
player.position.0 += 1.0;
}
if let Some(enemy) = entities.get(enemy_key) {
println!("Enemy at: {:?}", enemy.position);
}
}
Important considerations when using IDs/handles:
- The Borrow Checker no longer ensures that IDs/handles point to live objects.
- Every data access may fail (there exists an equivalent of “use-after-free”).
- The arena will still be affected by contagious borrowing issues.
Callbacks and Circular References
Problem Scenario
In dynamic responsive systems like GUIs and games, callback functions are common. However, if a callback function needs to capture a reference to a parent component, it can create a circular reference: the parent references the child, the child references the callback, and the callback references the parent.
Solution 1: Pass by Parameter
Do not let the callback capture mutable data; instead, pass it as a parameter:
struct ParentState {
counter: u32
}
struct ParentComponent {
button: ChildButton,
state: ParentState
}
struct ChildButton {
// Callback accepts ParentState as a parameter
on_click: Option<Box<dyn Fn(&mut ParentState) -> ()>>
}
fn main() {
let mut parent = ParentComponent {
button: ChildButton { on_click: None },
state: ParentState { counter: 0 }
};
// Callback does not capture state, but receives it as a parameter
parent.button.on_click = Some(Box::new(|state| {
state.counter += 1;
}));
// Split borrow of two fields
parent.button.on_click.as_ref().unwrap()(&mut parent.state);
assert!(parent.state.counter == 1);
}
Solution 2: Events as Data
Instead of using callbacks, convert events into data and use IDs to reference objects:
use uuid::Uuid;
enum Event {
ButtonClicked { button_id: Uuid }
// Other event types...
}
struct ParentComponent {
id: Uuid,
button: ChildButton,
counter: u32
}
struct ChildButton {
id: Uuid
}
impl ParentComponent {
fn handle_event(&mut self, event: Event) -> bool {
match event {
Event::ButtonClicked { button_id }
if button_id == self.button.id => {
self.counter += 1;
true
}
_ => false
}
}
}
The advantages of this approach include:
- Events can be serialized, transmitted over the network, or saved to disk.
- Events can be inspected for debugging and logging.
- Post-processing can be performed on the event list.
- It is easier to implement parallel processing.
Interior Mutability
Cell and RefCell
When the exclusivity of immutable borrowing is too strict, interior mutability can be used:
use std::cell::RefCell;
struct Counter {
value: RefCell<u32>
}
impl Counter {
fn increment(&self) { // Note: here is &self, not &mut self
*self.value.borrow_mut() += 1;
}
fn get(&self) -> u32 {
*self.value.borrow()
}
}
fn main() {
let counter = Counter {
value: RefCell::new(0)
};
counter.increment();
counter.increment();
assert_eq!(counter.get(), 2);
}
Considerations:
<span>RefCell</span>checks borrowing rules at runtime, violating rules will panic.<span>Cell</span>is suitable for simple types that implement Copy.<span>Mutex</span>and<span>RwLock</span>are used in multithreaded scenarios.
QCell
<span>QCell</span> provides an improved interior mutability solution:
use qcell::{QCell, QCellOwner};
fn main() {
let owner = QCellOwner::new();
let cell1 = QCell::new(&owner, 42);
let cell2 = QCell::new(&owner, 100);
// Access through owner
*cell1.rw(&mut owner) += 1;
let sum = *cell1.ro(&owner) + *cell2.ro(&owner);
assert_eq!(sum, 143);
}
<span>QCell</span> advantages include:
- Repeated borrowing results in compile-time errors instead of runtime panics.
- Low runtime overhead (only checks ID matching).
- Can be used in multithreaded scenarios (with
<span>RwLock<QCellOwner></span>).
Traps of Asynchronous Rust
Blocking Scheduler Threads
Asynchronous runtimes like Tokio use cooperative scheduling. Using the standard library’s <span>std::thread::sleep</span> or <span>std::sync::Mutex</span> will block the scheduler thread:
// Error: will block the entire runtime
async fn bad_example() {
std::thread::sleep(Duration::from_secs(1)); // Blocking!
let _guard = std::sync::Mutex::new(0).lock(); // Blocking!
}
// Correct: use the asynchronous version
async fn good_example() {
tokio::time::sleep(Duration::from_secs(1)).await; // Cooperative pause
let _guard = tokio::sync::Mutex::new(0).lock().await; // Asynchronous lock
}
Cancellation Safety
Rust’s <span>Future</span> can be “canceled” if discarded or leaked. Special care is needed when using <span>tokio::select!</span>:
use tokio::sync::mpsc;
// ❌ Unsafe: may lose messages
async fn unsafe_example(rx: &mut mpsc::Receiver<i32>) {
loop {
tokio::select! {
Some(msg) = rx.recv() => {
// If another branch completes first, msg will be lost
process(msg).await;
}
_ = tokio::time::sleep(Duration::from_secs(1)) => {
println!("Timeout");
}
}
}
}
Conclusion
Although Rust’s Borrow Checker is strict, its design has profound reasons. By understanding its core mechanisms and mastering various coping strategies, we can write code that is both safe and efficient.
Key points:
- Understand Contagious Borrowing: Borrowing propagates up the ownership tree, and getter/setter methods can break split borrowing.
- Choose the Right Solution:
- Simple scenarios: Remove getters/setters and make fields public directly.
- Complex changes: Use deferred mutation or event-driven approaches.
- Need sharing: Use IDs/handles or reference counting.
- Need flexibility: Use interior mutability (but with caution).
<span>Arc<Mutex<T>></span> and <span>RefCell</span> are not panaceas and should only be used when truly necessary.Remember, fighting with the Borrow Checker often means your design may need adjustment. Learn to think from Rust’s perspective, and you will find that many seemingly restrictive rules actually guide us to write better code.
References
- How to Avoid Fighting Rust Borrow Checker: https://qouteall.fun/qouteall-blog/2025/How%20to%20Avoid%20Fighting%20Rust%20Borrow%20Checker
Book Recommendations
The second edition of “The Rust Programming Language” is an authoritative learning resource written by the Rust core development team and translated by members of the Chinese Rust community. It is suitable for all software developers who wish to evaluate, get started, improve, and study the Rust language, and is regarded as essential reading for Rust development work.
This book introduces the fundamental concepts of Rust language to unique practical tools, covering advanced concepts such as ownership, traits, lifetimes, and safety guarantees, as well as practical tools like pattern matching, error handling, package management, functional features, and concurrency mechanisms. The book includes three complete project development case studies, guiding readers to develop Rust practical projects from scratch.
Notably, this book has been updated to include content from the Rust 2021 edition, meeting the systematic learning needs of beginners and serving as a reference guide for experienced developers, making it the best entry point for building solid Rust skills.
Recommended Reading
-
Rust: The Performance King Sweeping C/C++/Go?
-
A C++ Perspective from Rust Developers: Revealing Pros and Cons
-
Rust vs Zig: The Battle of Emerging System Programming Languages
-
Essential Design Patterns for Asynchronous Rust: Enhance Your Code’s Performance and Maintainability