Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

🥽Virtual Lab: Rust Enums and Closures

This virtual lab takes two of Rust’s most distinctive features (enums with pattern matching and closures) and shows what each one actually is in memory. Both ideas reduce, in the end, to a small struct the compiler synthesises for you. Once you can see the struct, the rest of the surface syntax stops being mysterious.

The lab has three connected experiments. The first is an introduction to enums and match: how a variant is laid out in memory, how match reads the discriminant, and what the niche optimisation gives you for Option<&T>. The second is a minimal introduction to closures: one example, two phases, and the single sentence “a closure is a struct with captured fields and a method”. The third extends the closure picture to Rust’s three closure traits (Fn, FnMut, FnOnce) and the move keyword, by varying what the body of the closure does.

You should already be comfortable with ownership, borrowing, and lifetimes from the earlier labs. Everything that follows builds on those ideas: enums obey the same rules as any other type, and closures borrow, mutably borrow, or move their captures using exactly the borrow checker you already know.


A short reference for the terms that appear across all three labs. Skim it now, come back as needed.

TermMeaning
EnumA type that can be one of several named variants, each optionally carrying its own data. The whole value is laid out as a tagged union.
VariantOne of the alternatives inside an enum (e.g. Some and None are the variants of Option).
DiscriminantA small integer stored inside the enum value that records which variant is currently live. Read by match.
Tagged unionA union (overlapping memory) plus a tag (the discriminant). Variants share the same bytes; the tag tells you what’s there.
Niche optimisationWhen the compiler reuses an “impossible” bit pattern of the payload (e.g. the null pointer for &T) to encode a variant, eliminating the discriminant byte.
Pattern matchingThe match construct that reads the discriminant, jumps to the matching arm, and destructures the payload into local bindings.
ExhaustivenessA compiler guarantee that a match covers every possible variant — or fails to compile.
ClosureAn anonymous function that may capture variables from its surrounding scope. Compiled to a synthesised struct (one field per capture) with a call method.
CaptureA variable from the enclosing scope that a closure stores inside its synthesised struct, either by reference (&T), mutable reference (&mut T), or by value (T).
Fn / FnMut / FnOnceThe three closure traits, forming a subset hierarchy. Fn calls via &self, FnMut via &mut self, FnOnce via self (consuming the closure).
move keywordA modifier on a closure expression that forces every capture to be by value, regardless of whether the body would otherwise need ownership.

A Rust enum is a tagged union: enough memory for the largest variant, plus a small tag that says which variant is alive. match reads the tag, jumps to the right arm, and destructures the payload into local bindings.

Three things make this lab worth doing. The first is seeing the layout: students from a C/C++ background have usually only seen union (no tag) or enum (just an integer), and Rust enums are neither. The second is seeing exhaustiveness: the compiler refuses to let you forget a variant. The third is seeing the niche optimisation: Option<&T> is the same size as &T, because the all-zero pointer pattern is illegal for a real reference and the compiler steals it to mean None.

Pick one of four enums from the top row (a custom Message, Option<i32>, Option<&str>, or Result). Then pick which variant is currently in the slot. The byte strip shows the actual layout the compiler picks; the dark code panel shows the source plus a match that destructures it. Step through the three phases (Construct, Match, Bind) to watch the discriminant being written, the matching arm being chosen, and the payload being bound to local names. Toggle “Show variant overlap” to see what a different variant would have placed in the same bytes.

Rust Enum & Match Lab

See how a Rust enum lays out in memory, then watch match read the discriminant and bind the payload.

Which enum?

Which variant is in the slot?

// Enum declaration
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

// Construct + match
let m = Message::Move { x: 10, y: 20 };
match m {
    Message::Quit => println!("quitting"),
    Message::Move { x, y } => println!("move to {x},{y}"),
    Message::Write(s) => println!("write {s}"),
    Message::ChangeColor(r, g, b) => println!("rgb({r},{g},{b})"),
}

Memory

Enum slot0x7FFFFFD832 bytes
tag01= 1+0
x··= 10+8
y··= 20+12
pad··16B+16
Write(String) is the largest variant at 24 bytes, so the slot is sized for it. Tag (8B aligned) + 24B payload = 32B total. Quit ‘wastes’ the unused 24B — that's the trade-off for fixed-size, no-allocation enums.
Inside the armlocals
x: i32
10
y: i32
20

Execution phase

Construct

A typical Rust enum: four variants, each carrying different (or no) payload. They all share one fixed-size slot in memory — the compiler reserves enough room for the largest variant.

🛠️

What's happening

Variant is chosen. The discriminant gets written, then the payload bytes go into the slot reserved for the largest variant.

📐

Exhaustiveness — the compiler's promise

Delete any arm of a match above and rustc will refuse to compile with error[E0004]: non-exhaustive patterns. Every variant must be handled (or caught by _). Add a new variant to the enum later, and every match in your codebase becomes a compile error until you handle it. This is how Rust prevents the entire class of bugs caused by forgetting a case.

Read the strip:The byte strip is the actual layout the compiler picks. The tag says which variant is alive; the payload cells overlap between variants — only one is meaningful at any time. Toggling Show variant overlap reveals what a different variant would have put in the same bytes. Option<&str> is the special case: the all-zero pointer pattern is illegal for a real reference, so the compiler steals it to mean None — no tag byte needed. C and C++ unions give you this layout but no tag and no exhaustiveness; Rust gives you both, for free.

Pick the Message scenario and select the Quit variant. The whole slot is 32 bytes, even though Quit carries no data. Why? Then switch to Write. What’s in those same 32 bytes now?

Show explanation

The slot is sized for the largest variant. In Message, that’s Write(String): a String is 24 bytes (pointer + length + capacity), and the compiler also reserves an 8-byte-aligned tag, so the total slot is 32 bytes. Every Message value (Quit, Move, Write, or ChangeColor) occupies exactly this much memory.

When Quit is in the slot, the tag byte is 0 and the remaining 24 bytes are simply unused. When Write is in the slot, the tag is 2 and the same 24 bytes hold the String’s (ptr, len, cap) triple, with the string contents ("hi") on the heap.

This is the trade-off Rust makes for fixed-size, no-allocation enums: every value is the size of the worst case. If that’s expensive (say, one variant carries a kilobyte and the others carry nothing), you can Box the large variant: Big(Box<[u8; 1024]>). The slot now only needs to hold a pointer, and the kilobyte lives on the heap when present.

Switch to Option<&str>. The slot is 16 bytes, which is exactly the size of an &str itself. Compare with Option<i32> (8 bytes for what could fit in 4). Where did the discriminant go?

Show explanation

A reference in Rust can never be the null pointer. That bit pattern is forbidden for &T. The compiler exploits this: it reuses the all-zero (ptr=0, len=0) pattern to mean None, freeing it from needing a separate tag byte. This is the niche optimisation.

So Option<&str> is laid out exactly like a bare &str:

+ 0 8 bytes ptr (0x00...0 means None)
+ 8 8 bytes len (0 in the None case)
— total: 16 bytes

Compare Option<i32>: there’s no impossible bit pattern for i32, so the compiler must add a real discriminant. The slot is 8 bytes (1 byte of tag, padded to 4 for alignment, plus the 4-byte i32) — twice the size of a bare i32.

The same trick works for Option<Box<T>>, Option<&mut T>, Option<NonZeroU32>, and any custom type with reserved bit patterns. It’s one of Rust’s most pleasing “free wins”: you write the obvious abstraction (Option<...>) and pay nothing for it.

Imagine you delete the Message::Write(s) => ... arm from the Message lab’s match. What does the compiler do? And why is that more useful than a runtime check?

Show explanation

The compiler refuses to build the program with error[E0004]: non-exhaustive patterns: 'Write(_)' not covered. The error points at the match and tells you exactly which variant you forgot.

This is exhaustiveness checking. Every match must handle every variant of the enum (or use _ to catch the rest). The check is static: it happens at compile time, not when the program runs.

The real payoff comes when you add a new variant to an enum later. Suppose you add Message::Pause six months from now. Every match on Message in your codebase becomes a compile error until you handle the new case. The compiler walks you through your own code and shows you everywhere that needs updating. Languages without exhaustive match (most of them) leave this work to runtime, typically as a bug filed by a user.

This is why Rust programmers reach for enums so eagerly: an enum + match turns “did I handle every case?” from a testing problem into a compiler problem.


Part 2: Closures, the minimum viable picture

Section titled “Part 2: Closures, the minimum viable picture”

A closure is an anonymous function that captures variables from its surrounding scope. The lesson worth internalising before anything else is what a closure literally is in memory: a small struct, synthesised by the compiler, with one field per captured variable, plus a method that runs when you invoke it. Everything else about closures ( the trait hierarchy, the move keyword, the borrow rules) is a consequence of this picture.

This intro lab has one example, two phases, and three side-by-side panels: the source you wrote, the equivalent struct + impl the compiler builds, and the live memory. The example captures an i32, which is Copy, so the question of “is this a copy or a borrow?” doesn’t yet arise. That distinction is the subject of Part 3.

There’s nothing to pick. Just step through the two phases, Define and Call, and watch all three panels update together. In Define the closure expression builds the synthesised struct and copies n into its field. In Call, add_n(5) invokes the struct’s method: a call frame opens with x = 5 as parameter, the body reads x + self.n, and the result is bound to result back in main.

Rust Closure Lab

A closure is a struct that holds the values it captured, plus a method that runs when you call it. Watch the desugaring.

Step through

Define
What you wroteRust
// 1. an ordinary local
let n = 10;

// 2. closure that captures `n`
let add_n = |x| x + n;

// 3. invoke it like a function
let result = add_n(5);
What the compiler buildsequivalent
// auto-generated for this closure
struct AddN {
    n: i32,   // the captured value
}

impl AddN {
    fn call(&self, x: i32) -> i32 {
        x + self.n
    }
}

// then your code becomes:
let add_n = AddN { n: 10 };
let result = add_n.call(5);

Memory at runtime

main()0x7FFFFFD8
ni32
10
0x7FFFFFD8
add_nAddN
captured field
n= 10
copied from main's n
resulti32
?
0x7FFFFFD0
add_n.call(x)

// call frame not yet active

🛠️

What's happening

The closure expression on the right side of `=` builds a tiny struct on the fly. Its field gets a copy of `n`'s current value (10). No body has run yet.

The takeaway:A closure is just a struct the compiler writes for you. The struct's fields are the variables you captured from the surrounding scope; its method is the closure body. Calling the closure calls the method. That's the whole picture. Coming next:How a closure captures (by value, by reference, or by move), and the three traits — Fn, FnMut, FnOnce — that classify what a closure is allowed to do with its captures. Both fall out naturally from the struct picture above.

Exercise 1: Where does n live, in two places?

Section titled “Exercise 1: Where does n live, in two places?”

Look at the memory panel during the Define phase. The variable n appears twice: once in main as an i32, and once inside the add_n struct as a captured field. Are they the same n?

Show explanation

No, they’re two separate i32s. Because i32 is a Copy type, capturing n simply copies its value (10) into the closure’s struct field. The n in main and the n inside add_n are now independent storage. Mutating one would not affect the other.

This is what makes the intro example so simple: there’s no aliasing, no borrowing, no question of “what if n goes out of scope?”. The closure carries its own copy. For non-Copy types like String or Vec, the question becomes much more interesting — and that’s exactly what Part 3 explores.

The key takeaway: the closure’s struct field is a real, separate slot in memory. It’s not a reference back to the original variable (in this case). A closure literally carries the captured data with it, packaged inside its synthesised struct.

Exercise 2: What does add_n(5) actually call?

Section titled “Exercise 2: What does add_n(5) actually call?”

In the Call phase, the desugared panel highlights fn call(&self, x: i32) -> i32. The lab calls this a “method”, but you wrote add_n(5), not add_n.call(5). What’s happening?

Show explanation

The two are equivalent. The compiler synthesises a struct (call it AddN) and an impl block with a call method. When you write add_n(5), the compiler rewrites it to add_n.call(5), which is function-call syntax on a closure value is defined as calling the synthesised method.

This unification has a satisfying consequence: ordinary functions and closures can be passed around using the same trait. A function pointer like fn(i32) -> i32 and a closure both implement the Fn(i32) -> i32 trait, and code generic over Fn(i32) -> i32 accepts both. From the type system’s point of view, an ordinary function is just a closure that captures nothing.

This is also why you can write add_n(5) repeatedly: the call goes through &self, which doesn’t consume add_n. Part 3 shows what changes when the receiver is &mut self or self — and why some closures can only be called once.


Part 3: Closures, extended (Fn, FnMut, FnOnce, and move)

Section titled “Part 3: Closures, extended (Fn, FnMut, FnOnce, and move)”

Part 2 ended on a deliberate cliffhanger: capturing an i32 was easy because i32 is Copy. The interesting question is what happens when you capture a String or a Vec. The answer turns out to depend entirely on what the body of the closure does:

  • If the body only reads the capture, the synthesised struct holds an &T, the method takes &self, and you get Fn, which is callable many times.
  • If the body mutates the capture, the struct holds an &mut T, the method takes &mut self, and you get FnMut, which is also callable many times, but only one mutable borrow at a time.
  • If the body consumes the capture (drops it, returns it), the struct holds the T itself, the method takes self, and you get FnOnce, which is callable exactly once, because the call destroys the closure.

Fn, FnMut, and FnOnce form a subset hierarchy: every Fn is also a FnMut is also a FnOnce. The badges below each scenario show which traits this particular closure satisfies.

The move keyword sits orthogonal to all of this. It forces every capture to be by value, regardless of what the body does. A move closure that only reads its capture is still Fn. It just owns its data instead of borrowing it. That’s the pattern you need when sending closures to threads or returning them from functions.

Pick one of four scenarios (Borrow, Mutate, Consume, move) and step through the three phases. The three panels match Part 2: source on the left, the desugared struct + impl in the middle, live memory on the right. Below the panels, the trait-badge row lights up the traits this closure implements, with one marked as primary. Pay close attention to the Borrow and move scenarios side by side — same body, same trait, different field type.

Rust Closure Lab — Fn, FnMut, FnOnce, and move

How a closure captures decides what it can do — and which trait it implements. Same struct picture, three different `self` receivers.

What does the body do with the capture?

Step through

Define
What you wroteRust
let s = String::from("hi");
let f = || println!("{s}");
f();
f();                   // ✓ can call many times
println!("{s}");       // ✓ s is still here
What the compiler buildsequivalent
struct ClosureF<'a> {
    s: &'a String,        // captured by &
}

impl<'a> Fn<()> for ClosureF<'a> {
    fn call(&self, _: ()) {
        println!("{}", self.s);
    }
}

Memory at runtime

main()0x7FFFFFD8
borrowed by f
sString
"hi" → heap
0x7FFFFFD8
fClosureF
captured field · &String
s→ main's s
&-reference back to main
heap0x00603000
"hi"
f.call()

// call frame not yet active

Which traits does this closure implement?

Fn ⊂ FnMut ⊂ FnOnce
Fnprimary

callable many times via &self — only reads its captures

FnMut

callable many times via &mut self — may mutate its captures

FnOnce

callable once via self — may consume its captures

💡

Why this scenario lands where it does in the hierarchy

Body only READS the capture, so the field can be a shared reference. Many readers are fine; the closure can run as many times as you like, and the original `s` is still usable in main.

The pattern:The closure body is the contract — what it does to its captures decides everything else. Reads only? The synthesised struct holds &T fields, the method takes &self, and you get Fn. Mutates? Fields become &mut T, method takes &mut self, you get FnMut. Consumes? Fields are T, method takes self, you get only FnOnce. The move keyword is orthogonal — it forces capture-by-value but the body still decides which trait you end up with. Every rule about closures in Rust falls out of this picture.

Exercise 1: Why can’t consume be called twice?

Section titled “Exercise 1: Why can’t consume be called twice?”

In the Consume scenario, the closure’s body is drop(s) and the trait badge shows only FnOnce. Step through to the After phase: f is rendered as consumed and crossed out. Why specifically does one call destroy f, when in the Mutate scenario the closure is also storing real data and those calls don’t destroy it?

Show explanation

It comes down to the self receiver of the synthesised method. In the Mutate scenario, the method is fn call_mut(&mut self, ...). It takes self by mutable reference. After the call returns, self (and therefore f) is still there, ready for another call.

In the Consume scenario, the method is fn call_once(self, ...). It takes self by value. Calling it transfers ownership of the closure into the call. After the call returns, the closure has been moved into the function and dropped along with its captured fields. There’s nothing left to call again.

This isn’t a special rule the compiler invented for closures; it’s the same ownership rule you’ve already seen for any other value. let x = f(); followed by let y = f(); would fail with the same borrow of moved value error you’d get from any other moved binding. The FnOnce trait is just the type system’s way of recording: “this thing’s call method takes self, so calling it consumes it.”

The reason the body forces this is that drop(s) itself takes s by value. The body owns s outright, which means the struct must own s outright (not borrow it), which means the call must take self (so the body can move s out of self.s). The whole chain follows from one fact about the body.

Exercise 2: Borrow vs move (same trait, different field)

Section titled “Exercise 2: Borrow vs move (same trait, different field)”

The Borrow and move scenarios both implement all three traits (Fn, FnMut, FnOnce) and both have bodies that just print s. But the synthesised structs are different. What’s different, and why does it matter?

Show explanation

In Borrow, the struct’s field is s: &'a String (a reference back to main’s s. In move, the field is s: String) the closure owns its own String outright, with main’s original s having been moved into it.

Both closures still implement Fn, because the body in both cases only reads through &self.s. The trait hierarchy is decided by the body, not by the storage class of the fields. But the lifetime of the two closures is very different:

  • The Borrow closure is tied to main’s s. It cannot outlive main. You cannot return it from a function or hand it to a thread. The borrow checker will refuse, because the reference inside the closure would dangle.
  • The move closure carries its String with it. It has no lifetime ties to anywhere; it’s self-contained. You can return it, store it, send it to a thread.

This is exactly why move exists. It’s not about changing the trait (the body decides that); it’s about changing the storage so the closure is independent of its original scope. The cost is one extra heap allocation per closure (because each closure now owns its String); the benefit is that the closure is portable.

A useful slogan: the body decides the trait; move decides the lifetime.

Exercise 3: When does the mutation in Mutate become visible?

Section titled “Exercise 3: When does the mutation in Mutate become visible?”

In the Mutate scenario, watch v in main across all three phases. In Define, v is vec![1] and shown as &mut by f. In Call, v is [1, 2, 2] but still shown as &mut by f. In After, v is still [1, 2, 2] but now shown plainly without the borrow badge. What’s the rule that ties this picture together?

Show explanation

The mutation actually lands in v the moment the body runs as there’s only one Vec<i32> in memory (the one in main), and the closure is mutating it through an &mut reference. So in the Call phase, v is genuinely [1, 2, 2] already.

What isn’t available until the After phase is reading v from main’s perspective. While the closure f exists and holds an &mut Vec<i32>, Rust’s borrow rules forbid any other access to v. You can’t print it, you can’t pass it elsewhere, you can’t take another reference. The borrow is exclusive. That’s why the lab shows v as borrowed in both Define and Call.

In the After phase, f has gone out of scope. Its &mut borrow is released. v is once again accessible to the rest of main, and you can finally println!("{:?}", v) and observe the changes the closure made.

This is the borrow checker doing its usual job: the unique-mutable-borrow rule applies to closures exactly as it does to any other code that holds an &mut. A FnMut closure is just a way of packaging up an &mut borrow with some code to run through it. When the closure dies, the borrow ends, and the underlying data becomes freely accessible again.


You’ve now seen the same idea in three guises:

The Rust enum is a tagged union, which is a fixed-size slot big enough for the largest variant, plus a tag that says which variant is alive. match reads the tag and destructures the payload. The compiler enforces exhaustiveness: every variant must be handled, or you don’t get to compile. And where the type system can prove a bit pattern is impossible (a null reference, a zero NonZeroU32), the compiler reuses it as a discriminant and the enum costs nothing to wrap.

The Rust closure is a struct the compiler writes for you. Its fields are the variables you captured; its method is the body you wrote. How the body uses each capture decides the field type (&T, &mut T, or T) and that in turn decides the method’s self receiver (&self, &mut self, or self) and that decides which of the three closure traits applies. The move keyword sits next to all of this, switching every capture to by-value storage so the closure can outlive the scope that built it.

The two ideas connect at a deeper level than they look. Both are examples of Rust giving you a compact, expressive surface syntax that desugars to something boring and concrete: a struct with fields. In Rust, “concrete” is the highest compliment. It means the compiler can check it, optimise it, and tell you exactly when it’s wrong.

Concept Match

Match the Rust Concepts

Drag each definition into its matching concept slot, then click Submit. Tap × to return a placed card to the pool.

Discriminant
drag a definition here…
Niche optimisation
drag a definition here…
Exhaustiveness
drag a definition here…
Capture
drag a definition here…
FnOnce
drag a definition here…
`move` keyword
drag a definition here…

Definition Pool

Reusing an impossible bit pattern of the payload to encode a variant, eliminating the discriminant byte.
The compiler's guarantee that a `match` covers every variant — or fails to compile.
A variable from the enclosing scope that a closure stores inside its synthesised struct.
A closure trait whose call method takes `self`, consuming the closure on the first invocation.
A modifier that forces every closure capture to be stored by value, regardless of what the body does.
A small integer stored inside an enum value that records which variant is currently live.
Knowledge Check

Why is the size of Option<&str> exactly the same as the size of &str, while Option<i32> is twice the size of i32?

Knowledge Check

In the closure `let add_n = |x| x + n;` (where `n` is an `i32` captured from the enclosing scope), what does the compiler actually build?

Knowledge Check

A closure's body calls `vec.push(2)` on a captured `Vec<i32>`. Which closure trait is the *most specific* trait that this closure implements, and why?