ExamplesBy LevelBy TopicLearning Paths
721 Fundamental

MaybeUninit — Safe Uninitialized Memory

Functional Programming

Tutorial

The Problem

Rust guarantees that every value of type T is valid before it can be read. Enforcing this guarantee at zero cost requires a mechanism to hold memory that may not yet contain a valid T—without triggering undefined behavior (UB) from reading an uninitialized value. Before MaybeUninit<T> (stabilized in Rust 1.36), the only option was unsafe pointer tricks that were easy to misuse and triggered UB under Miri.

The problem surfaces in three real scenarios: (1) initializing a large array incrementally without a default value, (2) reading out-parameters from C FFI where the callee writes the value, and (3) building collections that grow element-by-element without requiring T: Default. MaybeUninit<T> is a union of T and u8; it reserves space and alignment for T but tells the compiler that the bytes may be garbage, preventing any optimization that assumes validity.

🎯 Learning Outcomes

  • • Explain why reading uninitialized memory is undefined behavior in LLVM IR
  • • Use MaybeUninit<T>::uninit(), write(), and assume_init() correctly
  • • Initialize arrays element-by-element without T: Default
  • • Model C out-parameter FFI patterns safely with MaybeUninit
  • • Use MaybeUninit::uninit_array() for stack-allocated uninitialized arrays
  • Code Example

    use std::mem::MaybeUninit;
    
    fn fill_value(out: &mut MaybeUninit<u32>, x: u32) {
        out.write(x * 2);
    }
    
    fn single_value_demo() -> u32 {
        let mut slot = MaybeUninit::<u32>::uninit();
        fill_value(&mut slot, 21);
        // SAFETY: fill_value unconditionally calls .write()
        unsafe { slot.assume_init() }
    }

    Key Differences

    AspectRustOCaml
    Uninitialized memoryMaybeUninit<T>, explicit initNot exposed; GC initializes all
    Array without Defaultuninit_array + assume_initArray.make n dummy or option
    C out-parametersMaybeUninit::as_mut_ptr()Bigarray or Bytes buffer
    Safety enforcementMiri detects UB at test timeRuntime checks, no UB concept
    PerformanceZero overhead vs initializedSmall overhead for None boxing

    OCaml Approach

    OCaml does not expose uninitialized memory at the language level. The runtime initializes every allocated block to a valid value (usually 0 or a tagged int). The closest analog is an option array filled with None and then populated:

    (* Safe but heap-allocates option wrappers *)
    let init_partial n f =
      let arr = Array.make n None in
      for i = 0 to n - 1 do
        arr.(i) <- Some (f i)
      done;
      Array.map Option.get arr   (* panics if any slot is still None *)
    

    For FFI out-parameters, OCaml uses Bigarray or Bytes.create to allocate a writable buffer and passes it to C; the result is read back via safe accessors. There is no direct equivalent of assume_init — OCaml's GC ensures all values remain tagged.

    Full Source

    #![allow(clippy::all)]
    //! # 721: `MaybeUninit` — Safe Uninitialized Memory
    //!
    //! `MaybeUninit<T>` lets you allocate memory without initialising it.
    //! The type makes the contract explicit: `.write()` initialises a slot,
    //! `.assume_init()` (unsafe) asserts every byte is valid.
    
    use std::mem::MaybeUninit;
    
    // ── Pattern 1: Single-value "C output parameter" ──────────────────────────────
    
    /// Writes a computed value into a caller-provided `MaybeUninit` slot —
    /// the canonical "output parameter" pattern seen in C FFI.
    pub fn fill_value(out: &mut MaybeUninit<u32>, x: u32) {
        out.write(x * 2);
    }
    
    /// Allocate a single uninitialised slot, write into it, then read back.
    pub fn single_value_demo() -> u32 {
        let mut slot = MaybeUninit::<u32>::uninit();
        fill_value(&mut slot, 21);
        // SAFETY: `fill_value` unconditionally calls `.write()`, so the slot
        // is fully initialised before we call `assume_init`.
        unsafe { slot.assume_init() }
    }
    
    // ── Pattern 2: Fixed-size array built element-by-element ──────────────────────
    
    /// Build a `[u32; N]` by initialising each element individually,
    /// avoiding the `Default` bound that `[T; N]` initialisation would require.
    pub fn build_array<const N: usize>(f: impl Fn(usize) -> u32) -> [u32; N] {
        // Allocate an array of uninitialised slots.
        let mut arr: [MaybeUninit<u32>; N] = unsafe { MaybeUninit::uninit().assume_init() };
    
        for (i, slot) in arr.iter_mut().enumerate() {
            slot.write(f(i));
        }
    
        // SAFETY: Every element has been written by the loop above.
        // We transmute the fully-initialised `[MaybeUninit<u32>; N]` to `[u32; N]`.
        //
        // Using `ptr::read` + cast is the standard pattern pre-1.80;
        // from 1.80+ `MaybeUninit::array_assume_init` can be used instead.
        unsafe { std::mem::transmute_copy(&arr) }
    }
    
    // ── Pattern 3: Partial fill tracked by index ─────────────────────────────────
    
    /// A buffer that tracks how many slots have been initialised.
    /// Elements 0..len are valid; elements len..CAP are uninitialised.
    pub struct PartialBuf<T, const CAP: usize> {
        data: [MaybeUninit<T>; CAP],
        len: usize,
    }
    
    impl<T, const CAP: usize> PartialBuf<T, CAP> {
        pub fn new() -> Self {
            Self {
                // SAFETY: An array of `MaybeUninit` is always safe to "uninit".
                data: unsafe { MaybeUninit::uninit().assume_init() },
                len: 0,
            }
        }
    
        /// Push a value into the next slot. Returns `false` if full.
        pub fn push(&mut self, value: T) -> bool {
            if self.len >= CAP {
                return false;
            }
            self.data[self.len].write(value);
            self.len += 1;
            true
        }
    
        /// Return a slice over the initialised portion.
        pub fn as_slice(&self) -> &[T] {
            // SAFETY: `data[0..len]` has been written by `push`.
            unsafe { &*(std::ptr::slice_from_raw_parts(self.data.as_ptr().cast::<T>(), self.len)) }
        }
    
        pub fn len(&self) -> usize {
            self.len
        }
    
        pub fn is_empty(&self) -> bool {
            self.len == 0
        }
    }
    
    impl<T, const CAP: usize> Default for PartialBuf<T, CAP> {
        fn default() -> Self {
            Self::new()
        }
    }
    
    impl<T, const CAP: usize> Drop for PartialBuf<T, CAP> {
        fn drop(&mut self) {
            // SAFETY: `data[0..len]` are initialised; drop them in place.
            for slot in &mut self.data[..self.len] {
                unsafe { slot.assume_init_drop() };
            }
        }
    }
    
    // ── Pattern 4: Idiomatic safe wrapper (no unsafe exposed) ────────────────────
    
    /// Zero-cost helper: initialise a `MaybeUninit<T>` via a closure and
    /// return the initialised value — entirely in safe code for the caller.
    pub fn init_with<T>(f: impl FnOnce(&mut MaybeUninit<T>)) -> T {
        let mut slot = MaybeUninit::<T>::uninit();
        f(&mut slot);
        // SAFETY: caller's closure is required to call `.write()` before returning.
        // The doc-contract makes this clear; callers who skip the write have UB.
        unsafe { slot.assume_init() }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_single_value_demo() {
            // 21 * 2 == 42
            assert_eq!(single_value_demo(), 42);
        }
    
        #[test]
        fn test_fill_value_writes_doubled() {
            let mut slot = MaybeUninit::<u32>::uninit();
            fill_value(&mut slot, 5);
            let v = unsafe { slot.assume_init() };
            assert_eq!(v, 10);
        }
    
        #[test]
        fn test_build_array_squares() {
            let arr: [u32; 5] = build_array(|i| (i * i) as u32);
            assert_eq!(arr, [0, 1, 4, 9, 16]);
        }
    
        #[test]
        fn test_build_array_identity() {
            let arr: [u32; 3] = build_array(|i| i as u32);
            assert_eq!(arr, [0, 1, 2]);
        }
    
        #[test]
        fn test_partial_buf_push_and_slice() {
            let mut buf = PartialBuf::<u32, 4>::new();
            assert!(buf.is_empty());
    
            assert!(buf.push(10));
            assert!(buf.push(20));
            assert!(buf.push(30));
    
            assert_eq!(buf.len(), 3);
            assert_eq!(buf.as_slice(), &[10, 20, 30]);
        }
    
        #[test]
        fn test_partial_buf_full_returns_false() {
            let mut buf = PartialBuf::<u32, 2>::new();
            assert!(buf.push(1));
            assert!(buf.push(2));
            assert!(!buf.push(3)); // full
            assert_eq!(buf.as_slice(), &[1, 2]);
        }
    
        #[test]
        fn test_partial_buf_with_string_drops_correctly() {
            let mut buf = PartialBuf::<String, 3>::new();
            buf.push("hello".to_owned());
            buf.push("world".to_owned());
            assert_eq!(buf.as_slice(), &["hello", "world"]);
            // Drop runs here — verifies no double-free or leak via Miri / sanitisers.
        }
    
        #[test]
        fn test_init_with() {
            let v = init_with(|slot| {
                slot.write(99_u32);
            });
            assert_eq!(v, 99);
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_single_value_demo() {
            // 21 * 2 == 42
            assert_eq!(single_value_demo(), 42);
        }
    
        #[test]
        fn test_fill_value_writes_doubled() {
            let mut slot = MaybeUninit::<u32>::uninit();
            fill_value(&mut slot, 5);
            let v = unsafe { slot.assume_init() };
            assert_eq!(v, 10);
        }
    
        #[test]
        fn test_build_array_squares() {
            let arr: [u32; 5] = build_array(|i| (i * i) as u32);
            assert_eq!(arr, [0, 1, 4, 9, 16]);
        }
    
        #[test]
        fn test_build_array_identity() {
            let arr: [u32; 3] = build_array(|i| i as u32);
            assert_eq!(arr, [0, 1, 2]);
        }
    
        #[test]
        fn test_partial_buf_push_and_slice() {
            let mut buf = PartialBuf::<u32, 4>::new();
            assert!(buf.is_empty());
    
            assert!(buf.push(10));
            assert!(buf.push(20));
            assert!(buf.push(30));
    
            assert_eq!(buf.len(), 3);
            assert_eq!(buf.as_slice(), &[10, 20, 30]);
        }
    
        #[test]
        fn test_partial_buf_full_returns_false() {
            let mut buf = PartialBuf::<u32, 2>::new();
            assert!(buf.push(1));
            assert!(buf.push(2));
            assert!(!buf.push(3)); // full
            assert_eq!(buf.as_slice(), &[1, 2]);
        }
    
        #[test]
        fn test_partial_buf_with_string_drops_correctly() {
            let mut buf = PartialBuf::<String, 3>::new();
            buf.push("hello".to_owned());
            buf.push("world".to_owned());
            assert_eq!(buf.as_slice(), &["hello", "world"]);
            // Drop runs here — verifies no double-free or leak via Miri / sanitisers.
        }
    
        #[test]
        fn test_init_with() {
            let v = init_with(|slot| {
                slot.write(99_u32);
            });
            assert_eq!(v, 99);
        }
    }

    Deep Comparison

    OCaml vs Rust: MaybeUninit — Safe Uninitialized Memory

    Side-by-Side Code

    OCaml (deferred initialisation via option/variant)

    type 'a maybe_uninit = Uninit | Init of 'a
    
    let write _mu v = Init v
    
    let assume_init = function
      | Init v -> v
      | Uninit -> failwith "assume_init called on uninitialised value!"
    
    let () =
      let slot : int maybe_uninit ref = ref Uninit in
      slot := write !slot 42;
      let value = assume_init !slot in
      Printf.printf "Initialised value: %d\n" value
    (* Output: Initialised value: 42 *)
    

    Rust — idiomatic MaybeUninit<T> (single value)

    use std::mem::MaybeUninit;
    
    fn fill_value(out: &mut MaybeUninit<u32>, x: u32) {
        out.write(x * 2);
    }
    
    fn single_value_demo() -> u32 {
        let mut slot = MaybeUninit::<u32>::uninit();
        fill_value(&mut slot, 21);
        // SAFETY: fill_value unconditionally calls .write()
        unsafe { slot.assume_init() }
    }
    

    Rust — fixed-size array built element-by-element

    use std::mem::MaybeUninit;
    
    fn build_array<const N: usize>(f: impl Fn(usize) -> u32) -> [u32; N] {
        let mut arr: [MaybeUninit<u32>; N] =
            unsafe { MaybeUninit::uninit().assume_init() };
    
        for (i, slot) in arr.iter_mut().enumerate() {
            slot.write(f(i));
        }
    
        // SAFETY: every element written by loop above
        unsafe { std::mem::transmute_copy(&arr) }
    }
    

    Rust — partial buffer with tracked initialisation

    pub struct PartialBuf<T, const CAP: usize> {
        data: [MaybeUninit<T>; CAP],
        len: usize,
    }
    
    impl<T, const CAP: usize> PartialBuf<T, CAP> {
        pub fn push(&mut self, value: T) -> bool {
            if self.len >= CAP { return false; }
            self.data[self.len].write(value);
            self.len += 1;
            true
        }
        pub fn as_slice(&self) -> &[T] {
            unsafe {
                &*(std::ptr::slice_from_raw_parts(
                    self.data.as_ptr().cast::<T>(), self.len))
            }
        }
    }
    

    Type Signatures

    ConceptOCamlRust
    Uninitialised slot'a maybe_uninit = Uninit \| Init of 'aMaybeUninit<T>
    Write into slotwrite : 'a maybe_uninit -> 'a -> 'a maybe_uninitMaybeUninit::write(&mut self, T) -> &mut T
    Assert initialisedassume_init : 'a maybe_uninit -> 'a (raises on Uninit)unsafe fn assume_init(self) -> T (UB if uninit)
    Fixed array uninitArray.make n (Obj.magic 0) (unsafe)[MaybeUninit<T>; N] (safe to create)
    Partial bufferoption array + lengthPartialBuf<T, CAP> struct

    Key Insights

  • OCaml always initialises heap values — its GC requires valid pointers everywhere, so
  • a real MaybeUninit concept doesn't exist. The closest idiom is option, which adds a tag-word overhead (Some/None). Rust's MaybeUninit<T> has zero overhead: it is exactly sizeof(T) bytes with no extra discriminant.

  • The unsafe boundary is explicit — in OCaml the runtime hides unsafety behind Obj.magic.
  • In Rust, MaybeUninit keeps all allocation safe; only .assume_init() is unsafe, forcing the programmer to justify the invariant at exactly the right place.

  • **MaybeUninit enables the "C output parameter" pattern** — functions like fill_value
  • that take &mut MaybeUninit<T> mirror C APIs (int out; foo(&out);) without requiring Default or zeroing the buffer first.

  • **Array initialisation without Default** — [T; N] in Rust requires T: Default for
  • safe construction. [MaybeUninit<T>; N] has no such constraint, making element-by-element initialisation possible for any T (useful for non-Default types like File or TcpStream).

  • **Drop must be explicit** — because Rust cannot know which MaybeUninit slots hold live
  • values, the PartialBuf::drop implementation must manually call assume_init_drop() on each initialised slot. OCaml's GC handles this automatically via its always-valid invariant.

    When to Use Each Style

    **Use MaybeUninit when:** you are writing FFI glue, a custom allocator, a fixed-capacity buffer that avoids Default, or code that must avoid zero-initialising a large array before overwriting every element.

    **Use Option<T> (the OCaml style) when:** you are writing safe, high-level Rust and the overhead of the Some/None tag is acceptable — it is simpler to reason about and requires no unsafe at all.

    Exercises

  • Implement collect_uninit<T, I: Iterator<Item=T>>(iter: I) -> Vec<T> using
  • MaybeUninit and Vec::with_capacity to avoid the double-initialization that Vec::new() + push performs.

  • Write a safe wrapper around a C function int compute(double *out_a, double *out_b)
  • that fills two out-parameters. Return (f64, f64) without intermediate allocation.

  • Implement a fixed-size ring buffer RingBuf<T, const N: usize> backed by
  • [MaybeUninit<T>; N] with correct Drop that only drops initialized slots.

  • Use Miri (cargo +nightly miri test) to verify your assume_init calls are safe.
  • Introduce a deliberate bug (read before write) and confirm Miri catches it.

  • Benchmark fill_value above vs vec![val; n]. Explain any difference in terms of
  • memset vs element-by-element initialization.

    Open Source Repos