Prior to Rust 1.36, developers relied on std::mem::uninitialized to allocate stack memory for values that would be initialized later. This function was fundamentally unsound because it told the compiler that a valid T existed at that memory location, even though the bits were random. For types with safety invariants—such as bool, char, or references—this led to immediate undefined behavior, as the compiler would optimize based on the assumption that the value was valid (e.g., a bool being 0 or 1). RFC 1892 introduced MaybeUninit<T> as a union-like abstraction to explicitly denote memory that does not yet contain a valid T, solving this soundness hole.
The core issue stems from LLVM's treatment of uninitialized memory as undef or poison, coupled with Rust's automatic drop glue generation. When the compiler believes a variable of type T is live, it may emit destructor calls or niche optimizations. If T is a bool, an uninitialized byte might hold the value 2, which violates the bit validity invariant. Reading this during drop checking or discriminator inspection constitutes undefined behavior. Furthermore, if initialization fails partway through an array, the drop glue for the array type would attempt to drop all elements, interpreting uninitialized stack bytes as pointers and causing use-after-free or double-free errors.
MaybeUninit<T> acts as a typed container that may or may not hold a valid T. It prevents the compiler from assuming initialization, thereby inhibiting drop glue emission and invalid bit-pattern optimizations. The programmer must manually track which instances are initialized, typically via a separate index or boolean array. To extract a value, one uses assume_init, assume_init_ref, or std::ptr::read, but only after provably writing a valid T via write or pointer manipulation. The critical invariant is that assume_init must never be called on memory that is not fully initialized, and when abandoning a partially initialized structure, the programmer must manually drop only the initialized elements using ptr::drop_in_place to avoid resource leaks.
use std::mem::{self, MaybeUninit}; use std::ptr; fn init_array_fallible<T, E, const N: usize>( mut f: impl FnMut(usize) -> Result<T, E>, ) -> Result<[T; N], E> { let mut array: [MaybeUninit<T>; N] = unsafe { MaybeUninit::uninit().assume_init() }; let mut i = 0; while i < N { match f(i) { Ok(val) => { array[i].write(val); i += 1; } Err(e) => { for j in 0..i { unsafe { ptr::drop_in_place(array[j].as_mut_ptr()); } } return Err(e); } } } Ok(unsafe { mem::transmute::<[MaybeUninit<T>; N], [T; N]>(array) }) }
You are developing a no_std kernel driver for a network interface card where heap allocation is prohibited and latency must be deterministic. You need to allocate a fixed-size table of 1024 Connection objects on the stack. Each Connection initialization involves a hardware register write that can fail if the NIC buffer is full. The challenge is ensuring that if the 500th connection fails, the previous 499 are properly closed (dropping file descriptors and releasing DMA mappings) while the remaining 524 slots are left untouched, avoiding any undefined behavior from dropping uninitialized memory.
One potential approach involves utilizing Default::default() to pre-initialize the array with sentinel values. This requires Connection to implement Default, which is problematic because a "default" connection would still acquire kernel resources that must be explicitly released, complicating the error path. Furthermore, constructing 1024 dummy connections just to overwrite them wastes initialization cycles and violates the driver's strict timing requirements for bringing the interface online.
A second strategy employs Vec<Connection> with with_capacity and dynamic pushing, followed by conversion to a fixed array. This is safe and idiomatic in user-space code. However, Vec requires a global allocator, which is unavailable in this kernel context. It also introduces potential panic paths and memory fragmentation that are unacceptable in kernel space, and the conversion to a fixed-size array requires runtime checks that complicate the error handling logic.
The third approach leverages MaybeUninit<[Connection; 1024]> to allocate the storage without initialization. Successfully initialized connections are written via MaybeUninit::write, and if an error occurs at index i, we manually iterate from 0 to i-1 and call ptr::drop_in_place on each initialized slot before returning the error. On success, we transmute the entire array to the initialized type. We selected this solution because it provides zero-cost stack allocation with deterministic performance, satisfies the no_std constraint, and ensures that resource cleanup only occurs for truly initialized objects. The result was a robust driver that never invoked undefined behavior during partial failure recovery and maintained consistent microsecond-level initialization latency.
Why does calling assume_init on an uninitialized MaybeUninit<T> constitute undefined behavior even if the value is never explicitly read afterward?
Many candidates believe undefined behavior only occurs when you physically access the data, such as printing it or branching on it. However, Rust's type system informs the compiler that a valid T exists immediately upon calling assume_init. For types with niche optimizations (like bool, char, Option<&T>, or NonNull<T>), the compiler may generate code that inspects the bit pattern to determine enum variants or validity. If the memory holds random bits (e.g., 0xFF for a bool), this inspection triggers undefined behavior in LLVM (loading poison or undef). Additionally, when the scope ends, the compiler inserts drop glue for the T, which will attempt to run destructors on garbage data, leading to crashes or security vulnerabilities. Thus, assume_init is a contract where the programmer guarantees valid initialization; violating it poisons the compiler's state regardless of explicit reads.
What is the difference between using MaybeUninit::write versus std::ptr::write on the pointer returned by MaybeUninit::as_mut_ptr(), and when is each appropriate?
MaybeUninit::write is a safe method that takes ownership of a T and writes it into the uninitialized slot, returning a mutable reference to the now-initialized data. It is preferred when you have the value ready and want immediate safe access. In contrast, std::ptr::write is an unsafe function that writes a value to a raw pointer without reading or dropping the old value (which is critical since the memory is uninitialized). You must use ptr::write when you are writing through a raw pointer obtained from as_mut_ptr() and need to avoid the borrow checker restrictions of write, or when implementing low-level abstractions where you only have raw pointers. The key distinction is that write provides safety guarantees and lifetime tracking, while ptr::write requires manual verification that the destination is valid, properly aligned, and uninitialized to avoid aliasing violations or premature drops.
How does one correctly drop a partially initialized array of MaybeUninit<T> without leaking resources or invoking undefined behavior, and why is the order of operations critical?
When initialization fails at index i, you must drop only elements 0..i. The correct procedure is to iterate from 0 to i-1 and call std::ptr::drop_in_place(array[j].as_mut_ptr()). This runs the destructor for T without moving the value out of the MaybeUninit wrapper (which would leave the slot in a moved-from state, though still technically uninitialized). It is crucial to perform this cleanup immediately upon failure, before returning the error, to ensure the stack frame is unwound cleanly. If you instead attempted to use mem::forget on the array or simply returned, the MaybeUninit wrapper would be dropped (a no-op), but the live T instances inside would leak their resources (like file handles or heap memory). Conversely, if you mistakenly dropped elements i..N, you would invoke undefined behavior by treating garbage memory as valid T instances.