Skip to content

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

Test MDX File V7

Run scenario 3 · RAII unwinding and watch ~Resource() fire during the unwind — that’s the foundation of std::unique_ptr and std::lock_guard. Now run scenario 7 · noexcept violation: the same throw with the same local Resource never gets a destructor call — noexcept is a contract, not a hint, and breaking it skips cleanup entirely. Compare scenarios 3 and 4: identical code in f(), but whether main() catches decides between graceful recovery and std::terminate

C++ Exception Lab

Step through throws, catches, unwinding, and terminate — see what really happens to the stack.

Scenario

1 · Throw & catch

A throw inside a try block is caught by the matching catch — same function, no unwinding across frames.

Timeline

event 1 / 6

Running

Step 1 of 6

Call stacknewest on top
main()line 1· live
(no locals)
Source
1int main() {
2 try {
3 std::cout << "before throw\n";
4 throw std::runtime_error("oops");
5 std::cout << "unreachable\n";
6 } catch (const std::runtime_error& e) {
7 std::cout << "caught: " << e.what() << "\n";
8 }
9 return 0;
10}
Console / runtime trace
→ enter main()
Deep dive:When the throw expression evaluates, control transfers immediately to the matching catch — the line after the throw is never reached. Because the throw is in the same function as the try, no frames are popped. The catch parameter is bound to the exception object and the program continues normally after the catch block.

Rust Ownership Lab

Move, clone, copy — and watch the compiler track who owns the heap.

Stack — fn main()owned · scoped · auto-dropped
base = 0x7FFFFFD8
Stack frame is empty. Click let s1 = String::from("hi"); to start.
Heapmanaged by Drop · freed at end of owner's scope
base ≈ 0x00603000
Heap is empty. String::from allocates here; i32 never does.

Click a statement to execute it

String — owned, moved

i32 — copied, never moved

Try this:Click let s1 = String::from("hi");, then let s2 = s1;, then println!("{}", s1); — the compiler stops you. Rust caught at compile time the use-after-free that the C++ Stack & Heap lab could only catch as runtime UB. Now reset and try the same sequence with n and m — it works, because i32 implements Copy. Same syntax, different rule, decided by the type.

Rust Borrowing Lab

Move ownership, share a reference, or take an exclusive one — and watch the rules play out.

How does the function take its argument?

// Function definition
fn take(s: String) {
    s.push_str(", world");
}

// Caller
fn main() {
    let s = String::from("hi");
    take(s);
    // s has been moved — using it here would not compile
}
Stack — main()0x7FFFE020
caller frame
s
String
ptr → "hi"
24B·E020
Stack — take()0x7FFFE000
not yet called
frame will be allocated when called
Heapone allocation, one owner
base ≈ 0x00603000
heap
String data
"hi"
0x00603000

Execution phase

Before call

Ownership transfers into the function. After the call, the caller can no longer use `s` — Rust will refuse to compile any code that does.

📐

Borrow rule for this mode

No borrows are involved. Once moved, the binding is gone for the caller. Period.

What main sees after the call

scrub to "After return" to compare
Try this:Step through all three phases for each mode. In Move, watch the heap arrow re-anchor from caller to callee on "During", then disappear when the callee returns and drops it — that s in the caller is now permanently invalid. In Shared borrow, the dashed fuchsia arrow leans on the caller's binding without taking it. In Mutable borrow, the caller's card freezes during the call (it's literally unusable from main) and unfreezes after — with the new value visible. Same call site syntax, three completely different rules, all decided at compile time.

Rust Lifetime Lab

See what the compiler sees: which references are valid, for how long, and why a dangling pointer never escapes Rust alive.

Pick a scenario

fn main() {
let r = dangle();
println!("{}", r);
}
 
fn dangle() -> &String {
let s = String::from("hi");
&s
}
Stack — main()0x7FFFE020
caller frame
no bindings yet
Stack — dangle()0x7FFFE000
not yet called
frame will be allocated when called
Heapone allocation, one owner
base ≈ 0x00603000
no allocations yet

Execution phase

Before call

The local `s` dies when `dangle` returns, but the reference outlives it. Rust spots the lifetime mismatch at compile time and refuses to build the program.

Read the brackets:Each coloured bar in the code's left margin is a lifetime — a span of execution during which a binding (or a reference) is valid. Two rules: a reference's bracket must fit entirely within the bracket of what it points at, and the compiler must be able to prove this from the source alone. Dangle fails rule 1: 'r would extend past 's. No dangle sidesteps the problem by moving the value out instead of borrowing it. Longest uses an explicit annotation 'a to tell the compiler how the input and output lifetimes relate. The C++ counterpart of Dangle compiles, runs, and crashes — usually weeks later, in production.

Rust Box Lab

Put a value on the heap, write through the box, move ownership — and watch Rust free it for you.

Stack — fn main()owned · scoped · auto-dropped
base = 0x7FFFFFD8
Stack frame is empty. Click let mut b = Box::new(10); to allocate on the heap, or let n = 10; for a stack-only int.
Heapone allocation per Box · freed when its owner drops
base ≈ 0x00603000
Heap is empty. Box::new allocates here; let n = 10 never does.

Click a statement to execute it

Stack vs heap — same value, different storage

Through the box — read and write the heap value

Move ownership — Box is not Copy

Try this:Click let n = 10; and let mut b = Box::new(10); to compare a stack int with a boxed int — same value, different memory cost. Hit *b = 99; and watch the heap value change without anything in the stack moving. Then let b2 = b; and try println!("{}", *b); — the compiler stops you. Compare with the C++ Stack & Heap lab: every disaster that lab demonstrates (forgotten delete, double-delete, use-after-free) is structurally impossible here. The shape is the same; the rules are different.

Rust Slice Lab

View a range of someone else's allocation. See exactly where slices succeed and where they don't.

Pick a scenario

fn main() {
let v = vec![10, 20, 30, 40, 50];
let s = &v[1..4];
println!("{:?}", s); // [20, 30, 40]
}
Stack — main()0x7FFFFFD8
1 binding
v
Vec<i32>
ptr → 5 i32s
24B·FFD8
Heapone allocation
heap · Vec<i32>
10
[0]
20
[1]
30
[2]
40
[3]
50
[4]
0x00603000

Step through execution

1 / 3

A slice borrows a contiguous range of someone else's allocation. The 16-byte fat pointer (ptr + length) sits on the stack; the data lives wherever the source put it. Here `s` covers v[1..4] — the elements 20, 30, 40.

Read the brackets:The coloured brackets under the heap show exactly which elements (or bytes) each slice covers. A slice is just a pointer + a length — it can stop anywhere, start anywhere, and overlap with other slices freely. The two correct scenarios show that flexibility. The two incorrect scenarios show what Rust prevents: Borrow conflict blocks any mutation that could move the heap (and so invalidate your slice); Dangling slice blocks returning a slice that would outlive what it points at. The diagrams under "❌ Does not compile" show what *would* happen — the bug Rust's borrow checker prevents.

Rust Rc Lab

Share one heap allocation between many owners. Watch the strong count tell you when the data dies.

Stack — fn main()each Rc<T> is just an 8-byte pointer
base = 0x7FFFFFD8
Stack frame is empty. Click let a = Rc::new(42); to allocate the shared value.
Heapone allocation · header carries the strong/weak counts
base ≈ 0x00603000
Heap is empty. Rc::new allocates the shared value here.

Click a statement to execute it

Allocate / share — Rc::clone bumps the strong count

Special cases — move, drop, weak

Try this:Click let a = Rc::new(42);, then clone it twice — watch strong climb to 3. Notice that no second heap block ever appears: Rc::clone bumps a counter, that's all. Then click let d = a; to see move in contrast — same syntax as everywhere else, but here it transfers ownership of an existing slot rather than making a new owner. Hit drop(b); and watch the count walk back down. End scope, and the heap is freed when the count hits 0. Note for production: if two Rcs point at each other (a cycle), the count will never reach 0 and you'll leak. Use Weak for back-pointers to break the cycle.

Rust Trait Object Lab

A trait object is a fat pointer — and the second half points at a vtable. Step through the dispatch to see how.

Scenario

Concrete type
trait Animal {
fn speak(&self) -> &str;
fn legs(&self) -> u32;
}
 
struct Dog { legs: u32 }
struct Cat { legs: u32 }
struct Snake { legs: u32 }
 
impl Animal for Dog {
fn speak(&self) -> &str { "woof" }
fn legs(&self) -> u32 { self.legs }
}
// (Cat: "meow", Snake: "hiss" omitted for brevity)
 
fn main() {
let animal: Box<dyn Animal> = Box::new(Dog { legs: 4 });
println!("{}", animal.speak());
println!("{} legs", animal.legs());
}
Stackmain()
0x7FFE…
animalBox<dyn Animal>
data0x055A0E10
vtable0x562A0010
16 bytes · two pointers (data, vtable)
HeapBox::new(...)
0x055A0E10
Dog0x055A0E10
legs4
4 bytes · the Dog value itself
Static.rodata · read-only
0x562A0010
vtableDog · Animal
.rodata0x562A0010
drop_glue→ drop_in_place
size_of4 bytes
align_of4 bytes
speak→ Dog::speak
legs→ Dog::legs
40 bytes · 5 pointer slots, never freed

Dispatch trace

At rest

Binding declared. The fat pointer is wired up: data → heap, vtable → .rodata.

Why a trait object is 16 bytes, not 8

Box<i32>
8 bytes
one pointer — size is known at compile time
Box<dyn Animal>
16 bytes
fat pointer — data + vtable
vtable for (T, Animal)
40 bytes
drop, size, align + 2 method pointers · in .rodata
Try this:In the Single trait object scenario, flip between Dog, Cat, and Snake — the stack slot doesn't move and the heap value barely changes, but the vtable arrow swings to a different table each time. That's the "dynamic" in dynamic dispatch. Then switch to the Heterogeneous Vec and watch all three vtables sit side by side: every element of the Vec is the same 16-byte slot, but each one indirects through its own table. Compare with a generic fn speak<T: Animal>(t: T) — there's no vtable involved at all, because the compiler stamps out a fresh copy of the function for every concrete T(monomorphisation). dyn is the explicit opt-in to runtime polymorphism.