Question

Managing Object Lifetimes Safely in Rust Garbage Collector APIs

rustgarbage-collectionallocationlifetime

Question

I am building a simple mark-and-compact garbage collector in Rust. The internal implementation is acceptable, but the public API is fundamentally unsafe, and I want to redesign it so object validity is expressed more clearly through the type system.

Here is the current shape of the API:

/// Describes the internal structure of a managed object.
pub struct Tag {
    // ...
}

/// An unmanaged pointer to a managed object.
pub type Pointer = *mut usize;

/// Mapping from old object addresses to new object locations.
pub type Adjust = std::collections::BTreeMap<usize, Pointer>;

/// Mark this object and anything it points to as live.
pub unsafe fn survive(ptr: Pointer);

pub struct Heap {
    // ...
}

impl Heap {
    pub fn new() -> Heap {
        // ...
        unimplemented!()
    }

    /// Allocate an object with the specified structure.
    pub fn allocate(&mut self, tag: Tag) -> Pointer {
        // ...
        unimplemented!()
    }

    /// Move all live objects from `heap` into `self`.
    pub unsafe fn reallocate(&mut self, heap: Heap) -> Adjust {
        // ...
        unimplemented!()
    }
}

There are two important rules I want the API to model:

  • All pointers to objects allocated in a Heap become invalid when that heap is merged into another heap.
  • reallocate returns an Adjust whose values are valid pointers into self.

I considered replacing raw pointers with a lifetime-based handle type:

use std::collections::BTreeMap;
use std::marker::PhantomData;
use std::sync::atomic::AtomicUsize;

#[derive(Copy, Clone)]
pub struct Object<'a> {
    ptr: *mut AtomicUsize,
    mark: PhantomData<&'a usize>,
}

impl<'a> Object<'a> {
    pub fn survive(self) {
        // ...
    }
}

pub type Adjust<'a> = BTreeMap<usize, Object<'a>>;

pub struct Heap {
    // ...
}

pub struct Allocator<'a> {
    // ...
}

impl Heap {
    fn allocator<'a>(&'a self) -> Allocator<'a> {
        // ...
        unimplemented!()
    }

    // This does not work well:
    // fn allocate<'a>(&'a mut self, tag: Tag) -> Object<'a>;
    // fn reallocate<'a>(&'a mut self, heap: Heap) -> Adjust<'a>;
    //
    // Because borrowing `self` mutably for `'a` prevents creating
    // multiple object handles conveniently.
}

impl<'a> Allocator<'a> {
    /// The resulting objects are tied to the heap, not to the allocator.
    fn allocate(&mut self, tag: Tag) -> Object<'a> {
        // ...
        unimplemented!()
    }

    fn reallocate(&mut self, heap: Heap) -> Adjust<'a> {
        // ...
        unimplemented!()
    }
}

Is this kind of design correct for expressing object lifetime and invalidation in Rust? If not, what should be changed?

Short Answer

By the end of this page, you will understand how Rust lifetimes relate to ownership and borrowing, why they cannot directly express moving garbage-collected objects, and how safer APIs are usually built with handles, borrowing scopes, or indirection instead of raw pointers. You will also see practical Rust patterns for designing a heap API that prevents invalid references after compaction or heap merging.

Concept

Rust lifetimes describe how long references are allowed to be used, but they do not describe arbitrary semantic validity rules such as “this pointer becomes invalid after a heap compaction.” That is the core issue behind this question.

In a moving garbage collector, objects can change address. If your API exposes raw pointers, then compaction or heap merging can make previously returned pointers invalid. Rust's type system can help, but only if the API exposes values whose validity is tied to Rust's borrowing rules.

The key idea

A lifetime like Object<'a> only says:

  • this value is connected to something borrowed for 'a
  • the compiler will not let it outlive that borrow

It does not automatically mean:

  • the pointed-to object will remain at the same memory address
  • the object cannot be moved internally
  • the handle is still valid after some unrelated mutation

That means a type like this:

pub struct Object<'a> {
    ptr: *mut AtomicUsize,
    mark: PhantomData<&'a usize>,
}

only pretends to be lifetime-aware. The compiler sees the PhantomData, but the raw pointer itself is still just a raw pointer. If the heap compacts and moves the object, the pointer becomes stale. The lifetime does not fix that.

Why this matters

In real Rust APIs, if data may move or be invalidated by later operations, developers usually avoid returning direct pointers. Instead they return one of these:

  • indices into a table
  • stable IDs or generations
  • handles that require access through the heap
  • references only within a temporary borrow scope

These patterns let the API control when an object may be accessed and whether it is still valid.

What lifetimes are good at here

Lifetimes can still help model rules like:

  • “you may only access objects while the heap is borrowed”
  • “you cannot mutate the heap in a way that relocates objects while live references exist”
  • “an object reference is only valid during a collection session”

So the best design is often:

  1. avoid exposing raw pointers directly
  2. expose handles or IDs for long-term identity
  3. expose actual references only during short, controlled borrows

That is much closer to how Rust wants you to encode safety.

Mental Model

Think of the heap like a warehouse and objects like boxes inside it.

  • A raw pointer is like writing down the exact shelf location of a box.
  • A moving GC can reorganize the warehouse and move boxes to different shelves.
  • After reorganization, your written shelf location may point to the wrong place.

A Rust lifetime is not a magical tracker for moving boxes. It is more like a rule saying:

“You may ask the warehouse manager for a box location only while you are standing at the desk.”

If the warehouse reorganizes after that, the old shelf note is no longer trustworthy.

A safer design is to give users a ticket number instead of a shelf location.

  • The ticket number stays meaningful.
  • The warehouse manager looks up the current shelf location when needed.
  • If the box was removed or moved, the manager can detect that.

In Rust terms:

  • pointer = direct address, fragile when objects move
  • handle/id = stable identity, safer across internal movement
  • borrowed reference = temporary access while the owner guarantees stability
Take Quiz

Syntax and Examples

A fragile design: returning raw pointers

pub type Pointer = *mut usize;

impl Heap {
    pub fn allocate(&mut self, tag: Tag) -> Pointer {
        // returns direct address into the heap
        unimplemented!()
    }
}

This is unsafe for a moving collector because later compaction may relocate the object.

A safer design: return a handle

Instead of exposing the address, expose a stable object identifier.

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ObjectId(usize);

pub struct Heap {
    objects: Vec<String>,
}

impl Heap {
    pub fn new() -> Self {
        Self { objects: Vec::new() }
    }

    pub fn allocate(&mut self, value: String) -> ObjectId {
        let id = ObjectId(self.objects.len());
        self.objects.push(value);
        id
    }

    pub fn get(&self, id: ObjectId) -> Option<&str> {
        self.objects.get(id.0).map(String::as_str)
    }
}

fn main() {
    let mut heap = Heap::new();
    let id = heap.allocate("hello".to_string());

    println!("{:?}", heap.get(id));
}

Why this is better

  • ObjectId is not a memory address.
  • The heap can move objects internally.
  • Access goes through the heap, which controls safety.

If temporary references are needed

You can borrow through the heap for a limited scope:

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ObjectId(usize);

pub struct Heap {
    objects: Vec<String>,
}

impl Heap {
    pub fn get<'a>(&'a self, id: ObjectId) -> Option<&'a str> {
        self.objects.get(id.0).map(String::as_str)
    }
}

Here, the returned reference is tied to &self, which means it cannot outlive the heap borrow.

A pattern for relocation

If a collection phase moves objects, return a mapping from old handle to new handle, not old pointer to new pointer.

use std::collections::BTreeMap;

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ObjectId(usize);

pub type Adjust = BTreeMap<ObjectId, ObjectId>;

This keeps the API about identity, not addresses.

Step by Step Execution

Consider this small example:

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct ObjectId(usize);

struct Heap {
    objects: Vec<String>,
}

impl Heap {
    fn new() -> Self {
        Self { objects: Vec::new() }
    }

    fn allocate(&mut self, value: String) -> ObjectId {
        let id = ObjectId(self.objects.len());
        self.objects.push(value);
        id
    }

    fn get(&self, id: ObjectId) -> Option<&String> {
        self.objects.get(id.0)
    }
}

fn main() {
    let mut heap = Heap::new();
    let a = heap.allocate("A".to_string());
    let b = heap.allocate("B".to_string());

    println!("{:?}", heap.get(a));
    println!("{:?}", heap.get(b));
}

Step by step

  1. Heap::new() creates an empty vector.
  2. allocate("A"):
    • current length is 0
    • returns ObjectId(0)
    • pushes "A"
  3. allocate("B"):
    • current length is 1
    • returns ObjectId(1)
    • pushes "B"
  4. get(a) looks up index 0 and returns Some(&"A")
  5. get(b) looks up index 1 and returns Some(&"B")

Important safety point

The caller never receives a direct pointer into the vector storage. If the vector reallocates internally, the caller's ObjectId is still just an index. The heap remains responsible for translating that ID into the current location.

That is the same general idea you want for a moving garbage collector.

Real World Use Cases

Compilers and interpreters

Language runtimes often store objects in managed heaps. Exposing raw addresses is dangerous if objects can move during compaction. Handles or indices are safer.

Entity systems in games

Game engines commonly use entity IDs instead of direct pointers because entities may be moved, deleted, or recycled.

Caches and object stores

Large applications often return keys or handles for stored items instead of direct references, especially when the storage layer can reorganize data.

Databases and storage engines

A row ID or page ID is often safer than a direct memory address because the underlying storage may move or be rewritten.

API design for unsafe internals

Even when internals use raw pointers for speed, public APIs often wrap them in safer abstractions that control access and lifetime.

Take Quiz

Real Codebase Usage

In real Rust codebases, developers usually combine a few patterns for this kind of problem.

1. Stable handles instead of raw pointers

A common design is:

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Handle(usize);

The handle is passed around freely, but all real access goes through the owning heap.

2. Borrow the heap to access data

impl Heap {
    pub fn get(&self, h: Handle) -> Option<&Object> { /* ... */ unimplemented!() }
    pub fn get_mut(&mut self, h: Handle) -> Option<&mut Object> { /* ... */ unimplemented!() }
}

This makes Rust enforce that mutation and reading follow borrowing rules.

3. Guard collection with an explicit phase

Some designs introduce a collection or mutation session:

pub struct GcSession<'a> {
    heap: &'a mut Heap,
}

While the session exists, you may perform operations that move objects. Outside it, object access resumes through normal methods.

4. Use generations to detect stale handles

If objects can be deleted and slots reused, a simple index is not enough. A common pattern is:

pub struct Handle {
    index: usize,
    generation: u32,
}

The heap checks that the generation still matches before access.

5. Use guard clauses and validation

Real code should validate handles early:

fn get(&self, h: Handle) -> Option<&Object> {
    let slot = self.slots.get(h.index)?;
    if slot.generation != h.generation {
        return None;
    }
    slot.value.as_ref()
}

This is a common early-return pattern in production Rust.

6. Keep unsafe inside the implementation

A good target is:

  • public API is safe
  • unsafe is limited to heap internals
  • invariants are documented in one place

That is much easier to maintain than letting users call methods with raw pointers directly.

Common Mistakes

Mistake 1: Assuming PhantomData makes a raw pointer safe

This is a very common misunderstanding.

pub struct Object<'a> {
    ptr: *mut usize,
    marker: std::marker::PhantomData<&'a usize>,
}

This tells the compiler that Object<'a> behaves as if it borrowed something for 'a, but it does not make ptr automatically valid.

How to avoid it

Use PhantomData only when you already have real invariants that justify it. Do not use it as a substitute for ownership or reference safety.

Mistake 2: Returning direct pointers from a moving structure

Broken idea:

fn allocate(&mut self) -> *mut Object {
    // object may move later
    unimplemented!()
}

If compaction happens later, the pointer may dangle.

How to avoid it

Return handles or IDs instead.

Mistake 3: Tying object lifetimes to &mut self too aggressively

Example:

fn allocate<'a>(&'a mut self) -> Object<'a> {
    unimplemented!()
}

This often means the mutable borrow of self lasts as long as the returned object, which blocks further allocations or heap operations.

How to avoid it

Separate long-lived identity from short-lived access. Return a handle, then borrow the heap later to access the object.

Mistake 4: Thinking lifetimes can express every invalidation rule

Lifetimes can model borrow duration, but not all semantic events.

For example, “all old objects become invalid after merge” is more naturally modeled by:

  • consuming the old heap
  • issuing new handles from the new heap
  • preventing direct use of relocated objects except through the new owner

Mistake 5: Exposing unsafe when a safe wrapper is possible

If users must call:

unsafe { survive(ptr) }

then users must know deep internal invariants.

How to avoid it

Prefer:

heap.survive(handle);

or

session.mark(handle);

where the heap verifies the handle belongs to it.

Comparisons

ApproachWhat it returnsGood for moving GC?Main drawback
Raw pointerMemory addressNoBecomes invalid when object moves
Object<'a> wrapping raw pointerAddress with lifetime markerUsually noLifetime does not guarantee address stability
Reference &'a TBorrowed accessYes, temporarilyCannot be stored long-term across heap mutation
Index/ID handleStable identityYesRequires heap lookup for access
Handle with generationStable identity plus stale-checkingYesSlightly more bookkeeping

&T vs handle

Feature&THandle
Direct accessYesNo, needs heap lookup
Compiler-enforced borrow safetyYesIndirectly, through heap methods
Works across internal movementOnly while owner guarantees stabilityYes
Good for long-term storageUsually noYes

Why a handle-based API is often the best fit

A moving garbage collector wants:

  • stable identity for objects
  • freedom to relocate storage internally
  • central control over access

That matches handles much better than raw pointers or self-referential lifetime tricks.

Take Quiz

Cheat Sheet

Core rule

Rust lifetimes track borrow duration, not moving-GC object validity.

Prefer

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
struct Handle(usize);

impl Heap {
    fn allocate(&mut self, tag: Tag) -> Handle;
    fn get(&self, h: Handle) -> Option<&Object>;
    fn get_mut(&mut self, h: Handle) -> Option<&mut Object>;
}

Avoid

fn allocate(&mut self) -> *mut Object;
fn allocate<'a>(&'a mut self) -> Object<'a>; // often too restrictive

Good design ideas

  • Return handles, not raw pointers
  • Borrow the heap to access data
  • Consume old heaps when merging
  • Return handle-to-handle mappings after relocation
  • Use generations if handles can become stale
  • Keep unsafe inside implementation details

PhantomData reminder

PhantomData<&'a T>:

  • affects variance/drop checking/auto traits
  • does not validate a raw pointer
  • does not prevent underlying relocation

If objects move

Use one of:

  • ID lookup table
  • slot map
  • arena with stable handles
  • generation-based handle validation

Mental shortcut

  • reference = temporary borrowed access
  • handle = long-term identity
  • raw pointer = internal implementation detail

FAQ

Can Rust lifetimes prevent a garbage-collected object from moving?

No. Lifetimes only restrict how long borrowed access is allowed. They do not guarantee that an object's memory address stays the same.

Is PhantomData enough to make a raw pointer safe?

No. PhantomData can express type relationships to the compiler, but it does not make dereferencing a raw pointer valid.

Why does fn allocate<'a>(&'a mut self) -> Object<'a> cause usability problems?

Because the returned value keeps the mutable borrow alive for 'a, which often prevents more calls that also need &mut self.

What should I return instead of a raw pointer in a moving GC?

A stable handle, index, or object ID is usually the safest choice.

How do I safely access the object behind a handle?

Provide methods on the heap such as get, get_mut, or GC-session methods that validate the handle and borrow the heap.

Should reallocate return pointers or handles?

Handles are usually better. They represent object identity without exposing unstable addresses.

Is it okay to use raw pointers internally?

Yes, if necessary. The important part is to keep unsafe internal and expose a safe API with clear invariants.

Take Quiz

Mini Project

Description

Build a small Rust heap that stores strings and returns stable object handles instead of raw pointers. This demonstrates the main API design lesson behind moving garbage collectors: callers keep identities, while the heap controls access to the current object location.

Goal

Create a heap API that allows allocation and lookup through safe handles, without exposing direct memory addresses.

Requirements

  • Define a handle type that uniquely identifies an object.
  • Implement allocate to store a new object and return its handle.
  • Implement get to read an object through its handle.
  • Implement a compact-style method that rebuilds internal storage without changing how callers use handles.
Take Quiz

Keep learning

Related questions

View more