Skip to content

Rust Ownership & Borrowing Deep Dive — Advanced Rules & Patterns

DodaTech Updated 2026-06-23 8 min read

In this tutorial, you'll learn about Rust Ownership & Borrowing Deep Dive. We cover key concepts, practical examples, and best practices.

Explore advanced Rust ownership and borrowing patterns: interior mutability, borrow splitting, non-lexical lifetimes, and variance in safe systems code.

What You'll Learn

In this tutorial, you'll go beyond the basics of Rust ownership to master interior mutability with RefCell, borrow splitting across struct fields, subtyping variance, non-lexical lifetimes (NLL), and advanced patterns for designing APIs that satisfy the borrow checker without runtime overhead.

Why It Matters

The ownership rules are simple at first but become subtle in real code. Shared mutable state, self-referential structs, and cyclic data patterns challenge the borrow checker daily. Mastering advanced ownership patterns lets you write idiomatic Rust that compiles the first time, without resorting to unsafe or excessive cloning.

Real-World Use

The lock_api crate uses interior mutability patterns to expose safe mutex APIs. Servo's parallel layout engine uses borrow splitting to process DOM nodes concurrently. Durga Antivirus Pro's real-time scanner uses RefCell-based caches to share signature tables across scan threads without data races.

flowchart TD
    subgraph "Borrow Types"
        A[Reference] --> B{&T or &mut T?}
        B -->|&T shared| C[Read-only, many allowed]
        B -->|&mut T exclusive| D[Read+write, one allowed]
    end
    subgraph "Interior Mutability"
        E[RefCell] --> F{Borrow rules enforced}
        F -->|at runtime| G[panic on violation]
    end
    subgraph "Borrow Splitting"
        H[Struct with fields] --> I[Borrow field A mut]
        H --> J[Borrow field B mut]
        I & J --> K[Allowed: disjoint fields]
    end
â„šī¸ Info

Prerequisites: Rust Ownership basics and Borrowing & References. Understanding lifetimes helps.

Borrow Splitting Across Struct Fields

One of the most common borrow-checker frustrations is trying to borrow two fields of a struct mutably at the same time. The compiler knows these are disjoint, but naive code fails:

struct Graph {
    nodes: Vec<String>,
    edges: Vec<(usize, usize)>,
}

impl Graph {
    fn add_node_and_edge(&mut self, node: String, edge: (usize, usize)) {
        // Attempting to borrow self.nodes and self.edges simultaneously
        self.nodes.push(node);
        self.edges.push(edge); // Compiles because fields are disjoint
    }

    fn reborrow_split(&mut self, idx: usize) -> Option<&str> {
        let node = self.nodes.get(idx)?;
        // Cannot borrow self.edges here because self.nodes is already borrowed
        // Solution: use temporary extraction
        let _edge_count = self.edges.len();
        Some(node.as_str())
    }
}

fn main() {
    let mut g = Graph {
        nodes: vec!["A".into(), "B".into()],
        edges: vec![(0, 1)],
    };
    g.add_node_and_edge("C".into(), (1, 2));
    println!("Nodes: {:?}", g.nodes);
}

Expected output:

Nodes: ["A", "B", "C"]

The method reborrow_split fails to compile because self.nodes.get(idx) borrows self immutably, then self.edges.len() tries to borrow self immutably again — actually this should work since both are immutable. The real issue is if we try a mutable borrow after an immutable one. Let's see a trickier case:

struct Buffer {
    data: Vec<u8>,
    cursor: usize,
}

impl Buffer {
    fn read_byte(&mut self) -> Option<u8> {
        if self.cursor < self.data.len() {
            let b = self.data[self.cursor];
            self.cursor += 1;
            Some(b)
        } else {
            None
        }
    }
}

fn main() {
    let mut buf = Buffer {
        data: vec![10, 20, 30, 40],
        cursor: 0,
    };
    while let Some(b) = buf.read_byte() {
        print!("{} ", b);
    }
}

Expected output:

10 20 30 40

The read_byte method works because field accesses are evaluated one at a time, not held across the function body simultaneously.

Interior Mutability with RefCell

RefCell<T> moves borrow checking from compile time to runtime. Use it when you need mutable access to data behind an immutable reference:

use std::cell::RefCell;

struct Cache {
    store: RefCell<Vec<String>>,
}

impl Cache {
    fn new() -> Self {
        Cache {
            store: RefCell::new(Vec::new()),
        }
    }

    fn lookup(&self, key: &str) -> Option<String> {
        // Immutable method, but we need to mutate the cache
        let mut cache = self.store.borrow_mut();
        if let Some(pos) = cache.iter().position(|s| s == key) {
            // Move to front (LRU behavior)
            let val = cache.remove(pos);
            cache.push(val.clone());
            Some(val)
        } else {
            cache.push(key.to_string());
            None
        }
    }
}

fn main() {
    let cache = Cache::new();
    println!("Lookup 'rust': {:?}", cache.lookup("rust"));
    println!("Lookup 'rust': {:?}", cache.lookup("rust"));
    println!("Lookup 'go': {:?}", cache.lookup("go"));
    println!("Cache size: internal");
}

Expected output:

Lookup 'rust': None
Lookup 'rust': Some("rust")
Lookup 'go': None
Cache size: internal
âš ī¸ Warning

Borrow() vs borrow_mut(): You can have multiple borrow() calls or one borrow_mut(), but not both simultaneously at runtime. Violating this causes a panic, not a compile error.

Non-Lexical Lifetimes (NLL)

Before Rust 2018, borrows lasted until the end of the scope. NLL allows borrows to be released as soon as they are last used:

fn nll_example() {
    let mut s = String::from("hello");
    
    let r = &s;        // immutable borrow starts
    println!("{}", r); // last use of r — borrow ends here
    
    let m = &mut s;    // mutable borrow — allowed because r is done
    m.push_str(" world");
    println!("{}", m);
}

fn main() {
    nll_example();
}

Expected output:

hello
hello world

Without NLL (Rust 2015), the immutable borrow would last until the end of the function, preventing the mutable borrow. NLL made Rust significantly more ergonomic.

Common Errors

1. Borrowing field after partial move

struct Person { name: String, age: u32 }
let p = Person { name: "Alice".into(), age: 30 };
let _name = p.name;
// println!("{}", p.name); // ERROR: partially moved
println!("{}", p.age);     // OK: age was not moved

2. Mutable borrow after immutable borrow

let mut v = vec![1, 2, 3];
let r = &v[0];
v.push(4); // ERROR: cannot borrow as mutable while immutable borrowed
println!("{}", r);

3. Temporary value dropped

let r: &i32;
{
    let x = 42;
    r = &x; // ERROR: x does not live long enough
}
println!("{}", r);

4. Double mutable borrow

let mut x = 5;
let a = &mut x;
let b = &mut x; // ERROR: cannot borrow as mutable more than once
*a += 1;

5. RefCell borrow panicking at runtime

use std::cell::RefCell;
let cell = RefCell::new(10);
let _a = cell.borrow_mut();
let _b = cell.borrow_mut(); // Panics: already borrowed mutably

Practice Questions

1. What is borrow splitting and when would you use it? Borrow splitting is borrowing different fields of a struct separately. It works because the borrow checker tracks field-level borrows, allowing multiple mutable borrows as long as they target disjoint fields.

2. What is the difference between RefCell and Cell? Cell<T> uses copy semantics (works only with Copy types) and never borrows. RefCell<T> provides runtime borrow checking via borrow()/borrow_mut() and works with any type.

3. How do NLLs improve Rust code? Non-lexical lifetimes end borrows at the last point of use rather than at the end of the scope, allowing subsequent mutable borrows and reducing unnecessary borrow-checker friction.

4. What is subtyping variance in Rust? Variance describes how lifetimes propagate through types: &'a T is covariant in 'a and T, &'a mut T is invariant in T (to prevent aliasing), and fn(T) -> U is contravariant in T.

5. Challenge: Design a RefCell-based double-ended queue that supports O(1) push/pop at both ends using interior mutability, without using std::collections::VecDeque. Implement push_front, push_back, pop_front, pop_back.

Mini Project: Arena Allocator with Borrow Splitting

use std::cell::RefCell;

#[derive(Debug)]
struct Arena<T> {
    items: RefCell<Vec<T>>,
}

impl<T> Arena<T> {
    fn new() -> Self {
        Arena {
            items: RefCell::new(Vec::new()),
        }
    }

    fn alloc(&self, val: T) -> usize {
        let mut items = self.items.borrow_mut();
        let idx = items.len();
        items.push(val);
        idx
    }

    fn get(&self, idx: usize) -> Option<std::cell::Ref<T>> {
        let items = self.items.borrow();
        if idx < items.len() {
            // Use Ref::map for projecting into Ref
            Some(std::cell::Ref::map(items, |v| &v[idx]))
        } else {
            None
        }
    }
}

fn main() {
    let arena = Arena::new();
    let a = arena.alloc(10);
    let b = arena.alloc(20);
    let c = arena.alloc(30);
    println!("Arena[{}] = {:?}", a, arena.get(a).map(|r| *r));
    println!("Arena[{}] = {:?}", b, arena.get(b).map(|r| *r));
    println!("Arena[{}] = {:?}", c, arena.get(c).map(|r| *r));
}

Expected output:

Arena[0] = Some(10)
Arena[1] = Some(20)
Arena[2] = Some(30)

FAQ

Can I have two mutable references to different fields of a struct at the same time?

Yes. The Rust compiler tracks borrows at the field level. You can mutably borrow self.field_a and self.field_b simultaneously because they are provably disjoint. This is called borrow splitting.

When should I use RefCell vs Mutex?

RefCell is single-threaded interior mutability. Mutex is the thread-safe equivalent. Use RefCell for single-threaded contexts (e.g., caching in a UI component) and Mutex when data is shared across threads. Both enforce the same borrowing rules, but Mutex at the OS level and RefCell at the language level.

What is the 'cannot borrow as immutable because it is also borrowed as mutable' error?

This means you're trying to read data through a shared reference while a mutable reference exists. The mutable reference has exclusive access. Either reduce the scope of the mutable borrow or restructure your code to avoid overlapping borrows.

Rust Ownership Basics
Borrowing & References
Lifetimes
Unsafe Rust

What's Next

Deepen your understanding with Lifetimes to master how references stay valid, then explore Unsafe Rust for cases where you must bypass the borrow checker.

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro