🥽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.
Glossary
Section titled “Glossary”A short reference for the terms that appear across all three labs. Skim it now, come back as needed.
| Term | Meaning |
|---|---|
| Enum | A 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. |
| Variant | One of the alternatives inside an enum (e.g. Some and None are the variants of Option). |
| Discriminant | A small integer stored inside the enum value that records which variant is currently live. Read by match. |
| Tagged union | A union (overlapping memory) plus a tag (the discriminant). Variants share the same bytes; the tag tells you what’s there. |
| Niche optimisation | When 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 matching | The match construct that reads the discriminant, jumps to the matching arm, and destructures the payload into local bindings. |
| Exhaustiveness | A compiler guarantee that a match covers every possible variant — or fails to compile. |
| Closure | An anonymous function that may capture variables from its surrounding scope. Compiled to a synthesised struct (one field per capture) with a call method. |
| Capture | A 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 / FnOnce | The three closure traits, forming a subset hierarchy. Fn calls via &self, FnMut via &mut self, FnOnce via self (consuming the closure). |
move keyword | A modifier on a closure expression that forces every capture to be by value, regardless of whether the body would otherwise need ownership. |
Part 1: Enums and pattern matching
Section titled “Part 1: Enums and pattern matching”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.
How to use this lab
Section titled “How to use this lab”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.
Exercise 1: How big is Message::Quit?
Section titled “Exercise 1: How big is Message::Quit?”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.
Exercise 2: Why is Option<&str> so small?
Section titled “Exercise 2: Why is Option<&str> so small?”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 bytesCompare 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.
Exercise 3: Forgetting a variant
Section titled “Exercise 3: Forgetting a variant”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.
How to use this lab
Section titled “How to use this lab”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.
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 getFn, which is callable many times. - If the body mutates the capture, the struct holds an
&mut T, the method takes&mut self, and you getFnMut, 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
Titself, the method takesself, and you getFnOnce, 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.
How to use this lab
Section titled “How to use this lab”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.
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’ss. It cannot outlivemain. 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
Stringwith 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.
Wrap-up
Section titled “Wrap-up”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.
🧩Knowledge Check
Section titled “🧩Knowledge Check”Match the Rust Concepts
Why is the size of Option<&str> exactly the same as the size of &str, while Option<i32> is twice the size of i32?
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?
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?
© 2026 Derek Molloy, Dublin City University. All rights reserved.