We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Unsafe and Soundness step 23 of 24
`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:
-
Creating an invalid value is instant UB. “Do not transmute
3tobool. Even if you never do anything with thebool. Just don’t.” -
The return type is inferred, so a missing turbofish can silently pick a
type you never meant. Clippy’s
missing_transmute_annotationsexists for this alone. -
“Transmuting an
&to an&mutis ALWAYS Undefined Behavior. No you can’t do it. No you’re not special.” rustc’smutable_transmutesis deny-by-default and says so. - Transmuting to a reference without naming a lifetime produces an unbounded lifetime the caller chooses.
-
Anything not
repr(C)orrepr(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_) |
u32 → char |
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…
Stuck?
Rust reference solution
Sign in to attempt this problem and reveal the reference solution.