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>
A union is a type whose fields all share the same storage. Writing one field and then reading another reinterprets the same bytes as a different type. Rust does not record which field was written last, so a program that uses a union usually keeps a separate tag: a value that says how the bytes should be read.
In this exercise, records is a flat list of pairs: [tag0, bits0, tag1, bits1, ...]. For each complete pair, store bits in a Payload through the
i field, then produce one output string chosen by the tag:
| tag | field to read | output string |
|---|---|---|
0 |
.i |
the i64 in decimal, for example -7 |
1 |
.f |
the f64 formatted with format!("{value:?}") |
2 |
.b |
sum=N, where N is the sum of the eight bytes |
| any other value | none | invalid |
Contract details:
- Return one string per complete pair, in input order. If
recordshas an odd length, ignore the last element. Empty input returns an empty vector. bitscan be anyi64, includingi64::MINandi64::MAX.- For tag
1, the eight bytes ofbitsare read as anf64. Use Rust'sDebugformatting exactly. For example, the bit patterns of0.0,-0.0,1.5, positive infinity and a NaN print as0.0,-0.0,1.5,infandNaN. Very large or very small magnitudes use exponent notation, such as1e300. Every float the tests use prints in plain form: the five above plus2.0,0.5and-2.25. - For tag
2, each byte is an unsigned value from 0 to 255, so the sum is between 0 and 2040. Addition does not depend on byte order, so the result is the same on little-endian and big-endian targets. Sum into a type wider thanu8. - Tags are compared as
i64; negative tags such as-1are invalid.
Check the tag before reading any union field. In this exercise all three fields accept any 8-byte pattern, so no read here produces an invalid value, but the habit matters: in general, reading a field the tag does not describe can produce an invalid value, which is undefined behavior.
The starter does not compile. Reading a union field is one of the operations
Rust allows only inside an unsafe block, and rustc reports E0133 for the
starter's unguarded reads. The starter also enables
clippy::undocumented_unsafe_blocks, so each unsafe block you add needs a
// SAFETY: comment explaining why the read is valid.
(a) union: writing is safe, reading is not
Assigning to a union field, as in u.f = 2.0, is safe: it overwrites bytes
and cannot by itself produce an invalid value. Reading a field, or taking a
reference to one with &u.f or &mut u.f, requires unsafe. The reason:
Reading a union field is undefined behavior unless the bytes currently stored are a valid value of that field's type.
A type's validity invariant is the set of byte patterns that are
allowed to exist as a value of that type at all. bool allows only the bytes
0 and 1, so reading a stored 3 through a bool field is undefined
behavior. Every initialized 8-byte pattern, by contrast, is a valid i64,
a valid f64 (including infinities and NaNs) and a valid [u8; 8]. Because
this exercise always writes all eight bytes through .i, any of the three
fields can be read afterward.
Three more rules:
Field types are restricted. A union field must be a Copy type, a
reference, a ManuallyDrop<T>, or a tuple or array made only of those.
A field such as String is rejected with E0740. Rust cannot know which field
is currently stored, so it never runs a destructor for a union's contents;
this restriction keeps that from silently leaking or skipping cleanup.
Bytes that were never written are uninitialized. Consider
union U { small: u8, big: u32 }. After writing only small, three bytes of
big were never written. Reading big is undefined behavior even though
every initialized 32-bit pattern is a valid u32: uninitialized memory is
not "some unknown number", and integer types do not permit it. The article
on validity and safety invariants
covers this distinction in more detail.
Use #[repr(C)] when layout matters. With the default representation,
the Rust Reference does not guarantee that every field starts at offset 0.
With #[repr(C)] every field starts at offset 0, so all three fields of
Payload cover the same eight bytes. Clippy's
default_union_representation lint (off by default) flags unions without
an explicit representation.
Check your understanding
let a = unsafe { &mut u.b }; let c = unsafe { &mut u.i }; uses two different fields. Does it compile if both references are used afterward?
Show answer
Hide answer
No. rustc reports E0499:
error[E0499]: cannot borrow `u` (via `u.i`) as mutable more than once at a time
with the note that u.i is a field of the union, so it overlaps u.b.
Borrowing one field of a union borrows all of its fields for the same
lifetime. In a struct, &mut s.a and &mut s.b refer to separate fields
and can coexist (aliasing XOR mutability).
Union fields occupy the same bytes, so two live &mut references to
different fields would be two mutable references to overlapping memory,
which Rust's borrowing rules forbid.
(b) transmute, and safer alternatives
std::mem::transmute::<Src, Dst>(value) reinterprets the bytes of a Src
value as a Dst. The Rustonomicon describes it as "the most horribly unsafe
thing you can do in Rust". The compiler checks only that the two types
have the same size and reports E0512 otherwise. Everything else is the
caller's responsibility:
- The result must be a valid
Dst. Producing an invalid value, such as transmuting the byte3into abool, is undefined behavior immediately, even if the value is never used afterward. - The type arguments can be inferred. If you omit
::<Src, Dst>, the surrounding code decides the types, and a change elsewhere can change what is transmuted. Clippy'smissing_transmute_annotationslint (warn by default) flags calls whose types are not written at or next to the call. - Transmuting
&Tinto&mut Tis always undefined behavior. The compiler may assume that data behind a shared reference does not change. rustc rejects this transmute with themutable_transmuteslint, which is deny-by-default. - Transmuting to a reference can create an unbounded lifetime. If the result type does not name a lifetime tied to the source, the caller's context chooses it, and the borrow checker can no longer protect the original data.
- Matching sizes do not imply matching layouts. Structs and enums with
the default representation have no guaranteed field order or padding, so
transmuting between them depends on details the compiler may change. Use
#[repr(C)]or#[repr(transparent)]when the layout must be relied on.
std::mem::transmute_copy has no compile-time size check at all. It reads
size_of::<Dst>() bytes from behind a reference, and calling it with a
Dst larger than Src is undefined behavior according to its
documentation.
Many common reinterpretations have dedicated APIs:
| instead of transmuting | use | needs unsafe? |
|---|---|---|
f32/f64 to and from raw bits |
to_bits / from_bits |
no |
| integer to and from bytes | to_ne_bytes / from_ne_bytes, or the _le_ / _be_ forms |
no |
u32 to char |
char::from_u32, which returns None for invalid values |
no |
| numeric conversions | as, From, TryFrom |
no |
| raw pointer type to raw pointer type | the pointer cast method |
no (dereferencing the result does) |
&[u8] to &[u32] |
slice::align_to |
yes |
For several of these cases, rustc's unnecessary_transmutes lint (warn by
default) points out the safe replacement.
Check your understanding
slice::align_to::<u32>() returns three slices instead of one. Why?
Show answer
Hide answer
A u32 normally requires 4-byte alignment, but a &[u8] can start at any
address. Only part of the byte slice may be reinterpretable as properly
aligned u32 values.
let (prefix, middle, suffix) = unsafe { bytes.align_to::<u32>() };
prefix holds leading bytes, middle is an aligned &[u32], and suffix
holds trailing bytes that do not fit. For example, if a 10-byte slice
starts 1 byte past a 4-byte boundary, the largest possible middle gives
parts of 3 bytes, one u32 and 3 bytes.
The documentation allows align_to to return a shorter middle than
possible, even an empty one, so correct code handles all three parts and
must not depend on middle being as long as possible.
A plain transmute::<&[u8], &[u32]>(bytes) does none of this: the result can be
misaligned, and the slice length is not converted: on current compilers the
length is copied unchanged, so the result claims four times as many bytes as
the original slice covers. Both problems are undefined behavior.
align_to is still unsafe because it does not check that the middle bytes
form valid values of the target type. For u32, every initialized 4-byte
pattern is valid, which is the argument the // SAFETY: comment should
state.
(c) MaybeUninit: a value that may not be initialized yet
std::mem::uninitialized::<T>() is deprecated: it claims to return a T
while leaving the bytes uninitialized, which is undefined behavior for
almost every T, including integers. rustc's invalid_value lint warns on
mem::uninitialized::<u32>() with the note "integers must be initialized".
std::mem::zeroed::<T>() is different: it is valid only for types where the
all-zero byte pattern is allowed. It is fine for integers and floats, but
undefined behavior for references, NonZero types and function pointers;
invalid_value also warns on zeroing a reference.
MaybeUninit<T> is a union in the standard library, marked
#[repr(transparent)], with the same size, alignment and ABI as T. The
compiler assumes nothing about its contents until you claim, with an
unsafe call, that it holds a valid T.
The main API: uninit, zeroed and new create it; write(val) stores a
value and returns &mut T; as_ptr and as_mut_ptr give raw pointers; and
the unsafe methods assume_init, assume_init_ref, assume_init_mut,
assume_init_read and assume_init_drop treat the contents as an
initialized T.
assume_init requires a fully valid T. Every part of the value that
T's validity invariant requires must be initialized and valid. Padding
bytes between struct fields are not part of the value and need not be
initialized, so a struct can be initialized in place by writing each field
through a raw pointer to it.
Some practical details:
MaybeUninit<T>has no niche (no invalid bit pattern forOptionto reuse), so on the current compilerOption<MaybeUninit<bool>>is 2 bytes whileOption<bool>is 1.- An array of uninitialized elements can be written as
[const { MaybeUninit::uninit() }; N]. There is no stableMaybeUninit::uninit_arrayfunction. MaybeUninit<T>implementsCloneonly whenT: Copy, sovec![MaybeUninit::<String>::uninit(); n]does not compile (E0277). Use(0..n).map(|_| MaybeUninit::uninit()).collect()instead.
MaybeUninit never drops its contents. If you initialize some elements
of an array of MaybeUninit<T>, you are responsible for dropping exactly
those elements, for example with assume_init_drop, including when a panic
interrupts initialization. The MaybeUninit ring buffer
exercise practices this.
Clippy has two deny-by-default lints for common mistakes: uninit_assumed_init
(for example MaybeUninit::uninit().assume_init()) and uninit_vec
(Vec::with_capacity followed immediately by set_len).
What the tests cannot check
If you build the Payload and read a field before validating the tag, the
output can still be correct and every test can still pass: in this exercise
each field accepts every initialized byte pattern. Undefined behavior is a
property of the program's rules, not something an output comparison can
observe. The starter fails because rustc requires unsafe around union
reads, not because a test detected undefined behavior.
Passing requires compilation, correct output for every test, and Clippy with warnings treated as errors.
Your code, saved here
Sign in to access your accepted solutions and saved draft history. You can keep reading the lesson without an account.
Sign in to your accountStuck?
Rust reference solution
Sign in to track your attempts toward unlocking the reference solution.