Skip to content

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

Test MDX File V11

Rust Thread Lab

What can cross the thread boundary? Three walls — 'static, Send, and Mutex — and how to cross each.

Phase:pre-spawnspawnedjoined
Stack — mainbase = 0x7FFFFFD8
Frame is empty. Declare a binding to begin.
Heap — shared between threadsbase ≈ 0x00603000
Heap is empty. Vec, Rc, Arc, and Mutex all allocate here.

1 · Declare in main

3 · Spawn the thread

Try this:Declare v, leave its capture mode at &, and press the plain spawn. Rust refuses — the closure might outlive main. Switch to spawn move and it compiles. Now reset and try the same with rc — even with move, Rust refuses because Rc isn't Send. Swap to arc and it works. Finally, declare shared, capture it as .clone(), spawn-move, and increment through the lock — both threads now share write access, mediated by the Mutex.

Format:

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.
Rust concept demo

Move Semantics in Rust

src/main.rs
1fn take_ownership(s: String) {
2 println!("inside: {s}");
3}
4
5fn main() {
6 let s = String::from("hello");
7 take_ownership(s);
8 println!("outside: {s}");
9}
terminal — cargo build
$ cargo build
error[E0382]: borrow of moved value: `s`
--> src/main.rs:8:24
|
6 | let s = String::from("hello");
| - move occurs because `s` has type `String`,
| which does not implement the `Copy` trait
7 | take_ownership(s);
| - value moved here
8 | println!("outside: {s}");
| ^ value borrowed here after move
What the compiler is telling you

Passing a value by-value to a function **moves** it. Once `s` is moved into `take_ownership`, the binding `s` in `main` is invalidated — using it on the next line is a compile error. Notice this is the *same* mechanism as `let s2 = s;` — function parameters are just bindings, and binding to a non-`Copy` value moves rather than copies.

C++ comparison

C++ touchstone: by default C++ would copy `s` into the parameter (calling `std::string`'s copy constructor) and `s` would still be usable. To get Rust's behaviour you'd explicitly write `take_ownership(std::move(s))`, after which the C++ standard says `s` is in a "valid but unspecified state" — usable, but its contents are anyone's guess. Rust just removes the binding from the type system, so there is no zombie state to worry about.

Integrated:

Rust concept demo

Shadowing in Rust

src/main.rs
1fn main() {
2 let x = 5;
3 let x = x + 1;
4 let x = x * 2;
5 println!("x = {x}");
6}
terminal — cargo run
$ cargo run
x = 12
What just happened

Each let x = … creates a brand-new variable that happens to reuse the name x. The previous x is shadowed, still alive in memory until the new binding takes its place, but no longer reachable by that name. This compiles even though every x is immutable, because we're not mutating anything: we're introducing fresh bindings.

Rust concept demo

Copy and Clone in Rust

src/main.rs
1fn main() {
2 let x = 5;
3 let y = x;
4 println!("x = {x}, y = {y}");
5}
terminal — cargo run
$ cargo run
x = 5, y = 5
What just happened

i32 implements the Copy trait, so let y = x; makes a bit-for-bit copy of the value and both bindings stay valid afterwards. Copy is reserved for types whose whole value lives on the stack with no owned heap data — primitives (i32, f64, bool, char), shared references (&T), and tuples or arrays of Copy types. The duplication is cheap and safe, so Rust just does it for you on assignment.

Rust concept demo

Immutable & Mutable Borrows in Rust

src/main.rs
1fn main() {
2 let s = String::from("hello");
3 let r1 = &s;
4 let r2 = &s;
5 let r3 = &s;
6 println!("{r1} / {r2} / {r3}");
7}
terminal — cargo run
$ cargo run
hello / hello / hello
What just happened

A shared borrow &s gives read-only access to s without taking ownership. As many &s references can coexist as you like — they all see the same value, none can change it, and s itself stays usable too. Read-only access is safe to share, so Rust doesn't restrict it.

Rust concept demo

Lifetimes in Rust

src/main.rs
1fn main() {
2 let r;
3 {
4 let x = 5;
5 r = &x;
6 }
7 println!("r = {r}");
8}
terminal — cargo build
$ cargo build
error[E0597]: `x` does not live long enough
--> src/main.rs:5:13
|
4 | let x = 5;
| - binding `x` declared here
5 | r = &x;
| ^^ borrowed value does not live long enough
6 | }
| - `x` dropped here while still borrowed
7 | println!("r = {r}");
| - borrow later used here
What the compiler is telling you

Every value has a lifetime — the span of code during which it is alive. x is declared inside the inner block, so its lifetime ends at the closing }. But r is declared in the outer scope and is still in use on the println! line. A reference cannot outlive what it points at, so the compiler refuses. This is the classic dangling-pointer bug — caught at compile time, before the program ever runs.

Rust concept demo

Array & String Slices in Rust

src/main.rs
1fn main() {
2 let arr = [10, 20, 30, 40, 50];
3 let s: &[i32] = &arr[1..4];
4 println!("{s:?}");
5}
terminal — cargo run
$ cargo run
[20, 30, 40]
What just happened

A slice is a view into a contiguous run of elements — it doesn't own anything, it just borrows. The range 1..4 is half-open: it includes index 1 but stops before index 4, giving you elements at positions 1, 2, and 3. Under the hood s is a 16-byte fat pointer: an address (pointing at arr[1]) plus a length (3). No allocation, no copy — slicing is essentially free.

Rust Mutability Lab

Toggle mut on the binding and on the reference. To mutate through r, you need both.

Sourcequadrant: x (immutable) + no reference
let x: i32 = 5;
Stackone binding · optional reference
base = 0x7FFFFFD8
x
i32
5
4B·FFD8

Configure the source

x type
x mut?
r
r borrow

Run a statement

Try this:Set x mut? to let x, declare r, and set its borrow to &mut x. Notice the red banner: Rust refuses, because you can't hand out a mutable reference to an immutable binding. Now flip x mut? to let mut x and the configuration compiles. Press *r = … and watch the value update through the reference. Finally, switch x type to String. The rules are identical, but the action becomes r.push_str(", world") and the heap data updates instead.

Rust Memory Lab

Drag a Rust type onto the stack. Owned types — Box, String, Vec — also claim space on the heap.

Stackbase = 0x7FFFFFE0
32 / 32 bytes used0 free
+0
+8
+10
+18
a
0x00603000
b
ptr
0x00603004
len
0x05
cap
0x05
byte 0
8
16
24
Heapbase = 0x00603000
9 / 32 bytes used23 free
+0
+8
+10
+18
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
*a
42
*b
"hello"
byte 0
8
16
24

Type Palette — drag onto the stack

bool1B
i81B
char4B
i324B
f324B
i648B
f648B
Box<i32>8B + heap
String24B + heap
Vec<i32>24B + heap
integer types
floating point
character
boolean
owned (heap-backed)

Declared bindings

TypeNameValueStack addrStackHeap
Box<i32>a420x7FFFFFE08 B4 B
Stringbhello0x7FFFFFE824 B5 B
Try this:Click Worked example. Notice that a: Box<i32> stores an 8-byte pointer on the stack and just 4 bytes on the heap — the value lives over there. b: String is the heaviest: 24 bytes of stack record (pointer + length + capacity) plus the actual UTF-8 bytes on the heap. Together they fill the 32-byte stack exactly. Now clear all and drag an i32 in — it claims 4 stack bytes and zero heap. Then drag a char — it claims 4 bytes too, not 1. Rust's char is a Unicode scalar value, not a byte.
Rust concept demo

Idiomatic Error Handling in Rust

src/main.rs
1fn main() {
2 let nums = vec![1, 2, 3, 4, 5];
3
4 match nums.iter().find(|&&n| n > 3) {
5 Some(n) => println!("found {n}"),
6 None => println!("nothing found"),
7 }
8}
terminal - cargo run
$ cargo run
found 4
What just happened

Anything that might be absent in Rust is wrapped in Option<T>: there are only two possibilities, Some(value) or None, and the compiler forces you to handle both. match is the standard way to do it: each arm pulls the value out (if any) and decides what to do. Compare to languages where the same code returns null and you can dereference it without thinking. Rust just doesn't let you reach the value without first acknowledging it might not be there.

Rust concept demo

Generics & Trait Bounds in Rust

src/main.rs
1fn largest<T: PartialOrd>(list: &[T]) -> &T {
2 let mut biggest = &list[0];
3 for x in list {
4 if x > biggest { biggest = x; }
5 }
6 biggest
7}
8
9fn main() {
10 let nums = vec![34, 50, 25, 100, 65];
11 println!("largest = {}", largest(&nums));
12}
terminal - cargo run
$ cargo run
largest = 100
What just happened

<T: PartialOrd> declares a type parameter T together with a trait bound - any type used as T must implement PartialOrd, the trait that supplies <, >, <=, and >=. The bound is the contract the compiler needs in order to allow x > biggest inside the body. Without it, the compiler couldn't know whether > is even defined for whatever T turns out to be - see scenario 3 for what happens if you forget.

C++ comparison

C++ touchstone: this is a function template - template <typename T> T const& largest(std::vector<T> const& v). Pre-C++20, the constraint was implicit and only surfaced as a wall of template-instantiation errors when > failed for some T. With C++20 concepts (requires std::totally_ordered<T>) you can finally say the constraint up front - exactly what Rust's trait bounds have done since day one.

Rust concept demo

Traits & `impl` in Rust

src/main.rs
1trait Greet {
2 fn hello(&self);
3}
4
5struct Dog;
6
7impl Greet for Dog {
8 fn hello(&self) {
9 println!("woof!");
10 }
11}
12
13fn main() {
14 let d = Dog;
15 d.hello();
16}
terminal — cargo run
$ cargo run
woof!
What just happened

A `trait` declares a set of method signatures any type can opt in to. An `impl Trait for Type` block supplies the bodies. Call the method on a value with the usual `d.hello()` dot-syntax — the compiler resolves it to `Dog`'s implementation at compile time. There's no inheritance involved: `Dog` is a plain struct that just happens to satisfy the `Greet` contract.

C++ comparison

C++ touchstone: a Rust trait is conceptually a pure-virtual interface or a C++20 concept. The `impl Greet for Dog` block is the equivalent of `class Dog : public Greet` plus method definitions — but Rust splits the type definition and the interface conformance into separate blocks, so `Dog` doesn't need to know about `Greet` at the point it's defined.

Rust concept demo

String vs &str in Rust

src/main.rs
1fn main() {
2 let lit: &str = "hello";
3 let owned: String = String::from("hello");
4
5 println!("{lit} ({} bytes long)", lit.len());
6 println!("{owned} ({} bytes long)", owned.len());
7}
terminal - cargo run
$ cargo run
hello (5 bytes long)
hello (5 bytes long)
What just happened

Rust has two string types and the difference is ownership, not content. &str is a borrow, a 16-byte fat pointer (data ptr + length) into bytes that live somewhere else; for a literal, those bytes are baked into the program's read-only data segment. String is an owned, growable buffer on the heap, represented on the stack as 24 bytes (ptr + length + capacity). Same characters, same .len(), very different storage and ownership stories.

C++ comparison

C++ touchstone: String is std::string, an owning, heap-backed, growable buffer. &str is closest to std::string_view (C++17): a non-owning view of someone else's bytes. The standard library and idiomatic APIs in modern C++ have been moving the same direction Rust started: take a view when you only need to read.