Algebraic Data Types: Structs, Enums & Memory Dynamics

Master Product and Sum types, mathematical state spaces, discriminant tags, and Null Pointer Optimization (NPO).

01. Foundations & Type System Mechanics7 min read

Rust leverages Algebraic Data Types (ADTs) to model domain concepts with mathematical precision. Instead of relying on class inheritance or untyped unions, Rust programs represent state through two core primitives: Product Types (Structs) and Sum Types (Enums).

Option<&T> Tag OverheadZero-Cost
0 Bytes

Niche optimization uses the 0x0 invalid bit pattern for None, eliminating discriminant tags.

1. Product Types vs Sum Types#

In type theory, types can be combined using two primary algebraic operations:

Architecture Diagram
Rendering diagram...

Product Types (Structs)#

A struct combines multiple field types into a single unit. For a struct SS containing fields F1,F2,…,FkF_1, F_2, \dots, F_k, the total number of possible states (cardinality ∣S∣|S|) is the Cartesian product of the field domains:

∣S∣=∏j=1k∣Fj∣|S| = \prod_{j=1}^{k} |F_j|

For example, a struct with a bool (2 states) and a u8 (256 states) has ∣S∣=2×256=512|S| = 2 \times 256 = 512 distinct states.

Sum Types (Enums)#

An enum expresses an exclusive choice between a closed set of variants. For an enum EE with variants V1,V2,…,VmV_1, V_2, \dots, V_m, the cardinality ∣E∣|E| is the sum of the variant cardinalities:

∣E∣=∑i=1m∣Vi∣|E| = \sum_{i=1}^{m} |V_i|

This eliminates illegal states at compile time: a value can never be in two variants at once.

2. Memory Layout of Enums#

An enum is stored in hardware as a tagged union. It consists of two components:

  1. Discriminant (Tag): An integer identifying which variant is currently active.
  2. Payload: A memory union sized to hold the largest variant payload.
S(E)=S(Discriminant)+max⁡(S(V1),S(V2),…,S(Vm))+PalignS(E) = S(\text{Discriminant}) + \max\bigl(S(V_1), S(V_2), \dots, S(V_m)\bigr) + P_{\text{align}}
1
2
3
4
5
6
7
8
9
10
11
12
use std::mem::size_of; enum Message { Quit, // 0 bytes payload Move { x: i32, y: i32 }, // 8 bytes payload (two i32) Write(String), // 24 bytes payload (ptr + len + cap) } fn main() { println!("String size: {} bytes", size_of::<String>()); // 24 bytes println!("Message size: {} bytes", size_of::<Message>()); // 32 bytes (24B payload + 1B tag + 7B padding) }
Memory Inflation Warning
Because enum size is determined by its largest variant, putting a single massive payload inside an otherwise lightweight enum forces every instance of that enum to occupy the maximum size. To mitigate this, box the large variant: Write(Box<LargePayload>).

3. Niche Filling & Null Pointer Optimization#

One of Rust's most elegant compile-time optimizations is Null Pointer Optimization (NPO), also known as niche filling.

Certain types have invalid bit patterns (niches). For example, a Rust reference &T or standard NonNull<T> can never be null (address 0x00000000). The compiler detects this invariant and uses the zero address to represent the None variant of Option<&T>:

TypeValid Pointer ValuesNone RepresentationTotal Memory Size
&TNon-zero memory addressesN/A8 bytes (64-bit)
Option<&T>Non-zero memory addresses0x00000000000000008 bytes (Zero overhead!)
*const T (Raw pointer)Any address including nullCannot use NPORequires extra tag bytes

4. Struct Field Reordering#

In C, structs are laid out strictly in declaration order, which can introduce massive padding bloat if small fields alternate with large fields.

By default, the Rust compiler reorders fields in decreasing order of alignment size:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use std::mem::size_of; // Rust layout (default): rustc reorders to minimize padding struct Optimized { a: u8, // 1 byte b: u64, // 8 bytes c: u16, // 2 bytes } // C-compatible layout: fields preserved in exact order #[repr(C)] struct Unoptimized { a: u8, // 1 byte (+ 7 bytes padding!) b: u64, // 8 bytes c: u16, // 2 bytes (+ 6 bytes tail padding!) } fn main() { println!("Optimized size: {} bytes", size_of::<Optimized>()); // 16 bytes println!("Unoptimized size: {} bytes", size_of::<Unoptimized>()); // 24 bytes }

5. Concept Verification#

Test your understanding of Algebraic Data Types and compiler layout optimizations:

Concept check

What makes Option<&T> occupy the exact same memory footprint as a bare reference &T?

Concept check

If an enum has three variants with payload sizes 0 bytes, 8 bytes, and 128 bytes, what dictates the overall memory footprint of the enum?