Skip to content

← Ownership I: Moves, Copy, Clone, Drop step 2 of 20

Easy Primitives

Stack, heap, and what a variable actually holds

Report the size of five standard types, measured in machine words.

pub fn layout_report() -> Vec<usize>

A machine word here means size_of::<usize>() bytes: 8 bytes on a 64-bit target, 4 bytes on a 32-bit one. Thinking about a value's size in words helps explain what a variable of that type actually holds, and therefore what is copied when the value is moved.

What a String variable holds

A String stores its text in a separate heap allocation (when it has any capacity). The String value itself, the part stored in the variable, holds three words: a pointer to that buffer, the length (how many bytes are in use) and the capacity (how many bytes the buffer has room for).

  the String value                    heap buffer
  ┌───────────────┐
  │ ptr   ────────┼──────────▶  ┌───┬───┬───┬───┬───┬───┬───┬───┐
  │ len   = 5     │             │ h │ e │ l │ l │ o │   │   │   │
  │ cap   = 8     │             └───┴───┴───┴───┴───┴───┴───┴───┘
  └───────────────┘                               (3 bytes unused)

The drawing shows a String holding "hello" in a buffer with room for 8 bytes. The order of the three fields is chosen by the compiler; the drawing's order is only for illustration. When the String is a local variable, its three words are typically on the stack, but they could equally sit inside a struct, a Vec or a Box. Either way, the characters are not part of the String value; it only records where they are.

The other types in this exercise:

  • Vec<T> has the same three parts as String: a pointer, a length and a capacity. The standard library documents this for Vec.
  • Box<T>, for a sized T such as u8, is a single pointer. It needs no length or capacity because it owns exactly one T. (A Box of an unsized type such as Box<str> also stores a length, so it is two words.)
  • &str is a borrowed view of UTF-8 bytes stored somewhere else. It holds a pointer and a length, so it is two words. It has no capacity because it does not own the bytes and can never grow them. A pointer or reference that carries extra data like this length is called a fat pointer.

This also explains why let s2 = s1; moves a String instead of copying it. The assignment copies only the three words, so s1 and s2 would both point at the same buffer, and both would try to free it when they went out of scope. Rust instead treats s1 as moved: the compiler rejects any later use of it and only s2 frees the buffer. The check happens at compile time, so the move adds no allocation and no reference count.

The task

Return a Vec<usize> with exactly five entries, in this order:

index value
0 size_of::<String>() divided by size_of::<usize>()
1 size_of::<Vec<u8>>() divided by size_of::<usize>()
2 size_of::<Box<u8>>() divided by size_of::<usize>()
3 size_of::<&str>() divided by size_of::<usize>()
4 1 if size_of::<Option<Box<u8>>>() equals size_of::<Box<u8>>(), otherwise 0

The function takes no input. The tests either check the whole vector or ask for one entry by index (0 to 4). On the platforms Rust mode runs on, the expected result is [3, 3, 1, 2, 1].

Every entry is a ratio or a comparison, never a byte count. A String is 24 bytes on a 64-bit target and 12 bytes on a 32-bit target, but 3 words on both, so an answer computed in words does not depend on the machine running it. The same idea is useful when you write your own size assertions.

Entry 4: the niche

Option<T> has to record whether it is Some or None. For many types it does that with an extra tag field, which makes it larger than T. For Option<Box<u8>> no tag is needed.

A Box pointer is never null, so the all-zero bit pattern is never a valid Box. An invalid bit pattern that the compiler can reuse is called a niche. The compiler represents None with the all-zero pattern and Some(b) with the pointer itself, so Option<Box<u8>> is the same size as Box<u8>. The Option documentation guarantees this for Box<T>, &T, &mut T, NonNull<T> and the NonZero integer types, among others. Other types built on a non-null pointer, such as Rc<T>, get the same size in practice, but that is current compiler behavior rather than a documented guarantee.

Check your understanding If the niche trick works for Option<Box<u8>>, is Option<u8> the same size as u8? Show answer

No. A u8 is one byte, but Option<u8> is two.

Every one of the 256 bit patterns of a u8 is a valid value, so there is no spare pattern for None. The compiler adds a separate tag byte instead, and size_of::<Option<u8>>() is 2.

A niche exists only when a type has bit patterns it is not allowed to hold. bool uses 2 of a byte's 256 patterns, so it has many spare ones. char has invalid values too (surrogate code points and anything above 0x10FFFF). For &T, Box<T> and the NonZero types the spare pattern is zero. Plain integer types have none, which is why Option<u32> is larger than u32.

Practical notes

size_of has been in the prelude since Rust 1.80, so you do not need use std::mem::size_of;. Writing that import anyway still compiles and does not fail this problem's Clippy check, because the redundant_imports lint is allow-by-default; it is simply unnecessary.

To turn the bool for entry 4 into a usize, usize::from(flag) converts false to 0 and true to 1. flag as usize gives the same result. usize::from is often preferred because the standard library implements From between primitive number types only where no information can be lost, while as also performs lossy conversions (such as u64 as u8), so a reader has to check each as more carefully.

Your submission must compile, pass the tests and pass cargo clippy with warnings treated as errors (-D warnings).