ExamplesBy LevelBy TopicLearning Paths
549 Intermediate GitHub

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