Skip to content

← Unsafe and Soundness step 23 of 24

Hard Primitives

`union`, `transmute` and `MaybeUninit`

#[repr(C)]
union Payload { i: i64, f: f64, b: [u8; 8] }

pub fn tagged_union_decode(records: Vec<i64>) -> Vec<String>

Decode a manually-tagged union. records is a flat list of (tag, bits) pairs; a trailing odd element is ignored.

tag read emit
0 .i the i64, in decimal
1 .f the f64, formatted with {value:?}
2 .b sum=N, where N is the sum of the eight bytes
anything else nothing "invalid"

Read that last row again. An invalid tag must be rejected before any union field is read, because reading a field the tag does not describe is exactly the undefined behaviour this item is about.

The starter does not compile: reading a union field is superpower #5.

(a) union: writing is safe, reading is not

Writing a union field is safe — you are just storing bits, and the compiler does not care which field you claim they are. Reading is one of the five superpowers, and the reason is precise:

Reading a union field is undefined behaviour unless the bytes currently stored are a valid value AT THAT FIELD’S TYPE.

So reading 3 out of a bool field is UB, because bool‘s validity invariant is 0-or-1. But reading any eight bytes as a u64 or an f64 is fine, because every bit pattern is a valid u64 and every bit pattern is a valid f64 — including the NaNs and the infinities. That is why this problem’s three fields are safe to punt between: i64, f64 and [u8; 8] have no invalid values.

Three more rules worth knowing:

Fields must be Copy, references, ManuallyDrop<T>, or aggregates of those (E0740 otherwise). The compiler cannot know which field is live, so it cannot know which destructor to run — so unions never drop their contents at all.

Reading a field that was never written is UB even for u32, because the bytes are uninitialised, and uninitialised is invalid for every type with restricted values. “Uninitialised” is not “some arbitrary number”; item 17.12 and item 17.23’s MaybeUninit half both turn on that distinction.

#[repr(C)] is not optional if you care about layout. Without it a union’s layout is simply unspecified — field offsets and total size are up to the compiler. Clippy has default_union_representation for exactly this.

::: question let a = &mut u.b; let c = &mut u.i; — two different fields. Does that compile? No. E0499:

error[E0499]: cannot borrow `u` (via `u.i`) as mutable more than once at a time

Borrowing one field of a union borrows all of them. That is the opposite of a struct, where &mut s.a and &mut s.b are disjoint places and coexist happily (item 3.3).

The reason is that in a union the fields are the same place — they overlap by definition. Two &mut to overlapping bytes is exactly what the aliasing rule forbids, and the compiler is not being conservative here; it is being correct. :::

(b) transmute, and the list of things to use instead

The Nomicon calls mem::transmute “the most horribly unsafe thing you can do in Rust”, and it earns it. Its only compile-time check is that the two types have the same size (E0512 otherwise). Five caveats:

  1. Creating an invalid value is instant UB. “Do not transmute 3 to bool. Even if you never do anything with the bool. Just don’t.”
  2. The return type is inferred, so a missing turbofish can silently pick a type you never meant. Clippy’s missing_transmute_annotations exists for this alone.
  3. “Transmuting an & to an &mut is ALWAYS Undefined Behavior. No you can’t do it. No you’re not special.” rustc’s mutable_transmutes is deny-by-default and says so.
  4. Transmuting to a reference without naming a lifetime produces an unbounded lifetime the caller chooses.
  5. Anything not repr(C) or repr(transparent) has unspecified layout, so the transmute is meaningless even when the sizes match.

And transmute_copy removes even the size check.

The payoff is the replacement list, which covers the overwhelming majority of real uses and needs no unsafe at all:

instead of transmuting use
f32/f64 ↔ bits to_bits / from_bits
integer ↔ bytes to_ne_bytes / from_ne_bytes (and _le_ / _be_)
u32char char::from_u32
numeric conversions as, From, TryFrom
pointer → pointer ptr::cast
&[u8]&[u32] slice::align_to

rustc even ships an unnecessary_transmutes lint, warn-by-default, that names the safe replacement for you.

::: question slice::align_to::<u32>() returns three slices, not one. Why? Because a &[u8] is only 1-aligned, so the region you can legally reinterpret as u32 starts somewhere after the beginning and ends somewhere before the end.

let (prefix, middle, suffix) = unsafe { bytes.align_to::<u32>() };

prefix is the leading bytes before the first 4-aligned address; middle is the properly-aligned &[u32]; suffix is the trailing bytes that do not fill a whole u32. Handling all three explicitly is the entire correctness argument, and it is what the naive transmute::<&[u8], &[u32]>(bytes) throws away — that transmute produces a misaligned slice with the wrong length, and clippy names it (unsound_collection_transmute, cast_ptr_alignment).

Note align_to is still unsafe, for a different reason: it does not check that the bytes are a valid u32. For u32 every pattern is valid, so the obligation is trivially discharged — but say so in the comment rather than assuming the reader knows. :::

(c) MaybeUninit: the type that makes “not yet a T“ expressible

mem::uninitialized::<T>() and mem::zeroed::<T>() are undefined behaviour for any T with restricted valid values — including, unintuitively, plain integers, because uninitialised bytes are not “some arbitrary value” but formally invalid, and reading them twice may yield different results. rustc’s invalid_value lint fires on mem::uninitialized::<bool>() and mem::uninitialized::<u32>().

MaybeUninit<T> is a #[repr(transparent)] union — hence its place on this page — with the same size, alignment and ABI as T, whose entire purpose is to tell the compiler assume nothing about these bytes.

The API: uninit, zeroed, new, write(val) -> &mut T, as_ptr / as_mut_ptr, and the unsafe extraction family assume_init, assume_init_ref, assume_init_mut, assume_init_read, assume_init_drop.

assume_init‘s contract is total: every byte fully initialised and the result satisfying T‘s validity invariant. Padding bytes are exempt, which is why MaybeUninit reasoning is per-field rather than per-byte.

Two practical notes. Niche optimisation does not apply, so Option<MaybeUninit<bool>> is 2 bytes where Option<bool> is 1. And the current array idiom is [const { MaybeUninit::uninit() }; N]uninit_array never stabilised, and vec![MaybeUninit::uninit(); n] does not compile because MaybeUninit<T> is not Clone for a non-Clone T; use (0..n).map(|_| MaybeUninit::uninit()).collect().

MaybeUninit does not drop its contents. A partially-initialised array must be dropped element by element with assume_init_drop, and a panic mid-initialisation must not skip that. Item 17.24 is that exercise.

Two deny-by-default lints guard the obvious mistakes: uninit_assumed_init (MaybeUninit::uninit().assume_init()) and uninit_vec (with_capacity followed by set_len).

What this grader cannot check

If you build the Payload and read .i before validating the tag, every test here still passes. The bytes are there, they are a valid i64, nothing observes anything. The undefined behaviour in this problem is the ordering — reading a field the tag did not authorise — and the harness cannot see ordering. The starter fails only because rustc requires unsafe around a union read, not because anything detected the UB.

Remember the grade is compile + tests + clippy -D warnings.

Loading visualization…