MaybeUninit and object lifetime

Every value in safe Rust exists from the moment it is bound to the moment it is dropped, and the compiler tracks both ends. A buffer, an allocator and a file reader need the middle: memory that is reserved but holds nothing yet, and a value that ends before its scope does. MaybeUninit and ManuallyDrop are the two types that make that state explicit instead of leaving it to a comment.

This lesson writes one value into a slot, then four values into a buffer, then ends a value with an explicit drop. The unsafe part is the read of memory that must already be written, and the last section is the compiler error for a binding that was never written at all.

A slot without a value

MaybeUninit<T> is a T with one extra state: it may be empty. Creating it is safe, writing through its pointer is unsafe because the pointer must be valid, and assume_init is unsafe because it promises that the value is there.

The program reserves a slot for an integer, writes seven through the pointer, and reads the value out. The read happens once, after the write, and the slot is not used again.

use std::mem::MaybeUninit; fn main() { let mut slot = MaybeUninit::<i32>::uninit(); let pointer = slot.as_mut_ptr(); unsafe { pointer.write(7); } let value = unsafe { slot.assume_init() }; println!("{value}"); }

rustc --edition 2024 slot.rs
./slot

7

The two unsafe blocks are deliberately separate: the first says the pointer is valid, the second says the value is initialised. A function that returns the slot keeps the second promise in the caller, which is why assume_init stays close to the code that fills the memory. The lesson on pointer arithmetic writes the same value with ptr::write.

A buffer filled by hand

An array of MaybeUninit is the shape a growable buffer uses: the memory exists, no element is a value yet, and a loop writes them one at a time. The read at the end is one assume_init for the whole array, and it is sound only because every slot was written.

The program fills four slots with multiples of ten and reads the array back as a [u32; 4]. The cast reads the whole block, which is allowed once no byte in it is uninitialised.

use std::mem::MaybeUninit; fn main() { let mut buffer: [MaybeUninit<u32>; 4] = [MaybeUninit::uninit(); 4]; for (index, slot) in buffer.iter_mut().enumerate() { slot.write(index as u32 * 10); } let values: [u32; 4] = unsafe { (&raw const buffer as *const [u32; 4]).read() }; println!("{values:?}"); }

rustc --edition 2024 buffer.rs
./buffer

[0, 10, 20, 30]

The loop is the part that must run to the last slot. A break in the middle would leave the rest uninitialised and make the read undefined behaviour, and nothing in the type system warns about it: that promise is the programmer's. The lesson on manual memory builds a buffer that grows this way.

The line that initialises

A slot can be written twice, and the first value is then overwritten without being dropped. For a type with a destructor that is a leak; for a plain integer it is a plain store. The rule is the one from the ownership lesson: a value must be dropped exactly once, and the memory that held it must not be read afterwards.

MaybeUninit::write returns a mutable reference to the value inside, so a slot can be filled by a function that takes a reference and never knows about the wrapper.

use std::mem::MaybeUninit; fn fill(slot: &mut MaybeUninit<u32>, value: u32) { slot.write(value * 3); } fn main() { let mut slot = MaybeUninit::<u32>::uninit(); fill(&mut slot, 4); println!("{}", unsafe { slot.assume_init() }); }

rustc --edition 2024 fill.rs
./fill

12

The signature is the interesting part: fill is a safe function, it takes a mutable borrow, and the caller still owns the unsafe read. A function that returned the value instead would have to be unsafe itself or check a flag, which is what MaybeUninit leaves to the code that knows.

Ending a value early

drop(value) takes the value by move and runs its destructor at that point, so the release happens before the end of the scope. A guard that must be released before the next line uses the call, and the rest of the scope runs without it.

The program creates two guards, releases the first one explicitly and prints a line. The second guard is released at the end of main, so the output shows one closing before the message and one after.

struct Guard(&'static str); impl Drop for Guard { fn drop(&mut self) { println!("closing {}", self.0); } } fn main() { let first = Guard("first"); let _second = Guard("second"); drop(first); println!("after the explicit drop"); }

rustc --edition 2024 early_drop.rs
./early_drop

closing first
after the explicit drop
closing second

The apostrophe in &'static str is the lifetime from the lesson on lifetimes: the string literal lives for the whole program, so the guard can borrow it. The lesson on custom cleanup builds a guard that closes a handle instead of printing.

ManuallyDrop is the other end of the same control: it wraps a value and never runs the destructor. A buffer that moves values out with ptr::read uses it so the source does not drop what has already moved.

use std::mem::ManuallyDrop; struct Guard(&'static str); impl Drop for Guard { fn drop(&mut self) { println!("closing {}", self.0); } } fn main() { let boxed = ManuallyDrop::new(Guard("file")); let moved = unsafe { std::ptr::read(&*boxed) }; drop(moved); println!("done"); }

rustc --edition 2024 manually.rs
./manually

closing file
done

One closing line, not two: the read moved the guard out of the wrapper, and ManuallyDrop promised not to drop the copy that stayed behind. Without the wrapper the program would release the same handle twice, which for a file descriptor closes a number that may already belong to another file.

A binding that was never written

Safe code does not need MaybeUninit to express "not yet": a declaration without a value is legal, and the compiler refuses the read until an assignment happens on every path. The error is E0381, and it names the declaration and the use.

The program declares an integer and prints it without ever assigning one. The compiler points at both lines and suggests the initialiser.

fn main() { let value: i32; println!("{value}"); }

rustc --edition 2024 init_err.rs

error[E0381]: used binding `value` isn't initialized
--> init_err.rs:3:16
|
2 | let value: i32;
| ----- binding declared here but left uninitialized
3 | println!("{value}");
| ^^^^^ `value` used here but it isn't initialized
|
help: consider assigning a value
|
2 | let value: i32 = 42;
| ++++

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0381`.

Definite initialisation is a check on the control-flow graph, so an assignment inside an if is enough only when both branches write. The error appears where the value is read, not where it was declared, which is the line a reader has to fix. The lesson on calling C is the next place where memory arrives from outside the compiler's view.

Exercises

  1. Fill a MaybeUninit slot with a struct and read both fields back.
  2. Write a function that takes a slice of slots and returns how many it filled.
  3. Wrap a value in ManuallyDrop, read it out with ptr::read and drop the copy once.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What is safe about creating a MaybeUninit value?

    Answer

    Nothing is written, so no value exists yet

    The wrapper describes memory that may hold nothing, which is why creating it needs no unsafe.

  2. What does assume_init promise?

    Answer

    That the value inside is initialised

    Reading an uninitialised value is undefined behaviour, so the promise is the caller's.

  3. What does drop(value) do?

    Answer

    Takes the value by move and runs its destructor now

    The call consumes the value, so the release happens before the scope ends.

  4. What does ManuallyDrop change?

    Answer

    It suppresses the automatic destructor

    The wrapper never drops its value, which keeps a moved-out source from dropping twice.

  5. What does this program print? use std::mem::MaybeUninit; fn main() { let mut slot = MaybeUninit::<u32>::uninit(); slot.write(5); slot.write(9); println!("{}", unsafe { slot.assume_init() }); }

    Answer

    9

    The second write replaces the first, and the read returns the value that is there.

  6. What does this program print? use std::mem::MaybeUninit; fn main() { let mut buffer: [MaybeUninit<u8>; 3] = [MaybeUninit::uninit(); 3]; for (index, slot) in buffer.iter_mut().enumerate() { slot.write(index as u8 + 1); } let values: [u8; 3] = unsafe { (&raw const buffer as *const [u8; 3]).read() }; println!("{values:?}"); }

    Answer

    [1, 2, 3]

    Each slot is written with its index plus one before the array is read as a whole.

  7. What does this program print? struct Guard(&'static str); impl Drop for Guard { fn drop(&mut self) { println!("{}", self.0); } } fn main() { let guard = Guard("one"); drop(guard); println!("two"); }

    Answer

    one / two

    The explicit drop runs the destructor before the next statement.

  8. What does this program print? use std::mem::ManuallyDrop; struct Guard(&'static str); impl Drop for Guard { fn drop(&mut self) { println!("closing {}", self.0); } } fn main() { let wrapped = ManuallyDrop::new(Guard("a")); let _copy = unsafe { std::ptr::read(&*wrapped) }; }

    Answer

    closing a

    The copy is bound to a name and drops at the end of main, and the wrapper never drops its own.

  9. What is the first line of the compiler output for this program? fn main() { let value: i32; println!("{value}"); }

    Answer

    error[E0381]: used binding value isn't initialized

    The binding is declared but never written, and the read is refused.

  10. What is the first line of the compiler output for this program? use std::mem::MaybeUninit; fn main() { let slot = MaybeUninit::<i32>::uninit(); println!("{}", slot.assume_init()); }

    Answer

    error[E0133]: call to unsafe function MaybeUninit::<T>::assume_init is unsafe and requires unsafe block

    Reading the value out is one of the operations that need an unsafe block.

The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev