Attribution: This article was based on content by @birdculture on hackernews.
Original: https://blog.polybdenum.com/2024/06/07/the-inconceivable-types-of-rust-how-to-make-self-borrows-safe.html

Introduction

Self-borrows — when an object returns references to its own fields or borrows from self across calls — are one of Rust’s most subtle design points. You want the ergonomics of referencing internal buffers or creating self-referential structures, but you must preserve Rust’s guarantees about ownership, aliasing, and moves. In this tutorial you will learn safe, practical patterns to express self-borrows in Rust, when to require Pin, and when you must fall back to tested unsafe abstractions or existing crates.

Background: Rust enforces memory safety with ownership, borrowing, and lifetimes; the borrow checker ensures references don’t outlive their data.

Credit: This tutorial builds on practical discussions such as birdculture (2024) and Rust core work on lifetimes and pinning (Klabnik & Nichols, 2019; Matsakis, 2018).

Key Takeaways

  • Prefer API designs that avoid returning long-lived borrows of self; return owned or short-lived values instead.
  • Use RefCell/Rc for single-threaded interior mutability and Pin to prevent moves when you need stable addresses.
  • For robust self-referential data, prefer well-tested crates (e.g., ouroboros/self_cell) or encapsulated unsafe code with strong invariants.

Estimated total time: ~60–90 minutes.

Prerequisites

You will need:

  • Rust toolchain (rustc and cargo) installed and up-to-date (Rust 1.60+ recommended).
  • Familiarity with Rust ownership, borrowing, and lifetimes.
  • Basic knowledge of Box, Rc, Arc, RefCell, and Mutex.
  • Optional: experience with async/await and Pin.

Estimated time: 5 minutes.

Setup/Installation

  1. Create a new cargo project:
    • cargo new self_borrow_tutorial
  2. Add dependencies for examples if needed:
    • In Cargo.toml you may add ouroboros or self_cell for advanced patterns.

Estimated time: 5–10 minutes.

Expected result: A new Rust project scaffolded and ready for code examples.

Step-by-step Instructions

We’ll go through numbered, incremental approaches from safest to more advanced.

1) Prefer owned or short-lived borrows (API design)

Goal: Avoid leaking borrows of self.

Code example 1 — return owned value instead of &self:

1
2
3
4
5
6
impl Config {
    // Return a cloned value rather than a reference to an internal string.
    pub fn database_url(&self) -> String {
        self.db_url.clone() // clones cheaply if you use Arc/String interning
    }
}

Comments: cloning moves ownership to the caller and avoids lifetime issues.

Estimated time: 5–10 minutes. Expected output: No borrow-checker errors; caller owns the value.

2) Interior mutability for single-threaded cases (RefCell + Rc)

Goal: Temporarily borrow internal fields without requiring &mut self on the API.

Code example 2 — using Rc<RefCell<T>>:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::rc::Rc;
use std::cell::RefCell;

struct Cache { inner: Rc<RefCell<Vec<u8>>> }

impl Cache {
    // Borrow internal buffer for short-lived use
    fn with_buf<F, R>(&self, f: F) -> R
    where F: FnOnce(&mut Vec<u8>) -> R {
        let mut b = self.inner.borrow_mut(); // runtime-checked mutable borrow
        f(&mut *b)
    }
}

Comments: RefCell enforces borrow rules at runtime; avoid returning RefMut across await points.

Estimated time: 10–15 minutes. Expected output: Code compiles and runtime panics if borrow rules are violated.

Background: RefCell provides interior mutability in single-threaded contexts via runtime checks.

3) Pinning to prevent moves for stable addresses

Goal: Ensure a value’s memory address won’t change while you hold a reference into it.

Code example 3 — simple Pin<Box<T>> usage and projection:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use std::pin::Pin;

struct Node { buffer: String, /* maybe other fields */ }

impl Node {
    // Safe only if `self` is pinned so `buffer`'s address is stable.
    fn borrow_buffer<'a>(self: Pin<&'a mut Self>) -> &'a str {
        &self.get_ref().buffer // projection: safe because pinned
    }
}

Comments: requiring Pin<&mut Self> signals to callers that the object must not be moved.

Estimated time: 15–20 minutes. Expected output: Compiles if called from a pinned value (e.g., Pin::new(&mut boxed)).

Citation: Pin semantics and guidance are discussed in Turon (2019).

4) Use well-tested crates for self-referential structs

Goal: Avoid writing unsafe self-referential logic; use a crate that encapsulates invariants.

Code example 4 — using ouroboros-style pattern (conceptual):

1
2
3
4
5
6
7
// Pseudocode: actual crate API differs; this shows intent.
ouroboros::self_referencing! {
    struct MyBlob {
        data: Vec<u8>,
        slice: &'this [u8] = &data[..],
    }
}

Comments: crates like ouroboros and self_cell create safe constructors that return types with internal references that remain valid.

Estimated time: 20–30 minutes to read docs and integrate. Expected output: A self-referential struct that you can use without manual unsafe code.

Citation: Practical solutions for self-referential data are available in community crates (ouroboros, self_cell).

5) Localized unsafe with UnsafeCell when necessary

Goal: Use UnsafeCell only when you must and wrap it in a safe API with documented invariants.

Code example 5 — controlled unsafe pattern:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
use std::cell::UnsafeCell;

struct SharedBuf {
    buf: UnsafeCell<Vec<u8>>,
}

// implement safe accessors ensuring aliasing/mutation rules
impl SharedBuf {
    fn borrow_mut<'a>(&'a self) -> &'a mut Vec<u8> {
        unsafe { &mut *self.buf.get() } // unsafe: caller must ensure no aliases
    }
}

Comments: You must prove that borrow_mut cannot be used concurrently in a way that violates aliasing; prefer using RefCell/locks or pinning.

Estimated time: 20–45 minutes to implement correct invariants and tests. Expected output: Compiles, but requires careful review and tests; misuse can produce undefined behavior.

Citation: Unsafe interior mutability is the basis of many safe abstractions in Rust (Klabnik & Nichols, 2019).

6) Async/await and cross-boundary borrows

Goal: Avoid borrowing &self across await points unless the borrow is 'static or pinned appropriately.

Practical rule: Do not return &self from an async fn across an .await. Instead, clone or move the necessary data into the future using Arc or owned values.

Code example 6 — move data into the async block:

1
2
3
4
async fn process_blob(blob: Arc<MyBlob>) {
    let local = blob.clone(); // clone Arc, no self-borrow across await
    async { /* use local across .await */ }.await;
}

Estimated time: 10–15 minutes. Expected output: No borrow errors crossing await points.

Citation: NLL and async interactions are part of ongoing borrow-checker evolution (Matsakis, 2018).

Common Issues & Troubleshooting

  • Borrow-checker rejects returning &self that outlives a method:

    • Fix: return owned values, use Pin, or redesign the API.
  • Panics with RefCell at runtime (already borrowed):

    • Fix: restructure code to avoid nested borrows or use Ref/RefMut properly.
  • Use-after-move when storing self-references:

    • Fix: require Pin to prevent moves or avoid self-referential storage.
  • Unsafe code compiles but causes UB:

    • Fix: add tests, assertions, and code comments documenting invariants; prefer safe wrapper crates.

Estimated time for debugging common issues: 10–30 minutes per issue depending on complexity.

Next Steps / Further Learning

  • Read The Rust Programming Language (Klabnik & Nichols, 2019) for ownership fundamentals.
  • Explore Pin and interior mutability posts by Rust core contributors (Turon, 2019; Matsakis, 2018).
  • Evaluate crates: ouroboros, self_cell, and pin-project for safe abstractions.
  • When writing unsafe wrappers, add fuzz/ASAN testing and clear documentation of invariants.

Estimated time: ongoing; 1–3 hours to read and experiment.

Additional Resources

  • Klabnik & Nichols (2019). The Rust Programming Language.
  • Matsakis (2018). Work on non-lexical lifetimes and borrow checker improvements.
  • birdculture (2024). “The inconceivable types of Rust: How to make self-borrows safe” — practical perspectives and examples.
  • ouroboros, self_cell, pin-project crates for real-world patterns.

Citations:

  • Klabnik & Nichols (2019)
  • Matsakis (2018)
  • Turon (2019)
  • birdculture (2024)

Estimated time to follow resources: variable.

Common Pitfalls (summary)

  • Returning references to fields that can be moved.
  • Borrowing across .await boundaries.
  • Using UnsafeCell without a documented invariant.
  • Forgetting Pin when you need a stable address.

Expected overall outcome: After completing these steps, you will be able to choose an appropriate pattern for self-borrows: prefer redesigns and owned returns for ergonomics, use RefCell/Rc or Arc+locks for interior mutability, require Pin when addresses must be stable, and rely on established crates or well-tested unsafe wrappers for complex self-referential structures.

Further learning will deepen your ability to balance safety, ergonomics, and performance in Rust APIs that need to expose internal references.

References