MaybeUninit — Safe Uninitialized Memory
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
MaybeUninit<T>::uninit(), write(), and assume_init() correctlyT: DefaultMaybeUninitMaybeUninit::uninit_array() for stack-allocated uninitialized arraysCode 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
| Aspect | Rust | OCaml |
|---|---|---|
| Uninitialized memory | MaybeUninit<T>, explicit init | Not exposed; GC initializes all |
| Array without Default | uninit_array + assume_init | Array.make n dummy or option |
| C out-parameters | MaybeUninit::as_mut_ptr() | Bigarray or Bytes buffer |
| Safety enforcement | Miri detects UB at test time | Runtime checks, no UB concept |
| Performance | Zero overhead vs initialized | Small 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);
}
}#[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
| Concept | OCaml | Rust |
|---|---|---|
| Uninitialised slot | 'a maybe_uninit = Uninit \| Init of 'a | MaybeUninit<T> |
| Write into slot | write : 'a maybe_uninit -> 'a -> 'a maybe_uninit | MaybeUninit::write(&mut self, T) -> &mut T |
| Assert initialised | assume_init : 'a maybe_uninit -> 'a (raises on Uninit) | unsafe fn assume_init(self) -> T (UB if uninit) |
| Fixed array uninit | Array.make n (Obj.magic 0) (unsafe) | [MaybeUninit<T>; N] (safe to create) |
| Partial buffer | option array + length | PartialBuf<T, CAP> struct |
Key Insights
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.
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.
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
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.
int compute(double *out_a, double *out_b) that fills two out-parameters. Return (f64, f64) without intermediate allocation.
RingBuf<T, const N: usize> backed by [MaybeUninit<T>; N] with correct Drop that only drops initialized slots.
cargo +nightly miri test) to verify your assume_init calls are safe.Introduce a deliberate bug (read before write) and confirm Miri catches it.
fill_value above vs vec![val; n]. Explain any difference in terms ofmemset vs element-by-element initialization.