ExamplesBy LevelBy TopicLearning Paths
549 Intermediate

PhantomData for Lifetime Markers

Functional Programming

Tutorial Video

Text description (accessibility)

This video demonstrates the "PhantomData for Lifetime Markers" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. Sometimes a struct logically borrows from an external lifetime but does not store any reference — perhaps it holds a raw pointer, a numeric ID, or an opaque handle. Key difference from OCaml: 1. **Runtime cost**: `PhantomData` is zero

Tutorial

The Problem

Sometimes a struct logically borrows from an external lifetime but does not store any reference — perhaps it holds a raw pointer, a numeric ID, or an opaque handle. Without PhantomData, the compiler has no way to know the struct's relationship to that lifetime, leading to incorrect variance and missing lifetime checks. PhantomData<&'a T> is a zero-size type that carries lifetime and variance information without storing any data. It is essential for safe wrappers around raw pointers, arena allocators, typed indices, and foreign-function handles.

🎯 Learning Outcomes

  • • Why PhantomData<&'a T> is needed when a struct logically borrows 'a but has no reference field
  • • How Handle<'a, T> with PhantomData<&'a T> prevents handles from outliving their source
  • • How Index<T> uses PhantomData<T> for type-safety without storing a T
  • • How PhantomData affects variance (covariant, contravariant, invariant)
  • • Where PhantomData appears: arena allocators, foreign-function handles, typed indices, raw pointer wrappers
  • Code Example

    #![allow(clippy::all)]
    //! PhantomData for Lifetime Markers
    //!
    //! Using PhantomData to carry lifetime information.
    
    use std::marker::PhantomData;
    
    /// Struct that conceptually borrows from 'a.
    pub struct Handle<'a, T> {
        id: usize,
        _marker: PhantomData<&'a T>,
    }
    
    impl<'a, T> Handle<'a, T> {
        pub fn new(id: usize) -> Self {
            Handle {
                id,
                _marker: PhantomData,
            }
        }
    
        pub fn id(&self) -> usize {
            self.id
        }
    }
    
    /// Typed index into a collection.
    pub struct Index<T> {
        idx: usize,
        _marker: PhantomData<T>,
    }
    
    impl<T> Index<T> {
        pub fn new(idx: usize) -> Self {
            Index {
                idx,
                _marker: PhantomData,
            }
        }
    
        pub fn get(self) -> usize {
            self.idx
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_handle() {
            let h: Handle<i32> = Handle::new(42);
            assert_eq!(h.id(), 42);
        }
    
        #[test]
        fn test_index() {
            let idx: Index<String> = Index::new(5);
            assert_eq!(idx.get(), 5);
        }
    }

    Key Differences

  • Runtime cost: PhantomData is zero-size — no runtime overhead; OCaml phantom types are also zero-cost at runtime since the 'a type parameter is erased.
  • Lifetime enforcement: Rust PhantomData<&'a T> enforces that Handle<'a, T> cannot outlive 'a at compile time; OCaml phantom types cannot express lifetime constraints.
  • Variance control: PhantomData precisely controls variance (covariant, contravariant, invariant); OCaml phantom types are covariant by default unless annotated.
  • Raw pointer wrappers: Rust raw pointer wrappers use PhantomData<T> to tell the compiler what the pointer "logically owns"; OCaml raw pointers (via Bigarray or ctypes) rely on programmer discipline.
  • OCaml Approach

    OCaml achieves typed index safety through phantom types using abstract module signatures or the Ppx_phantom approach:

    type 'a index = Index of int
    let make_index n : 'a index = Index n
    let get (Index n) = n
    (* User_index and Post_index are the same type at runtime but distinct by convention *)
    

    OCaml's phantom types are a convention — the runtime has no distinction. Rust's PhantomData enforces the distinction at the type level.

    Full Source

    #![allow(clippy::all)]
    //! PhantomData for Lifetime Markers
    //!
    //! Using PhantomData to carry lifetime information.
    
    use std::marker::PhantomData;
    
    /// Struct that conceptually borrows from 'a.
    pub struct Handle<'a, T> {
        id: usize,
        _marker: PhantomData<&'a T>,
    }
    
    impl<'a, T> Handle<'a, T> {
        pub fn new(id: usize) -> Self {
            Handle {
                id,
                _marker: PhantomData,
            }
        }
    
        pub fn id(&self) -> usize {
            self.id
        }
    }
    
    /// Typed index into a collection.
    pub struct Index<T> {
        idx: usize,
        _marker: PhantomData<T>,
    }
    
    impl<T> Index<T> {
        pub fn new(idx: usize) -> Self {
            Index {
                idx,
                _marker: PhantomData,
            }
        }
    
        pub fn get(self) -> usize {
            self.idx
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_handle() {
            let h: Handle<i32> = Handle::new(42);
            assert_eq!(h.id(), 42);
        }
    
        #[test]
        fn test_index() {
            let idx: Index<String> = Index::new(5);
            assert_eq!(idx.get(), 5);
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_handle() {
            let h: Handle<i32> = Handle::new(42);
            assert_eq!(h.id(), 42);
        }
    
        #[test]
        fn test_index() {
            let idx: Index<String> = Index::new(5);
            assert_eq!(idx.get(), 5);
        }
    }

    Deep Comparison

    OCaml vs Rust: lifetime phantom

    See example.rs and example.ml for implementations.

    Key Differences

  • OCaml uses garbage collection
  • Rust uses ownership and borrowing
  • Both support the core concept
  • Exercises

  • Typed generation handle: Implement struct GenerationHandle<'arena, T> { id: u32, _p: PhantomData<&'arena T> } where 'arena ensures the handle cannot outlive the arena it was allocated from.
  • Two phantom types: Create struct Matrix<T, Rows, Cols> { data: Vec<T>, _p: PhantomData<(Rows, Cols)> } and implement fn transpose(m: Matrix<T, R, C>) -> Matrix<T, C, R> — use phantom row/col types to prevent transposing the wrong way.
  • Invariant phantom: Change Handle<'a, T> to use PhantomData<&'a mut T> — explain what changes in terms of variance and what programs the compiler now rejects.
  • Open Source Repos