rustc::study — for PHP & Python devs
score 0/0

A study companion · Rust for people who write PHP or Python

Your code won't compile. That's the whole point.

In PHP and Python, mistakes show up when a user hits the page. In Rust, a large class of them shows up before the program exists. Learning Rust is mostly learning to read what the compiler is telling you — so let's start there, with an error you will definitely meet.

main.rslooks completely reasonable
fn main() {
    let greeting = String::from("Bonjour");
    shout(greeting);
    shout(greeting);
}

fn shout(text: String) {
    println!("{}!", text.to_uppercase());
}

Two calls, same value. In Python or PHP this prints BONJOUR! twice and nobody raises an eyebrow. Guess what Rust does before you continue.

Part 1 · Foundations — §1 of 4

Compiled, not interpreted

Objective

Understand what happens between saving a file and running it, and why that gap is where Rust does all its work.

Your current mental model is probably: save the file, hit refresh or run the script, see what happens. The program is read line by line as it executes. If line 300 has a typo, you find out when line 300 runs — possibly in production, possibly at 3am.

Rust inserts a step. You save the file, then a program called rustc reads all of it, checks it, and translates it into machine code for your specific processor. Only then can it run. Nothing is interpreted, nothing is looked up by name at runtime, and there is no interpreter shipped alongside your code. You get a single executable file — that is what you deploy.

Python / PHP

Source ships to the server. An interpreter reads it each time. A syntax error in a rarely used branch can sit there for months.

Rust

Source is checked and compiled once, on your machine or in CI. A binary ships. That branch was checked whether or not it ever runs.

This is why Rust feels slow to write and fast to run. The compiler is doing work you used to do — by testing, by review, by getting paged. Expect to spend your first week arguing with it and your second week grateful for it.

The toolbox, in words you already know

RustYou know it asWhat it does
rustupnvm, pyenvInstalls and switches Rust versions.
cargocomposer, pip + makeDependencies, building, running, testing — one tool.
Cargo.tomlcomposer.json, pyproject.tomlDeclares your package and its dependencies.
Cargo.lockcomposer.lock, poetry.lockPins exact versions. Commit it for applications.
cratepackage on Packagist / PyPIA unit of Rust code. Published on crates.io.
cargo checkType-checks without building. Your fastest feedback loop.
Learn more — why cargo check changes how you work

Compiling to a runnable binary takes time; verifying that your code is correct takes much less. cargo check does only the second part. The practical loop for a beginner is: write a little, run cargo check, read the error, fix, repeat — and only run cargo run when it's quiet.

Add cargo clippy once you're comfortable. It is a linter that goes beyond correctness into idiom, and it teaches you the language's taste. Treat its suggestions as a free code review, not as nagging.

Part 1 · Foundations — §2 of 4

Where your values live

Objective

Build a physical picture of the stack and the heap. Almost every Rust rule that confuses newcomers is obvious once this picture is in place.

PHP and Python hide memory from you completely, and that is usually a kindness. Rust does not, so you need one image in your head. Here it is.

The stack is a stack of trays in a canteen. You add a tray on top, you take a tray off the top, and that is all you can do. It's extremely fast because "where does this go" is never a question — it goes next. The catch: every tray must be a known, fixed size, decided before the program runs.

The heap is a large warehouse. You ask for space, someone finds a shelf big enough, and hands you back a shelf number. Slower, because searching takes time and the number has to be followed to reach the goods. But the size can be anything, and it can grow.

Stack and heap A stack frame holds an integer directly and holds a String as three fields — pointer, length and capacity — where the pointer refers to a buffer of seven characters stored on the heap. STACK fixed sizes · ordered · fast HEAP any size · can grow · slower let count = 7; count 7 i32 · 4 bytes let name = String::from("Bonjour"); name ptr len capacity 0x7f…a20 7 7 the actual text, on the heap B o n j o u r 7 bytes. Nothing here knows its own name — the stack side holds the only handle to it.
A String straddles both worlds: a fixed-size handle on the stack, a growable buffer on the heap. Keep this picture — ownership is entirely about who is responsible for that buffer.

Now the key insight. That stack-side handle is small and copyable. The heap buffer is not. If two handles pointed at the same buffer and both tried to free it, you'd have a bug that has cost the industry billions. Every ownership rule you're about to learn exists to make that impossible without a garbage collector.

Learn more — you already use references, you just can't see them

In Python, b = a for a list doesn't copy the list; both names point at the same object, which is why mutating through b surprises people. In PHP, objects behave the same way, and &$x exists precisely because arrays don't.

So references are not new to you. What's new is that Rust makes the distinction between "the value" and "a reference to the value" visible in the type — String versus &String — and then checks that every reference is still valid. The concept is familiar; the bookkeeping is what's new.

Part 1 · Foundations — §3 of 4

Who cleans up after you

Objective

See ownership as one of three answers to an old question, so it feels like a design decision rather than an arbitrary obstacle.

Every language must answer: when heap memory is no longer needed, who frees it? There are exactly three strategies in wide use, and you have been enjoying the second one.

Strategy one

You do it, by hand

C and C++. You call free() at the right moment. Free too early and you read garbage; free twice and you corrupt the allocator; never free and you leak. Fast, and the source of a large share of security vulnerabilities.

Strategy two

A runtime does it

PHP, Python, Java, Go. Something counts references or traces reachable objects and frees the rest. You never think about it. The cost is memory overhead, occasional pauses, and no control over when cleanup happens.

Strategy three

The compiler does it

Rust. Each value has exactly one owner; when the owner goes out of scope, the value is freed — and the compiler proves at build time that nothing still refers to it. No runtime, no pauses, no leaks by default.

Strategy three has no runtime cost at all. It is paid entirely at compile time, in your patience. That is the trade Rust asks you to make, and it's a genuine trade — not a free lunch and not a con.

The habit to unlearn

In Python you write a function that returns a reference to a local thing and it just works, because the object outlives the function as long as someone holds it. In Rust, a borrow is not a co-owner. It's a promise that something else is keeping the value alive. Break the promise and the code doesn't compile.

Learn more — where PHP and Python already leak

CPython uses reference counting plus a cycle collector, and PHP does something similar. Reference counting cannot free a cycle on its own: if a holds b and b holds a, both counts stay at one forever. That's why both languages need an extra collector pass.

Rust hits the same wall in the same place. When you eventually reach for Rc<T> (module 12), you are opting into reference counting — and you inherit the cycle problem, which is why Weak<T> exists. The trade-offs are universal; only the defaults differ.

Part 1 · Foundations — §4 of 4

Types, mutability, and the absence of null

Objective

Adjust three expectations you've built up in dynamic languages, before they cause confusion in module 2.

Types are checked before the program exists

A type in Rust isn't documentation and it isn't a hint — it's a claim the compiler verifies. fn total(items: &[Order]) -> u32 guarantees that items is a list of orders and the result is a non-negative whole number. Not "should be". Cannot be otherwise. There is no TypeError at runtime because there is no runtime type to get wrong.

And nothing converts itself quietly. "5" + 5 is a compile error, not 10 and not "55". If you want a number from a string you ask for one, and you handle the case where it isn't one. This is the single biggest source of "why is Rust so verbose" — and the single biggest source of its reliability.

Immutable by default

let x = 5; creates a binding you cannot reassign. To allow change you write let mut x = 5;. Reversed from what you're used to, and deliberately so: the default is the safe one, and mut becomes a visible marker of where state changes. When you read Rust, mut tells you where to look for surprises.

There is no null

No null, no None lurking in a variable that claims to hold a user, no undefined. A value of type User is a user. If it might be absent, the type says so out loud: Option<User>, which you cannot use as a User until you've handled the empty case. An entire family of 2am bugs simply cannot be expressed.

Same story for errors. Rust has no exceptions. A function that can fail returns Result<T, E> — a value that is either the answer or the reason there isn't one — and ignoring it produces a warning. Failure becomes part of the signature instead of invisible control flow.

What you're used to

# Python — the danger is invisible
user = find_user(uid)
print(user.email)
# AttributeError, three weeks later

What Rust makes you write

// the absence is in the type
match find_user(uid) {
    Some(user) => println!("{}", user.email),
    None => println!("no such user"),
}

Glossary · foundations

compile
Translate source into machine code, checking everything on the way. Done once, before running.
binary
The single executable file the compiler produces. What you deploy.
stack
Fast, ordered memory for fixed-size values that live for a known span.
heap
Flexible memory for values whose size or lifetime isn't known up front.
pointer
A value holding the address of another value. In Rust you nearly always meet it as a reference.
reference
A checked pointer, written &T. Guaranteed to point at something valid.
allocate / free
Reserve heap space, and hand it back. In Rust, both happen automatically at points the compiler decides.
owner
The single binding responsible for a value's cleanup.
crate
A Rust package: a library or an executable.

Part 1 · Checkpoint

Before you go on

Three questions. If any of them feel like a coin flip, the section above is worth a second pass — module 3 will be much harder without them.

Checkpoint · 3 questions

A colleague says "Rust is fast because it has no garbage collector." What's the more accurate version of that claim?

B. The work is moved to compile time, not removed. Cleanup code is inserted at the points where owners go out of scope — decided while building, executed with no bookkeeping at runtime.

C is the common half-truth: Rust uses the heap constantly (every String and Vec does). D describes an arena or a leak, which is a real technique but not Rust's default.

Why does a String keep its length on the stack while the characters live on the heap?

C. The stack needs fixed sizes known at compile time. "Pointer, length, capacity" is always the same size no matter how long the text gets, so the handle fits on the stack and the variable-length part goes on the heap.

D is worth dismissing explicitly: the borrow checker reasons about both, and it never looks at addresses at all — it reasons about scopes in your source code.

In Rust, let total = 0; followed later by total = total + 1; fails to build. What's the reason?

A. Add mut and it compiles. This one catches everybody once.

C sounds sophisticated and is wrong for a reason worth knowing now: integers are copied, not moved, so reading one never invalidates it. D describes shadowing, which is legal and does something different — you'd get a new binding rather than changing the old one.

Part 2 · Twelve modules

The sequence, and why it's in this order

Modules 1 and 2 get you writing. Modules 3, 4 and 5 are the ones people quit over — they're placed early and given the most room, because everything afterwards assumes them. Modules 6 to 9 replace habits you brought with you. Modules 10 to 12 are where Rust starts feeling expressive rather than restrictive.

Each module has an objective, an idea explained from intuition, a worked example, a glossary, one exercise, and a quiz. Do the exercise before revealing the solution — reading Rust and writing Rust are different skills, and only one of them is being tested by the quiz.

ModuleDifficultyWhat it unlocks
1 · Toolchain & first programgentleRunning code at all.
2 · Values & typesgentleReading any snippet.
3 · Ownershipthe wallEvery error message you'll get in week one.
4 · Borrowingthe wallWriting functions that take arguments.
5 · Lifetimesthe wallStructs that hold references; reading library docs.
6 · Structs & enumsmoderateModelling your own domain.
7 · OptionmoderateLife without null.
8 · Result & ?moderateLife without exceptions.
9 · Strings & collectionsmoderateGetting real work done — and the biggest trap for you specifically.
10 · Traits & genericsmoderateReuse without inheritance.
11 · Iterators & closuresfamiliarIdiomatic, fast data pipelines.
12 · Sharing & concurrencydeep endThreads without fear; the escape hatches.

Module 01 · gentle

The toolchain and your first program

Objective

Create, run and inspect a Rust project, and be able to name every part of a minimal program.

Install rustup from rust-lang.org/tools/install; it brings the compiler and Cargo with it. Then everything happens through Cargo, which does the job of Composer, pip, and your Makefile at once.

terminalfour commands, whole workflow
cargo new invoicer      # make a project (also runs git init)
cd invoicer
cargo check             # type-check only — fast, use constantly
cargo run               # build and run

You get Cargo.toml (your manifest) and src/main.rs. The presence of main.rs is what makes this an executable rather than a library — Cargo works by convention, so there's no build script to write.

Worked example: every part named

src/main.rscompiles
fn main() {
    let customer = "Renata";
    let mut items = 2;
    items = items + 1;

    println!("Invoice for {customer}: {items} items");
}
  • fn main() — the entry point. Exactly one per executable. No file gets executed top-to-bottom the way a PHP script does; execution starts here.
  • let customer = "Renata"; — a binding. No type written, because the compiler infers &str.
  • let mut items — reassignable, because mut says so. Without it, line 4 is an error.
  • println! — the ! means macro, not function. It is expanded into code at compile time, which is how it can check your format string before the program runs.
  • Semicolons end statements, and braces define scope. Blocks are also expressions, which matters in module 2.

Not like PHP

There is no include or require, and no autoloader. Code is organised into modules and crates declared in your source, resolved at compile time. A file's path on disk does not by itself make it part of your program.

Learn more — why println! is a macro rather than a function

A Rust function has a fixed number of arguments of fixed types. Printing needs neither, so println! is a macro: at compile time it reads your format string, counts the placeholders, checks each argument can be displayed, and generates the code. Get the count wrong and you get a compile error — the format string is checked, not merely interpolated.

Practical rule for now: ! means "this is expanded before compiling". You'll meet vec!, format!, panic! and assert_eq! soon, and they follow the same logic.

Glossary · module 01

cargo
Build tool and package manager. Almost every command you run is a Cargo command.
manifest
Cargo.toml — name, version, edition, dependencies.
binary crate
A crate with src/main.rs; produces an executable.
library crate
A crate with src/lib.rs; meant to be used by other crates.
macro
Code that writes code at compile time. Called with a trailing !.
binding
A name attached to a value by let. Immutable unless declared mut.

Exercise 01

Make the compiler complain, then satisfy it

Create a project and write a program that starts a counter at 10, subtracts 3, and prints the result. Write it without mut first, read the error carefully — including the help: line — and only then fix it. The goal is to see the error's shape, not to avoid it.

first attemptdoes not compile
let counter = 10;
counter = counter - 3;
error[E0384]: cannot assign twice to immutable variable `counter` --> src/main.rs:3:5 | 2 | let counter = 10; | ------- first assignment to `counter` 3 | counter = counter - 3; | ^^^^^^^^^^^^^^^^^^^^^ cannot assign twice | help: consider making this binding mutable | 2 | let mut counter = 10; | +++
src/main.rscompiles
fn main() {
    let mut counter = 10;
    counter = counter - 3;
    println!("counter is {counter}");
}

A second valid answer: let counter = 10; let counter = counter - 3;. That's shadowing — a new binding reusing the name — and module 2 explains when to prefer it.

Quiz · module 01

What does the ! in println! tell you?

D. The ! is Rust's marker for a macro invocation. It's why println! can accept any number of arguments and still verify your format string at compile time.

C is a true statement about println! that has nothing to do with the ! — a nice example of a plausible-sounding wrong answer. B describes eprintln!.

Module 02 · gentle

Values, types, and the no-surprises rule

Objective

Read and write type annotations confidently, and predict where Rust refuses to convert things for you.

Rust infers types wherever it can, so annotated code is rarer than you'd fear. But inference works forwards from evidence, not from hope — and it never invents a conversion. That second half is the adjustment.

TypeHoldsYour equivalent
i32 i64Signed whole numbersPHP int, Python int (but bounded)
u8 u32 usizeUnsigned — never negativeNo equivalent. usize is for sizes and indexes.
f64Decimalsfloat
booltrue / falseSame — but only these two, no truthiness
charOne Unicode characterNo equivalent; a 1-char string isn't a char
StringOwned, growable textstr in Python, string in PHP
&strA borrowed view into textNo equivalent — see module 9
(i32, bool)Tuple: fixed group of mixed typesPython tuple
[i32; 4]Array: exactly four numbersNothing fixed-size; you want Vec

Worked example: the conversions Rust won't do

every line here failsdoes not compile
let a = "5" + 5;              // no. text is not a number
let b: i32 = 4.5;             // no. that's an f64
let c = 3 + 2.5;              // no. i32 + f64 has no meaning
if 1 { println!("yes"); }   // no. 1 is not a bool
ask explicitly and it's finecompiles
let quantity: i32 = "5".parse().unwrap();   // parse can fail; unwrap says "crash if it does"
let price = 3 as f64 + 2.5;                  // 5.5
let total = quantity as f64 * price;
if quantity > 0 { println!("{total}"); }      // a real comparison

Nothing was forbidden — you just had to say what you meant. .unwrap() is a beginner's placeholder meaning "crash if this failed"; module 8 replaces it with something you'd ship.

mut versus shadowing

mut — same box, new contents

let mut n = 5;
n = 6;
// n is i32, always was, always will be

Use when a value genuinely changes over time.

shadowing — a new box, same label

let input = "42";
let input: i32 = input.parse().unwrap();
// second `input` is a different type

Use to transform a value into its final form without inventing input_str.

Learn more — integer overflow, and why usize keeps appearing

Python integers grow without limit. Rust's don't: i32 stops at about 2.1 billion. In a debug build, exceeding it panics with a clear message; in a release build it wraps around silently by default, because checking on every arithmetic operation costs speed. If wrapping would be a bug, say what you want: checked_add returns None on overflow, saturating_add clamps to the maximum, wrapping_add wraps on purpose.

usize is an unsigned integer exactly as wide as a memory address on the machine — 64 bits on anything modern. Indexes and lengths use it, which is why my_vec.len() is a usize and comparing it to an i32 needs a cast. Slightly annoying; it also means a length can never be negative.

Glossary · module 02

type inference
The compiler deducing a type from surrounding evidence, so you needn't write it.
annotation
Writing the type yourself: let x: u8 = 3;. Needed when evidence is ambiguous.
shadowing
Declaring a new binding with an existing name. The old one is inaccessible, not modified.
scalar
A single simple value: integer, float, bool, char.
tuple
Fixed-length group of possibly different types: (3, true).
usize
Unsigned integer sized to the machine's addresses. The type of lengths and indexes.
panic
A controlled crash. The program prints a message and stops. Not an exception — you don't catch it.
as
Explicit numeric cast. Can silently lose precision, so use it knowingly.

Exercise 02

Two strings and a total

You're handed let qty = "12"; and let unit = "4.25"; — both text, as if from a form. Print the line total as a decimal. Use shadowing rather than new names, and don't reach for mut.

src/main.rscompiles · prints 51
fn main() {
    let qty = "12";
    let unit = "4.25";

    let qty: f64 = qty.parse().unwrap();
    let unit: f64 = unit.parse().unwrap();

    let total = qty * unit;
    println!("line total: {total:.2}");
}

Parsing qty straight to f64 avoids a cast later. Note {total:.2} — format specifiers work much like Python's f-strings.

If you parsed qty as i32 you'd need qty as f64 * unit, since i32 * f64 doesn't exist. Both answers are correct; the second shows you met the rule.

Quiz · module 02

Which line compiles?

C. A comparison produces a bool, and flag is inferred as one.

A: u8 is unsigned, so -1 is out of range. B: 10 is inferred as an integer and there's no i32 + f64; write 10.0 + 2.5. D: double quotes make a &str; a char needs single quotes, 'x'.

You need to turn a text field into a number and never use the text again. Which approach is more idiomatic, and why?

B. A mut binding keeps its type for life, so it can't hold text and then a number. Shadowing creates a genuinely new binding, which may have a new type.

D is the tempting one: the two features look similar but differ exactly here. C describes many other languages' scoping rules, not Rust's.

Module 03 · the wall

Ownership: who holds the value

Objective

Predict, for any line of Rust, whether a value was copied, moved, or left where it was — and explain what happens to the original.

This is the concept. Everything difficult about Rust is downstream of it, and it takes about a week to stop tripping over. The good news: there are only three rules and they are short.

  1. Every value has exactly one owner.
  2. There can only be one owner at a time.
  3. When the owner goes out of scope, the value is dropped — its memory freed.

The intuition: a physical document

Think of an owned value as a single paper contract, not a Google Doc. If you hand me the contract, you no longer have it — there's one copy and it's in my hands now. When I'm done, I shred it. That's ownership: assignment hands the thing over. Rust calls this a move.

Compare what you're used to: in Python, b = a makes a second label on the same object, both perfectly usable. In Rust, let b = a; for a heap-owning value makes b the owner and marks a as unusable, right there at compile time.

A move After assigning s1 to s2, the stack handle is duplicated but the heap buffer is not. The original binding s1 is marked invalid, so only s2 points at the buffer. AFTER let s2 = s1; s1 moved — cannot be used ptr 0x…a20 len 7 s2 the owner ptr 0x…a20 len 7 one buffer, on the heap — never duplicated B o n j o u r dropped once, when s2 goes out of scope
A move copies the cheap handle and transfers responsibility for the expensive buffer. No bytes of text are copied — and no second free is possible, because s1 no longer counts as a thing.

Worked example: moves, and the exception

heap-owning values movedoes not compile
let s1 = String::from("Bonjour");
let s2 = s1;                  // s1 moved into s2
println!("{s1}");             // error[E0382]: borrow of moved value
small stack values are copiedcompiles
let a = 7;
let b = a;                    // i32 is Copy — a is still valid
println!("{a} and {b}");      // prints "7 and 7"

Why the difference? An i32 is four bytes on the stack with nothing on the heap, so duplicating it costs nothing and creates no cleanup question. Types like that implement a trait called Copy, and assignment copies them instead of moving. Integers, floats, bool, char, and tuples of those are Copy. Anything owning heap memory — String, Vec, your own structs by default — is not.

The mistake everyone makes in week one

Reaching for .clone() the moment a move error appears. It compiles, and occasionally it's right — but it duplicates the whole heap buffer, and if you're cloning in a loop you've quietly made your program slow. Ask first: "do I need to own this, or only to look at it?" Usually the answer is look, and the fix is module 4's &.

Functions take ownership too

scope decides everythingannotated
fn main() {
    let note = String::from("paid");
    archive(note);              // note is moved in…
    // note is gone here — archive dropped it

    let kept = tag(String::from("draft"));  // …unless it's handed back
    println!("{kept}");          // "draft/reviewed"
}

fn archive(text: String) {
    println!("archiving {text}");
}                               // text goes out of scope → freed

fn tag(mut text: String) -> String {
    text.push_str("/reviewed");
    text                        // no semicolon: this is the return value
}

Passing by value into a function is a move, exactly like assignment. Returning a value moves it back out. This works, and for a day or two you'll write code that passes things in and returns them just to keep using them — a phase everyone goes through, and which borrowing ends.

Learn more — Drop, and why Rust never leaks by accident

When an owner goes out of scope, Rust inserts a call to drop. For a String that frees the buffer; for a file handle it closes the file; for a lock guard it releases the lock. Because it's tied to scope rather than to your remembering, the resource is released on every path out of the function — including early returns and panics.

This pattern generalises far beyond memory. In PHP or Python you write try/finally or a with block to guarantee cleanup; in Rust the type itself carries the cleanup, so there is nothing to remember and nothing to forget. It is one of the language's quietest wins.

Glossary · module 03

ownership
The rule that each value has one binding responsible for freeing it.
move
Transfer of ownership. The source binding becomes unusable — enforced at compile time.
Copy
A trait for small stack-only types. Assignment duplicates them instead of moving.
Clone
An explicit, possibly expensive duplicate, including heap data. Always visible as .clone().
drop
The automatic cleanup that runs when an owner goes out of scope.
scope
The region between { and } where a binding exists.
E0382
"Use of moved value" — the error code you'll see most often this week.

Exercise 03

Three ways out of one error

This doesn't compile. Find three different fixes — one using clone, one returning the value, one changing the function signature — and rank them by which you'd actually ship.

fix medoes not compile
fn main() {
    let label = String::from("invoice-88");
    let n = length_of(label);
    println!("{label} has {n} chars");
}

fn length_of(text: String) -> usize {
    text.len()
}

Fix 1 — clone. Works; duplicates the whole string to read its length. Wasteful.

let n = length_of(label.clone());

Fix 2 — hand it back. Works; makes the signature lie about its purpose, and forces the caller to rebind.

let (label, n) = length_of(label);

fn length_of(text: String) -> (String, usize) {
    let n = text.len();
    (text, n)
}

Fix 3 — borrow. One character, and the signature now states the truth: this function only reads.

the one to shipcompiles
let n = length_of(&label);

fn length_of(text: &String) -> usize {
    text.len()
}

Ranking: 3, then 2, then 1. The habit worth forming is to reach for & first and treat clone as a deliberate decision you could defend in review.

Quiz · module 03 · 3 questions

After let v2 = v1; where v1 is a Vec<i32>, what is true?

C. A move: the handle is copied, the heap data is not, and the compiler stops you using the old binding.

A is Python's behaviour and the most common wrong answer. B is what .clone() does. D is roughly what C++ used to let you do — and the resulting dangling pointers are precisely what ownership exists to prevent.

Why is i32 copied on assignment while String is moved?

A. Copying is safe precisely when there's no shared resource to free twice. Those types implement Copy.

D fails on the facts — i64 and f64 are pointer-width or larger and still Copy, while a one-byte struct isn't unless it opts in. B confuses the binding's mutability with the type's behaviour.

You hit error[E0382]: use of moved value. What's the best first question to ask?

D. Most move errors are really a signature stating "give me this" when "let me look at this" was meant. Borrowing is the usual answer.

B is the reflex to resist — it silences the compiler without answering the question. C is impossible for anything holding heap data: Copy requires that a bit-for-bit duplicate be valid, which a String can never satisfy.

Module 04 · the wall

Borrowing: lending without giving away

Objective

Choose correctly between T, &T and &mut T in a function signature, and explain the one rule that governs all borrow errors.

A borrow is a reference: access to a value without owning it, written with &. When it ends, nothing is freed — the owner is still the owner. And there are exactly two kinds.

&T — a shared borrow

Read-only. You may have as many as you like at once. Nobody can change the value while any of them exist, so every reader sees the same thing.

&mut T — a unique borrow

Read and write. You may have exactly one, and no shared borrows alongside it. While it exists, it is the only route to the value.

That's the whole rule, and it has a name worth remembering: shared XOR mutable. Many readers, or one writer. Never both.

Shared XOR mutable On the left, three shared borrows all point at one value and are permitted. On the right, a unique mutable borrow is permitted but a second shared borrow alongside it is rejected. ALLOWED many readers &invoice &invoice &invoice invoice owner REJECTED a writer excludes everyone &mut invoice &invoice invoice owner
Shared XOR mutable. The rule isn't about protecting the owner — it's about guaranteeing that nobody reads a value while somebody else is halfway through changing it.

Why the rule exists

Every PHP or Python developer has been bitten by this: you iterate over a list and modify it inside the loop, and the result is nonsense or a RuntimeError. That's the same hazard. A reader assumed things would hold still, and a writer moved them.

Rust turns that class of bug from a runtime surprise into a compile error — and the same rule, applied to threads, is what makes Rust's concurrency safe (module 12). One idea, two enormous payoffs.

Worked example

lending, both wayscompiles
fn main() {
    let mut line = String::from("invoice");

    // two shared borrows at once — fine
    let a = &line;
    let b = &line;
    println!("{} {} {}", a, b, width(&line));

    // a and b are never used again, so their borrows have ended here

    append_ref(&mut line);      // now a unique borrow is available
    println!("{line}");           // "invoice-88" — line is still ours
}

fn width(text: &String) -> usize {
    text.len()
}

fn append_ref(text: &mut String) {
    text.push_str("-88");
}

Note let mut line: you can only hand out a &mut to something you own mutably. And at the call site you write &mut line explicitly — mutation through a borrow is never hidden from the reader.

overlapping the two kindsdoes not compile
let mut line = String::from("invoice");
let peek = &line;           // shared borrow starts
line.push_str("-88");        // needs a unique borrow → conflict
println!("{peek}");          // shared borrow still alive here
error[E0502]: cannot borrow `line` as mutable because it is also borrowed as immutable --> src/main.rs:3:1 | 2 | let peek = &line; | ----- immutable borrow occurs here 3 | line.push_str("-88"); | ^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here 4 | println!("{peek}"); | ---- immutable borrow later used here

Read the last line of that error first. The borrow conflicts because peek is used after the mutation. Delete line 4 and the code compiles — a borrow lasts until its last use, not until the end of the block.

Learn more — the borrow ends at its last use, not at the closing brace

Early Rust really did tie borrows to the enclosing scope, which made some obviously fine code illegal. Since 2018 the compiler tracks the actual span of use, so this compiles:

let mut v = vec![1, 2, 3];
let first = &v[0];
println!("{first}");   // last use of the shared borrow
v.push(4);            // fine — nothing is borrowed any more

Swap the last two lines and it fails. When an error looks unreasonable, check where the borrow is last used — that's usually the line to move, and it's often a one-line fix rather than a redesign.

Learn more — you'll rarely write &String in real code

&String works, but idiomatic Rust takes &str instead: fn width(text: &str) -> usize. It accepts more callers — a &String converts to &str automatically — and asks for the least it needs.

The general habit: accept the most permissive borrowed form. &str over &String, &[T] over &Vec<T>. This page uses &String in early examples only because the parallel with String makes the ownership point clearer. Module 9 covers the real story.

Glossary · module 04

borrow
Temporary access to a value you don't own. Created with &.
shared borrow
&T. Read-only, any number at once.
unique borrow
&mut T. Read and write, exactly one, exclusive.
borrow checker
The compiler pass that verifies every borrow's validity and exclusivity.
aliasing
Two routes to the same value. Rust permits it only when nobody can write.
dereference
Following a reference to the value, written *r. Often implicit for method calls.
E0502
Conflicting borrows — the second error code you'll come to know well.

Exercise 04

Pick the right signature three times

For each function, decide whether the parameter should be Vec<String>, &Vec<String>, or &mut Vec<String> — and say why in one sentence before revealing.

  1. count_overdue — returns how many entries start with "OVERDUE".
  2. add_footer — pushes a summary line onto the end of the list.
  3. into_report — consumes the list and returns a single joined String, after which the caller has no further use for it.
signaturescompiles
// 1 · reads only → shared borrow
fn count_overdue(lines: &Vec<String>) -> usize { /* … */ 0 }

// 2 · changes the list in place → unique borrow
fn add_footer(lines: &mut Vec<String>) {
    lines.push(String::from("-- end --"));
}

// 3 · takes over and reuses the parts → ownership
fn into_report(lines: Vec<String>) -> String {
    lines.join("\n")
}

The reasoning to internalise: read → &, modify → &mut, consume or store → take ownership. Case 3 earns ownership because it recycles the strings' buffers instead of copying them; the naming convention into_* signals "this eats its input", and you'll see it throughout the standard library.

In production you'd write &[String] for case 1 — same reasoning, wider acceptance. See module 9.

Quiz · module 04 · 2 questions

How many borrows of one value can coexist?

B. Shared XOR mutable. Many readers or one writer, never simultaneously.

D is a reasonable-sounding rule that would reintroduce exactly the bug the check prevents: a reader created before the writer would still be holding a view of data that's being changed.

This code is rejected. What is the smallest change that fixes it?

C. The loop holds a shared borrow of the vector for its whole body, so pushing inside it conflicts. Ending the borrow first — by finishing the loop — resolves it.

B is wrong because the vector is already mut; the conflict is between two live borrows, not a missing one. D makes it worse: iter_mut takes a unique borrow, so the push conflicts even harder. A compiles but duplicates data to dodge a rule that was pointing at a real design question.

the snippet for question 2does not compile
let mut lines = vec![String::from("a")];
for line in &lines {
    if line == "a" {
        lines.push(String::from("b"));   // E0502
    }
}

Module 05 · the wall

Lifetimes: a reference can't outlive its value

Objective

Read <'a> in a signature without panic, and know the two situations where you have to write one yourself.

Lifetimes have a reputation. Most of it comes from one misunderstanding, so let's remove it before anything else.

The misunderstanding

A lifetime annotation does not make anything live longer. It does not allocate, extend, retain, or keep anything alive. It is a label you attach to a relationship, so the compiler can check a claim you're already making. Writing 'a is like writing a type — a description of what's true, not an instruction.

Here's what they're for. This function is a disaster in the making, and every language has to deal with it somehow:

returning a reference to a localdoes not compile
fn make_label() -> &String {
    let label = String::from("invoice-88");
    &label          // label is dropped at the closing brace…
}                   // …so this reference would point at freed memory

In C this compiles and you get a use-after-free — a security bug. In Python it can't happen, because the object is kept alive by the reference count. Rust takes a third path: it refuses, and explains that the return type needs to say where the borrowed data comes from.

Lifetimes as overlapping spans Two timelines. In the valid case the borrow ends before the owner is dropped. In the rejected case the borrow extends beyond the point where the owner is dropped. VALID borrow ends first time → value `data` is alive &data is used dropped here every use of the borrow sits inside the value's span REJECTED borrow outlives the value time → value `data` is alive &data …still used out here dropped here the shaded part is a dangling reference — E0106 / E0597
A lifetime is a span of code, not a duration in seconds. The only rule: every span in which a reference is used must sit inside the span where its value is alive.

Most of the time you write nothing

The compiler fills in the obvious cases — a set of rules called elision. You've already written lifetime-using code in module 4 without noticing:

the same function, twicecompiles
// what you write
fn width(text: &str) -> usize { text.len() }

// what the compiler understands: one input reference,
// so any returned reference must come from it
fn width<'a>(text: &'a str) -> usize { text.len() }

Case one: two inputs, one returned reference

With two borrowed inputs, the compiler can't guess which one the result borrows from — so you say. Read <'a> as "call this relationship a":

the classic examplecompiles
fn longer<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

In English: "the returned reference is valid for as long as both inputs are". You have not changed any value's lifespan — you've told the compiler what to verify at every call site. If a caller keeps the result after one input dies, that caller gets the error, which is exactly right.

Case two: a struct holding a reference

borrowed data in a structcompiles
struct Excerpt<'a> {
    text: &'a str,        // I don't own this; someone else does
}

fn main() {
    let article = String::from("Rust is a systems language.");
    let first = Excerpt { text: &article[0..4] };
    println!("{}", first.text);   // "Rust"
}

The <'a> declares: an Excerpt cannot outlive the text it points into. Drop article while first lives, and the compiler stops you.

The beginner's escape hatch, and it's a legitimate one: if lifetime annotations start multiplying in your code, that's usually a signal to store owned data instead. Use String rather than &'a str in your structs until you have a measured reason not to. You'll pay a small allocation and save hours. Nobody will judge you; experienced Rust developers make this choice constantly.

Learn more — 'static, and why it isn't what it looks like

&'static str means "a reference valid for the entire run of the program". Every string literal is one, because the text is baked into the binary — that's why let s = "hello"; needs no owner.

The trap: seeing an error mentioning lifetimes and adding 'static to make it go away. That doesn't extend anything; it strengthens a requirement, usually pushing the same error one step up the call chain, or forcing you to leak memory to satisfy it. When you see 'static in an error, read it as "this needs to be owned data or a literal", and reach for String.

Learn more — why self-referential structs are impossible

A struct cannot hold both a value and a reference into that same value. The reason is mechanical: moving the struct moves the value's bytes to a new address, and the stored reference would still point at the old one. Rust moves things freely, so it forbids the arrangement rather than forbidding moves.

This is the wall behind linked lists, parent pointers in trees, and graph structures — and the reason those are famously awkward in Rust. The real answers are indices into a Vec (the arena pattern, and usually faster anyway), Rc<RefCell<T>> with Weak for back-references (module 12), or a crate built for the job. It is not a beginner problem, and finding it hard is not a sign you've misunderstood something.

Glossary · module 05

lifetime
The span of code during which a reference is valid. Always inferred; sometimes named.
'a
A lifetime parameter — a name for a relationship between references.
elision
The rules that let the compiler infer lifetimes so you needn't write them.
'static
Valid for the whole program. String literals and leaked data qualify.
dangling reference
A reference to freed memory. Rust makes it unrepresentable in safe code.
E0106
"Missing lifetime specifier" — the compiler asking which input a returned reference borrows from.

Exercise 05

Explain the error before you fix it

The code below is rejected. First, write one sentence about which reference outlives what. Then fix it in the way you'd actually ship — which is not by adding a lifetime.

fix medoes not compile
fn main() {
    let banner;
    {
        let name = String::from("Renata");
        banner = &name;
    }
    println!("{banner}");
}

The sentence: name is dropped at the inner closing brace, but banner borrows it and is used afterwards — so the borrow would outlive the value.

error[E0597]: `name` does not live long enough --> src/main.rs:5:18 | 4 | let name = String::from("Renata"); | ---- binding `name` declared here 5 | banner = &name; | ^^^^^ borrowed value does not live long enough 6 | } | - `name` dropped here while still borrowed 7 | println!("{banner}"); | -------- borrow later used here

The fix — own the data instead of borrowing it:

src/main.rscompiles
fn main() {
    let banner;
    {
        let name = String::from("Renata");
        banner = name;          // moved out — banner now owns it
    }
    println!("{banner}");      // fine: the value left the inner scope
}

Notice that no lifetime annotation could have saved this. The problem was never a missing label — it was that the data genuinely died too early. That's the general shape of lifetime errors: they're telling you something about your program's structure, and annotations only ever describe what's already true.

Quiz · module 05 · 2 questions

What does adding <'a> to a function actually do?

D. Annotations are descriptive. They add information the compiler couldn't infer; they never change runtime behaviour, and code with lifetimes compiles to exactly the same machine code.

A is the misconception this module exists to remove. B is what Box or returning an owned String does. C describes Rc<T> — a real Rust tool, but one you opt into with a type, not a lifetime.

You're writing a struct to hold a parsed configuration that must remain usable for the program's whole run. What's the sensible default?

A. Owned fields make the struct self-contained and free to move anywhere. The allocation cost is paid once at startup and is irrelevant next to the complexity you avoid.

B is what a library optimising for zero-copy parsing would do, and it infects every type that touches the struct with <'a>. C only works if the text is a literal in your source — file contents read at runtime aren't 'static unless you deliberately leak them. D isn't legal: struct fields holding references always require a declared lifetime.

Module 06 · moderate

Structs and enums: modelling your domain

Objective

Define your own types with data and behaviour, and use enums to make invalid states impossible to write down.

A struct is close to what you'd call a class without inheritance: named fields, and methods defined separately in an impl block.

data and behaviour, kept apartcompiles
struct Invoice {
    number: u32,
    total_cents: u64,
    paid: bool,
}

impl Invoice {
    // no `self` → an associated function, like a static constructor
    fn new(number: u32, total_cents: u64) -> Self {
        Self { number, total_cents, paid: false }
    }

    // &self → borrows, reads only
    fn total(&self) -> f64 {
        self.total_cents as f64 / 100.0
    }

    // &mut self → borrows uniquely, may change fields
    fn mark_paid(&mut self) {
        self.paid = true;
    }
}

fn main() {
    let mut inv = Invoice::new(88, 4250);
    inv.mark_paid();
    println!("#{} — {:.2} — paid: {}", inv.number, inv.total(), inv.paid);
}

Everything from modules 3 and 4 shows up in the method receivers. &self is a shared borrow, &mut self a unique one, and a bare self consumes the struct. You now know exactly what each means and why mark_paid requires let mut inv.

Enums are the part you'll actually miss later

In PHP or Python, "a payment is pending, or settled with a reference, or failed with a reason" typically becomes a class with three nullable fields, or a dict with a type key and an unwritten rule about which other keys are present. Both let you construct nonsense: settled and failed, with no reference.

A Rust enum is a closed set of variants, and each variant carries its own data. Nonsense becomes unwritable:

one value, three shapescompiles
enum Payment {
    Pending,
    Settled { reference: String, cents: u64 },
    Failed(String),
}

fn describe(p: &Payment) -> String {
    match p {
        Payment::Pending =>
            "waiting on the bank".to_string(),
        Payment::Settled { reference, cents } =>
            format!("{:.2} settled, ref {reference}", *cents as f64 / 100.0),
        Payment::Failed(reason) =>
            format!("failed: {reason}"),
    }
}

Two things just happened that are worth pausing on. First, a Settled payment always has a reference — there is no path through your program where it doesn't. Second, match is exhaustive: add a Refunded variant next month and every match in the codebase that doesn't handle it fails to compile. The compiler hands you the complete list of places to update. This is the feature people miss most when they go back to a dynamic language.

The dict-with-a-type-key habit

# nothing stops this
p = {"type": "settled"}
p["reference"]   # KeyError

The enum

// won't compile — missing field
Payment::Settled { cents: 100 }
Learn more — if let and let…else, for when you want one case

A full match is overkill when you care about a single variant. if let handles one arm:

if let Payment::Failed(reason) = &payment {
    eprintln!("alert ops: {reason}");
}

And let…else handles the "extract it or bail out" shape that would otherwise nest your code to the right — closer to an early return in PHP than to anything in Python:

let Payment::Settled { cents, .. } = payment else {
    return;      // must diverge: return, break, continue, or panic
};
// `cents` is in scope for the rest of the function
Learn more — no inheritance, and what replaces it

There is no class Dog extends Animal in Rust. Not "discouraged" — absent. Two things take its place. Shared behaviour goes into traits (module 10): any type can implement Payable, regardless of ancestry. Shared structure goes into composition: put a Contact struct inside your Customer struct.

If you're used to deep hierarchies this feels restrictive for a fortnight and then stops mattering. The problems inheritance solves — polymorphism, code reuse — are handled; what's gone is the coupling, and the question of which of five parents defined the method you're calling.

Glossary · module 06

struct
A custom type with named fields.
impl block
Where a type's methods and associated functions are defined.
method
A function taking self, &self or &mut self. Called with a dot.
associated function
A function in an impl with no self. Called with ::, e.g. Invoice::new.
Self
Shorthand for the type being implemented.
enum
A type that is exactly one of several named variants, each able to carry data.
variant
One of an enum's alternatives.
exhaustive
Property of match: every variant must be handled, or it won't compile.
pattern
The shape on the left of a match arm, which also destructures the data out.

Exercise 06

Make an invalid state unrepresentable

A colleague models a document with struct Doc { status: String, published_at: Option<String>, rejection_reason: Option<String> }. List two invalid combinations that structure permits, then redesign it as an enum so neither can be written.

Invalid combinations it allows: a status of "published" with published_at: None; both published_at and rejection_reason set at once; and a fourth one for free — status: "publshed", a typo the compiler can't see because the field is text.

the redesigncompiles
enum Doc {
    Draft,
    InReview { reviewer: String },
    Published { at: String },
    Rejected { reason: String },
}

impl Doc {
    fn is_public(&self) -> bool {
        matches!(self, Doc::Published { .. })
    }
}

Now Published cannot exist without a date, Rejected cannot exist without a reason, and neither can be both. The status typo is gone too, because variants are names the compiler checks rather than strings it doesn't. This technique has a name worth knowing — making invalid states unrepresentable — and it's the highest-value habit you can carry out of this page.

Quiz · module 06 · 2 questions

You add a fourth variant to an enum that's matched in fifteen places. What happens?

B. Exhaustiveness is a hard error. Refactoring an enum turns into a checklist the compiler writes for you — one of the most practical benefits of the whole type system.

A is true only if you already wrote a _ => catch-all, which is why adding one reflexively throws away the guarantee. C describes a runtime check; Rust does this at compile time instead.

fn mark_paid(&mut self) is called as inv.mark_paid(). Why must inv be declared mut?

C. Same rule as module 4, now wearing a method's clothes. The call desugars to Invoice::mark_paid(&mut inv), and that &mut needs a mutable owner.

B confuses &mut self with self — the latter does consume the struct, which is how builder methods chain. A invents a heap requirement that doesn't exist.

Module 07 · moderate

Option: life without null

Objective

Handle possibly-absent values without unwrap, using the three tools that cover almost every real case.

Option<T> is just an enum — you already know how it works:

from the standard librarynothing magic
enum Option<T> {
    Some(T),
    None,
}

That's the whole thing. <T> means it works for any type — Option<u32>, Option<Invoice>. The power isn't in the type; it's in the fact that Option<Invoice> and Invoice are different types, so you cannot use one where the other is expected. The check you kept forgetting is now impossible to forget.

The bug you've shipped

# Python: signature says nothing
def find(uid):
    return db.get(uid)   # or None

find(7).name             # fine until it isn't

The version that can't ship

// signature is honest
fn find(uid: u32) -> Option<Customer>

find(7).name           // error: no field `name`
                        // on `Option<Customer>`

Three tools, in the order you'll reach for them

getting the value outcompiles
let found: Option<u32> = lookup_discount("SPRING");

// 1 · a default, when absence is ordinary
let percent = found.unwrap_or(0);

// 2 · handle one case and move on
if let Some(p) = found {
    println!("discount {p}%");
}

// 3 · both branches matter
let message = match found {
    Some(0)  => "code valid, but no discount".to_string(),
    Some(p)  => format!("{p}% off"),
    None     => "unknown code".to_string(),
};

Note the second arm: patterns can match on values too, not only variants, and the compiler still checks you've covered everything. That's match earning its keep.

Transforming without unpacking

chainingcompiles
// Option<Customer> → Option<String>, no unpacking needed
let city = find(7).map(|c| c.city);

// keep it only if it passes a test
let big = find(7).filter(|c| c.orders > 10);

// each step may itself be absent — and_then flattens
let region = find(7).and_then(|c| region_of(&c.city));

// finish with a fallback
let label = city.unwrap_or_else(|| "unknown".to_string());

If you've used Python's if x is not None and ... ladders, or PHP's ?->, this is the same idea with the nesting removed. map applies a function if there's a value; and_then is for when your function also returns an Option.

On .unwrap()

.unwrap() means "give me the value, and crash the program if there isn't one". It's fine in a throwaway script or a test. In anything you'd deploy, prefer unwrap_or, a match, or ? from module 8. If you truly know it can't fail, use .expect("config was validated at startup") — the message appears in the panic, and future-you will need it.

Learn more — Option is free, which is a small miracle

You'd expect wrapping a value in an enum to cost a byte for the tag. For many types it costs nothing: Option<&T> and Option<Box<T> are the same size as &T and Box<T>, because a reference can never be zero — so the compiler uses the all-zero pattern to mean None. It's called niche optimisation.

The upshot: null-safety in Rust is not a performance trade. You get the guarantee, at the same memory cost as the nullable pointer you were using before, with the difference that the compiler forces you to check it.

Glossary · module 07

Option<T>
Either Some(value) or None. Rust's replacement for null.
generic
<T> — the type works for any T, decided at the call site.
unwrap
Extract the value, panicking if absent. A placeholder, not a solution.
expect
Like unwrap, with a message explaining why you believed it was safe.
unwrap_or
Extract the value or use a supplied default. No panic.
map
Apply a function to the value if present, leaving None untouched.
and_then
Like map, for functions that themselves return an Option.
closure
An inline anonymous function, written |x| x + 1. Module 11.

Exercise 07

Remove every unwrap

Rewrite this so it cannot panic. The rule: no unwrap, no expect. Absent nickname means fall back to the full name; a name that is empty text should also count as absent.

fix mecompiles, but panics on None
struct User { name: String, nickname: Option<String> }

fn display_name(u: &User) -> String {
    u.nickname.clone().unwrap()
}
one expression, no paniccompiles
fn display_name(u: &User) -> String {
    u.nickname
        .as_deref()                       // Option<String> → Option<&str>, no clone
        .filter(|n| !n.is_empty())        // empty counts as absent
        .unwrap_or(&u.name)                // fall back to the real name
        .to_string()
}

A match version is equally correct and easier to read while the combinators are still unfamiliar:

fn display_name(u: &User) -> String {
    match &u.nickname {
        Some(n) if !n.is_empty() => n.clone(),
        _ => u.name.clone(),
    }
}

Some(n) if !n.is_empty() is a match guard — an extra condition on an arm. And as_deref is worth remembering: it borrows through the Option so you inspect the string without cloning it.

Quiz · module 07 · 2 questions

Why can't you accidentally use an absent value in Rust?

A. It's a type-level guarantee, checked before the program runs. You must convert Option<Customer> into a Customer — and every conversion makes you say what happens when it's absent.

B describes what unwrap does after you've asked for it, not the guarantee. C is Go's zero-value approach, and it's exactly the class of quiet bug Option eliminates.

lookup(id) returns Option<Customer>, and region_of(&str) returns Option<Region>. You want the region for a customer's city. Which combinator?

D. With map you'd get Option<Option<Region>>. and_then collapses the two layers into one — if either step is absent, the result is None.

The rule of thumb: your function returns a plain value → map; your function returns another Optionand_then. Same distinction as Result's in module 8.

Module 08 · moderate

Result and ?: errors as values

Objective

Write fallible functions that read as cleanly as exception-based code, and understand what ? does at each step.

Rust has no exceptions. Nothing is thrown, nothing propagates invisibly, and there is no catch. A function that can fail says so in its return type:

also just an enumnothing magic
enum Result<T, E> {
    Ok(T),
    Err(E),
}

The consequence is worth stating plainly: you can see every failure point in a signature. No hunting for which of forty called functions might throw. And ignoring a Result produces a compiler warning — a discipline PHP and Python can't enforce.

The ? operator does the work

Handling errors by hand is verbose, and it was verbose in early Rust too. Then ? arrived. On a Result, it means: if this is Ok, unwrap it and carry on; if it's Err, return that error from the enclosing function immediately.

By hand

let text = match read_file(p) {
    Ok(t)  => t,
    Err(e) => return Err(e),
};

With ?

let text = read_file(p)?;
a realistic fallible functioncompiles
use std::fs;
use std::error::Error;

fn total_from_file(path: &str) -> Result<u64, Box<dyn Error>> {
    let text = fs::read_to_string(path)?;   // may fail: no file, no permission
    let mut total = 0;

    for line in text.lines() {
        if line.trim().is_empty() { continue; }
        total += line.trim().parse::<u64>()?;   // may fail: not a number
    }

    Ok(total)                            // success is explicit too
}

fn main() {
    match total_from_file("amounts.txt") {
        Ok(t)  => println!("total: {t}"),
        Err(e) => eprintln!("could not compute total: {e}"),
    }
}

Two different error types — a filesystem error and a number-parsing error — both propagate through the same ?. That works because Box<dyn Error> means "some error, I'm not being specific", and ? converts into it automatically. It's the right choice for application code and quick tools.

Read the body again and notice what it looks like: a straight line of happy-path logic, with ? marking each place something can go wrong. That's the same ergonomics as exceptions — every failure point is just visible.

Where ? won't work

? returns from the current function, so the function must return Result (or Option). It can't be used in a plain fn main() — but fn main() -> Result<(), Box<dyn Error>> is allowed, and printing an error then exiting non-zero is handled for you.

Learn more — panic! is not an exception

Rust distinguishes two kinds of bad news. Recoverable — a missing file, bad input, a network timeout — is a Result, because the caller can reasonably decide what to do. Unrecoverable — an index past the end of a vector, a failed invariant — is a panic!: the program prints a message and unwinds.

Panics are not for control flow. There is catch_unwind, and using it as a try/except substitute is a code smell that will be flagged in review. The rule: if a caller might sensibly handle it, return Result; if it means your code has a bug, panic.

Learn more — the two crates every Rust project ends up using

Box<dyn Error> is fine but loses detail. Real projects reach for two crates, and knowing which is which saves confusion:

  • thiserror — for libraries. Derives a proper error enum, so callers can match on precisely what went wrong.
  • anyhow — for applications. One anyhow::Result<T> everywhere, with .context("reading the invoice file") to build a readable trail. The closest thing to a stack trace you'll get, and better, because you wrote the messages.

The distinction is about your consumer: a library's caller needs to branch on errors, an application's user needs to read them.

Glossary · module 08

Result<T, E>
Either Ok(value) or Err(error).
?
Unwrap on success, or return the error from the enclosing function.
propagate
Pass an error up to the caller rather than handling it here.
Box<dyn Error>
"Some error type" — a boxed trait object. Convenient for applications.
panic!
Unrecoverable failure. Prints and unwinds; not meant to be caught.
()
The unit type — "no meaningful value". Result<(), E> means "worked, or here's why not".
use
Brings a path into scope, like a PHP use or Python from … import.

Exercise 08

Convert an exception habit

Write fn sum_csv(line: &str) -> Result<i64, std::num::ParseIntError>, which splits on commas, parses each field, and returns the total. Any bad field should make the whole call fail — and you should not need a single match.

src/lib.rscompiles
fn sum_csv(line: &str) -> Result<i64, std::num::ParseIntError> {
    let mut total = 0;
    for field in line.split(',') {
        total += field.trim().parse::<i64>()?;
    }
    Ok(total)
}

The ? exits the loop and the function on the first bad field, which is what a thrown exception would have done — except here it's a value being returned, visible in the signature.

Once module 11 lands, the iterator version becomes available, and it's a genuinely elegant trick:

fn sum_csv(line: &str) -> Result<i64, std::num::ParseIntError> {
    line.split(',')
        .map(|f| f.trim().parse::<i64>())
        .sum()          // sums Result<i64,_> into Result<i64,_>
}

sum() over an iterator of Results produces a single Result, short-circuiting on the first error. collect() does the same trick, turning Vec<Result<T,E>> into Result<Vec<T>,E>.

Quiz · module 08 · 2 questions

What does ? do when applied to an Err?

C. A plain early return, plus an automatic conversion into the function's declared error type. Nothing is thrown and nothing unwinds.

D is the exception model, and the difference matters: ? only ever returns one level, to the immediate caller, who must in turn handle or propagate it. That's why the chain is visible in signatures.

You're writing a library that parses config files. Callers need to react differently to "file missing" and "invalid syntax". What should your error type be?

B. An enum (usually derived with thiserror) is the library convention precisely because it lets callers branch. Your public error type is part of your API.

A and D are the right calls in an application, where nothing downstream needs to branch — but they erase the distinction, forcing callers into fragile string matching or downcasting. C makes that worse by discarding the type entirely.

Module 09 · moderate

Strings and collections: your biggest trap

Objective

Stop fighting String versus &str, and know why text[0] doesn't exist.

This module exists because text is where PHP and Python developers lose the most time. In your languages a string is one thing that does everything. In Rust there are two, and the difference is ownership — which you now understand, so this will be quick.

String — owns its text

Heap-allocated, growable, yours to keep. Use it in structs, as a return value, whenever the text must outlive the current expression.

&str — borrows a view

A pointer and a length into text someone else owns. Cheap, read-only, cannot outlive the owner. Use it for function parameters.

String and string slice A String handle with pointer, length and capacity points at the start of a heap buffer. A separate string slice holds a pointer into the middle of the same buffer plus its own shorter length. greeting: String — owns the buffer ptr len capacity → byte 0 12 12 last: &str — borrows part of it ptr len → byte 8 4 one heap buffer · 12 bytes B o n j o u r t o u t len 4 — no copy made
let last = &greeting[8..12]; allocates nothing. A slice is a pointer and a length, which is why passing &str around is essentially free.
the conversions in practicecompiles
let owned: String = String::from("Bonjour tout");
let literal: &str = "Bonjour tout";      // literals are always &str

let view: &str = &owned;                   // String → &str, free
let copy: String = literal.to_string();     // &str → String, allocates

// so: take &str, return String
fn shout(text: &str) -> String {
    text.to_uppercase()
}

shout(&owned);      // works
shout(literal);     // works — one signature, both callers

The rule that ends the confusion: parameters take &str, return values and struct fields use String. If you follow only that, ninety per cent of your string friction disappears.

Why text[0] is a compile error

This one offends people, so here's the honest reason. Rust strings are UTF-8, always. In UTF-8, a character takes between one and four bytes — é takes two, 😀 takes four. So "the character at index 0" and "the byte at index 0" are different questions, and only one of them is cheap to answer.

PHP's $s[0] gives you one byte, which mangles any accented text. Python 3 gives you a character, at the cost of a more complex internal representation. Rust refuses to guess, and makes you name which you want:

say what you meancompiles
let s = String::from("café");

s.len();                          // 5 — bytes, not characters
s.chars().count();                // 4 — characters, costs a scan
s.chars().next();                 // Some('c') — the first character
s.chars().rev().collect::<String>(); // "éfac"
&s[0..3];                       // "caf" — byte range, panics if it splits a character

Carry this one home

.len() on a string is bytes. If you have ever written a length check that behaved oddly with accented input, you have met this bug from the other side. Rust didn't create the complexity — it stopped hiding it.

Vec and HashMap

the two collections you'll use dailycompiles
use std::collections::HashMap;

let mut lines: Vec<String> = Vec::new();
lines.push(String::from("first"));
let nums = vec![10, 20, 30];       // vec! is the shorthand

nums[0];                       // 10 — but panics if out of range
nums.get(99);                  // None — the safe way

let mut stock: HashMap<&str, u32> = HashMap::new();
stock.insert("widget", 4);
stock.get("widget");            // Some(&4) — an Option, of course
*stock.entry("widget").or_insert(0) += 1;  // insert-or-update in one step

Note that .get() returns Option everywhere in Rust. Indexing with [] panics on a missing key or index — the same trade PHP makes with a notice and Python makes with KeyError, except here you have a checked alternative that's just as short.

Learn more — &[T], the slice that makes your functions flexible

Just as &str is a borrowed view of text, &[T] is a borrowed view of a sequence. Taking &[i32] instead of &Vec<i32> means your function also accepts arrays, and parts of vectors:

fn total(xs: &[i32]) -> i32 { xs.iter().sum() }

let v = vec![1, 2, 3, 4];
total(&v);          // whole vector
total(&v[1..3]);    // just the middle — no copy
total(&[7, 8]);     // a fixed array

Pattern to internalise, and it's the same one as strings: accept the borrowed view, return the owned thing. &str and &[T] in, String and Vec<T> out.

Glossary · module 09

String
Owned, growable, heap-allocated UTF-8 text.
&str
Borrowed view into UTF-8 text. Pronounced "string slice".
slice
A pointer plus a length, viewing part of a contiguous sequence. &[T].
Vec<T>
Growable array. Your default list type.
HashMap<K, V>
Key–value store. PHP associative array, Python dict.
entry
API for insert-or-update on a map without a double lookup.
UTF-8
Variable-width text encoding. One character is one to four bytes.

Exercise 09

Count words, safely

Write fn word_counts(text: &str) -> HashMap<String, usize>, counting whitespace-separated words case-insensitively. Then answer: why must the key be String and not &str?

src/lib.rscompiles
use std::collections::HashMap;

fn word_counts(text: &str) -> HashMap<String, usize> {
    let mut counts = HashMap::new();
    for word in text.split_whitespace() {
        *counts.entry(word.to_lowercase()).or_insert(0) += 1;
    }
    counts
}

Why String: two reasons, and both are module 3 and 5 in disguise. First, to_lowercase() produces a brand-new String — there is no existing text to borrow, since "CAFÉ" lowercased isn't a substring of the input. Second, the map is returned, so it outlives text; a map of &str keys would be a map of dangling references, and the compiler would demand a lifetime parameter to prove otherwise.

*counts.entry(k).or_insert(0) += 1 reads oddly at first: or_insert hands back a &mut usize, and the * writes through it. It's the standard idiom for counting, and you'll recognise it instantly after the third time.

Quiz · module 09 · 2 questions

A function only needs to read some text. What's the best parameter type?

D. It asks for the least and accepts the most. A caller with a String passes &my_string and the conversion is automatic.

B is the near-miss most people write first — it borrows correctly but rejects literals and slices without a conversion. C requests write access you don't need, which forces callers to hold the value mutably for no reason.

"café".len() returns 5. Why?

B. len() is a byte count and runs instantly. For characters, .chars().count() — which has to walk the string, hence the different name.

C is worth ruling out carefully: it can be true of text that arrives decomposed, in which case .chars().count() would also give 5. That's a real Unicode subtlety, but it isn't what's happening with a plain literal here.

Module 10 · moderate

Traits and generics: behaviour without inheritance

Objective

Define shared behaviour with a trait, write one function that serves many types, and choose between generics and dyn.

A trait is an interface: a set of method signatures a type can promise to provide. If you've used PHP interfaces or Python protocols, you're most of the way there — with one significant upgrade. You can implement your trait for a type you didn't write, including types from the standard library.

a trait, two implementationscompiles
trait Summary {
    fn headline(&self) -> String;

    // a default — implementors may override it or not
    fn preview(&self) -> String {
        format!("{} …", self.headline())
    }
}

struct Invoice { number: u32, cents: u64 }
struct Note { body: String }

impl Summary for Invoice {
    fn headline(&self) -> String {
        format!("Invoice #{} — {:.2}", self.number, self.cents as f64 / 100.0)
    }
}

impl Summary for Note {
    fn headline(&self) -> String {
        self.body.chars().take(40).collect()
    }
}

Two unrelated structs, no common ancestor, and no class hierarchy to plan. The trait is declared separately from both types, which is the piece that makes traits more flexible than interfaces: behaviour is bolted on, not baked in.

Generics: one function, many types

trait boundscompiles
// "any T, as long as T implements Summary"
fn print_card<T: Summary>(item: &T) {
    println!("┌ {}", item.headline());
    println!("└ {}", item.preview());
}

// same thing, lighter syntax
fn print_card(item: &impl Summary) { /* … */ }

The : Summary part is a trait bound, and it's the whole idea behind Rust generics. In Python a duck-typed function hopes the argument has the method; here the compiler checks it, at the call site, for every type you ever pass. Unlike Java or C# generics, this costs nothing at runtime — the compiler generates a specialised copy of the function per concrete type.

Generic or dyn? The one distinction to learn

<T: Summary> — decided while compiling

let items: Vec<Invoice> = /* … */;
// all one type. fastest possible calls.

Each type gets its own compiled copy. No indirection. Slightly larger binary.

Box<dyn Summary> — decided while running

let items: Vec<Box<dyn Summary>> = vec![
    Box::new(invoice),
    Box::new(note),      // mixed types!
];

One copy, method looked up through a table. This is what PHP and Python do for every call.

The decision rule is simple: if the collection must hold different types, you need dyn. Otherwise use generics. A Vec<T> holds one type only — that's what makes it fast, and it's the constraint that sends people to dyn.

The traits you'll meet on day one

TraitGives youUsually via
DebugPrinting with {:?} for developers#[derive(Debug)]
DisplayPrinting with {} for usersWritten by hand
Clone.clone()#[derive(Clone)]
PartialEq==#[derive(PartialEq)]
DefaultType::default()#[derive(Default)]
From / IntoConversion, and what ? usesWritten by hand
Iteratorfor loops and the whole of module 11Written by hand

#[derive(Debug, Clone, PartialEq)] above a struct generates all three implementations. You will type it constantly — start with Debug on everything, since {:?} is your var_dump and your repr().

Learn more — the orphan rule, and the error it produces

You may implement your trait for someone else's type, or someone else's trait for your type. You may not implement someone else's trait for someone else's type — for instance Display for Vec<T>. That's the orphan rule, and it exists so two crates can't provide conflicting implementations that break when used together.

The workaround is the newtype pattern: wrap it in a struct of your own. struct Lines(Vec<String>); is now your type, so you can implement anything for it. This costs nothing at runtime and is used pervasively in real Rust code.

Glossary · module 10

trait
A set of methods a type can implement. Rust's interface.
trait bound
T: Summary — a requirement on a generic type parameter.
generic
Code parameterised over types, resolved at compile time.
monomorphisation
The compiler emitting one specialised copy per concrete type. Why generics are free.
trait object
dyn Trait — a value whose exact type is only known at runtime.
static dispatch
The call target is decided while compiling. Generics.
dynamic dispatch
The call target is looked up while running. dyn.
derive
An attribute that generates a trait implementation for you.
newtype
A one-field wrapper struct, used to gain the right to implement traits.

Exercise 10

Make your type printable

Give struct Money { cents: i64 } a Display implementation so that println!("{}", Money { cents: 4250 }) prints 42.50. Then decide whether Vec<Money> or Vec<Box<dyn Display>> is right for a list of amounts, and why.

src/lib.rscompiles
use std::fmt;

struct Money { cents: i64 }

impl fmt::Display for Money {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}.{:02}", self.cents / 100, (self.cents % 100).abs())
    }
}

write! is the same machinery as println!, aimed at a formatter rather than the terminal. Returning its result is why the body has no semicolon.

The collection: Vec<Money>. Every element is the same type, so there's nothing for dynamic dispatch to do — you'd pay an allocation per element and a pointer hop per call, and gain nothing. Reach for dyn only when the list genuinely holds different types. If you find yourself boxing a single type, that's a signal to simplify.

Quiz · module 10 · 2 questions

You need a list holding both Invoice and Note values, both implementing Summary. What do you write?

C. A Vec stores one type, and elements must be the same size. Boxing makes every element a pointer, and dyn carries the method table.

D deserves credit — it's a genuinely good alternative and often the better one when the set of types is closed and known. It's not what was asked for, but if you picked it, your instincts are sound. A and B aren't valid syntax in that position; generic parameters and impl Trait both resolve to one type per use.

Why do Rust generics cost nothing at runtime?

A. Monomorphisation. print_card::<Invoice> and print_card::<Note> become two real functions, each fully optimised as though you'd written it by hand.

B is Java's type erasure — a reasonable guess if that's your background, and the reason Java generics can't avoid boxing. D describes the dyn path, which does have a small per-call cost.

Module 11 · familiar

Iterators and closures: familiar territory

Objective

Write data pipelines that read like Python comprehensions and compile to hand-written loop speed.

Good news: this module is mostly recognition. If you've used Python generators or PHP's array_map, you already have the concepts. A closure is an anonymous function that captures its surroundings, written |x| x * 2. An iterator is lazy — it does nothing until something asks it for values.

a pipelinecompiles
let invoices = vec![4250, 0, 19900, 350];

let big_total: u64 = invoices
    .iter()                      // borrow each element
    .filter(|&&cents| cents > 1000) // keep the large ones
    .sum();                      // this line does all the work

let labels: Vec<String> = invoices
    .iter()
    .map(|c| format!("{:.2}", *c as f64 / 100.0))
    .collect();                  // and this one

Nothing happens in filter or map. They build a description. The final sum or collect — the consumer — drives the whole chain, touching each element exactly once. And because it all compiles to a single specialised loop, this is typically as fast as the for loop you'd have written, sometimes faster.

That last point is the one worth savouring: in Python, chaining map and filter costs you performance and you accept it for readability. In Rust the idiomatic version and the fast version are the same version.

Which iter? Ownership again

CallYieldsAfterwards
.iter()&T — shared borrowsCollection still yours
.iter_mut()&mut T — unique borrowsStill yours, elements modified in place
.into_iter()T — owned valuesCollection consumed and gone

for x in &collection is .iter(); for x in collection is .into_iter() — which is why the second form makes the collection unusable afterwards. That surprises everyone once, and then never again.

the consumers you'll use mostcompiles
let v = vec![3, 9, 4];

v.iter().any(|&x| x > 5);            // true
v.iter().all(|&x| x > 0);            // true
v.iter().find(|&&x| x % 2 == 0);      // Some(&4)
v.iter().position(|&x| x == 9);       // Some(1)
v.iter().max();                        // Some(&9)
v.iter().count();                      // 3
v.iter().enumerate();                  // (0, &3), (1, &9), …
v.iter().rev().take(2);                // 4, 9 — lazily
Learn more — the three closure traits, briefly

Closures are categorised by what they do with what they capture, and you'll see these in error messages:

  • Fn — only reads its captures. Callable repeatedly.
  • FnMut — modifies its captures. Callable repeatedly, needs unique access.
  • FnOnce — consumes its captures. Callable once.

The compiler infers which one you wrote; you rarely name them except when storing a closure or accepting one as a parameter. The move keyword, as in move |x| …, forces captures to be taken by ownership rather than borrowed — which is exactly what you need when handing a closure to a thread that may outlive the current function. Module 12 uses it.

Glossary · module 11

closure
Anonymous function that captures its environment: |a, b| a + b.
iterator
Anything producing values one at a time via next().
lazy
Nothing is computed until a consumer asks. Adaptors are free until then.
adaptor
An iterator-to-iterator step: map, filter, take, skip.
consumer
A method that drives the chain and produces a final value: collect, sum, count.
collect
Gathers an iterator into a collection. The target type decides which.
move
Makes a closure take ownership of what it captures.
zero-cost abstraction
A high-level construct that compiles to what you'd have written by hand.

Exercise 11

Replace the loop

Rewrite this as a single iterator chain, with no mut and no for.

rewrite meworks, but not idiomatic
fn overdue_refs(lines: &[String]) -> Vec<String> {
    let mut out = Vec::new();
    for line in lines {
        if line.starts_with("OVERDUE") {
            out.push(line.to_uppercase());
        }
    }
    out
}
src/lib.rscompiles
fn overdue_refs(lines: &[String]) -> Vec<String> {
    lines
        .iter()
        .filter(|line| line.starts_with("OVERDUE"))
        .map(|line| line.to_uppercase())
        .collect()
}

collect() knows to build a Vec<String> from the return type — no annotation needed. When there's no such clue, you'll write .collect::<Vec<_>>(), and the _ lets inference fill in the element type.

filter_map would merge the two steps into one, which is neater when the test and the transformation are the same operation. Two clear steps beat one clever one while you're learning.

Quiz · module 11

What does v.iter().map(|x| expensive(x)) do on its own, with no further call?

B. Adaptors are lazy. Rust will even warn you that the iterator is unused, because building one and discarding it is almost always a mistake.

C is a nice guess — types are resolved entirely at compile time, so nothing needs to be run to determine them. A is Python 2's map, and precisely the eager behaviour that laziness avoids.

Module 12 · deep end

Sharing and concurrency: the escape hatches

Objective

Recognise which tool to reach for when one owner isn't enough, and see why Rust's threads are safe for the same reason its borrows are.

Sooner or later you'll need two parts of a program to share one value, and "exactly one owner" will be in the way. Rust's answer is a small set of wrapper types. You don't need to master them now — you need to recognise their names, so that when you meet one you know what problem it solves.

WrapperUse it whenCost
Box<T>One owner, but the value must live on the heap — recursive types, or dyn Trait.One allocation, one pointer hop.
Rc<T>Several owners in one thread. Freed when the last one goes.A reference count. Read-only.
RefCell<T>You need mutation through a shared handle.Borrow rules checked at runtime — it panics instead of failing to compile.
Arc<T>Several owners across threads. The Rc that's thread-safe.An atomic count — slightly more than Rc.
Mutex<T>Mutation shared across threads, one at a time.Locking. The lock releases itself when the guard is dropped.

The two combinations you'll see in real code, and what they mean when you spot them: Rc<RefCell<T>> for shared mutable state in one thread, and Arc<Mutex<T>> for shared mutable state across threads. Each reads as "many owners" wrapped around "mutation allowed".

Arc and Mutex Three threads each hold an Arc handle to one shared counter. All three pass through a single mutex, which admits one writer at a time. three threads thread A thread B thread C Arc — each holds an owner Arc clone Arc clone Arc clone Mutex — one at a time .lock() grants &mut counter: u64 one value, one buffer the same &mut-is-exclusive rule from module 4, now enforced across threads at runtime
Arc answers "who owns it"; Mutex answers "who may write". Splitting the two questions is why the combination reads strangely at first and makes sense afterwards.

Fearless concurrency, concretely

Here's the claim that draws people to Rust: data races are compile errors. Not caught in review, not found by a race detector under load — rejected before the program exists.

Two marker traits do it. Send means a type is safe to move to another thread; Sync means it's safe to share by reference. You never implement them — the compiler works them out — but they're why Rc can't cross a thread boundary while Arc can. If you try, the error mentions Send, and now you'll know what it means.

three threads, one countercompiles
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..3 {
        let counter = Arc::clone(&counter);   // a new owner, same value
        handles.push(thread::spawn(move || {
            for _ in 0..1000 {
                let mut n = counter.lock().unwrap();
                *n += 1;
            }   // guard dropped here → lock released, automatically
        }));
    }

    for h in handles { h.join().unwrap(); }
    println!("{}", *counter.lock().unwrap());   // always 3000
}

Always 3000. Not usually. There is no version of this program that misses an increment, and no version that forgets to unlock — the guard's Drop handles that on every exit path, including a panic. Compare the equivalent in a language where forgetting to release a lock is a thing that happens.

Two things Rust does not prevent

Deadlocks. Take two locks in opposite orders in two threads and you'll hang, exactly as anywhere else. Logic races. The compiler guarantees no two threads touch the same memory unsafely; it can't know your business rules were applied in the wrong order. "Fearless concurrency" means memory safety, not correctness.

Learn more — async, and why to leave it for later

Threads are for CPU work. For thousands of simultaneous network connections you want async, which the book covers in its own chapter. The mental model: async fn doesn't return a result, it returns a Future — a state machine that makes progress when polled. .await is a point where it may pause and let something else run. If you've used Python's asyncio, the shape will be familiar.

Two differences that catch people out. First, Rust ships no runtime, so you pick one — tokio in practice. Second, and more importantly for you: async combines the difficulty of modules 3, 4 and 5 all at once, adding Pin, Send bounds on futures, and cancellation at every .await. Learning it before ownership is comfortable is the single most reliable way to conclude that Rust is impossible. Build two or three synchronous programs first.

Learn more — unsafe is not what the name suggests

unsafe doesn't switch off the borrow checker. It unlocks five specific abilities — chiefly dereferencing raw pointers and calling foreign functions — and shifts responsibility for a handful of invariants from the compiler to you. Everything else still applies.

You will almost certainly never write it. It exists so that the standard library can implement Vec, so Rust can call C libraries, and so embedded code can touch hardware registers. If you find yourself reaching for it as a way out of a borrow error, that's a sign the design needs rethinking, not that the language needs overriding.

Glossary · module 12

Box<T>
Single-owner heap allocation. The simplest smart pointer.
Rc<T>
Reference-counted shared ownership, single-threaded, read-only.
Arc<T>
Atomically reference-counted — the thread-safe Rc.
RefCell<T>
Moves borrow checking to runtime, enabling mutation through a shared handle.
interior mutability
The pattern of changing a value through a shared reference.
Mutex<T>
Mutual exclusion. .lock() yields a guard granting unique access.
guard
A value whose Drop releases a resource — here, the lock.
Send / Sync
Marker traits: safe to move between threads / safe to share between them.
data race
Unsynchronised concurrent access with at least one writer. A compile error in safe Rust.

Exercise 12

Diagnose four situations

For each, name the wrapper you'd reach for — and for one of them, argue that no wrapper is needed at all.

  1. A tree node needs to hold children of the same type.
  2. Two structs in one thread both need to read a shared config that never changes.
  3. Four worker threads append to one shared log.
  4. A function needs to read a Vec the caller keeps using afterwards.
  1. Box<T> — or Vec<Node> for the children, which is a Box underneath. A struct can't contain itself directly: its size would be infinite. A pointer has a known size, which breaks the recursion.
  2. Rc<Config> — several owners, one thread, read-only. No RefCell, because nothing mutates. (An &Config also works if the lifetimes line up; Rc is what you use when they don't.)
  3. Arc<Mutex<Vec<String>>> — across threads and mutating, so both wrappers. Rc would be rejected for not being Send, and that error message is the compiler teaching you this distinction.
  4. No wrapper. Take &[T] and you're done. This one is in the list because reaching for a smart pointer where a plain borrow suffices is the most common overcorrection after modules 3 and 4 — if the value's owner clearly outlives the borrow, borrow it.

Quiz · module 12 · 2 questions

You pass an Rc<Config> into thread::spawn and the compiler refuses. Why?

C. Rc updates its count with plain arithmetic — fast, and unsound if two threads do it at once. So Rc isn't Send, and Arc exists to pay for atomics only when you need them.

A is a real error you'll also hit, with a different message — and adding move here wouldn't help, because the problem is the type rather than the capture. D is wrong on the facts: threads in one process share a heap.

Rust's threads are called "fearless". What exactly is guaranteed?

A. A precise and limited claim, which is why it holds. Memory safety across threads is guaranteed; ordering, liveness and business logic are still your job.

B is the most attractive wrong answer — deadlock freedom needs whole-program lock-order analysis, which Rust doesn't attempt. D overclaims in a way that's worth being able to reject when someone says it to you.

After this page

Where to go next

This page is scaffolding. It gives you the mental model in the order that hurts least, but you cannot learn Rust without arguing with the compiler yourself. The next step is a project small enough to finish.

A four-week plan that works

  1. Week 1 — Rustlings. Small compiler-checked exercises. Perfect for making the ownership rules automatic rather than merely understood.
  2. Week 2 — the book, chapters 1 to 10. You've now seen everything in them once, so this is consolidation rather than first contact. Read chapters 4 and 10 twice.
  3. Week 3 — a command-line tool. Something you'd genuinely use: a log filter, a CSV summariser, a file renamer. Add clap for arguments and anyhow for errors. Finish it.
  4. Week 4 — the book, chapters 13 to 17. Iterators, smart pointers, concurrency. Then rewrite week 3's tool now that you know more, and notice how much shorter it gets.

Leave async, macros and unsafe for month three. They aren't harder to read, but they're much harder to debug while ownership still takes conscious effort.

The official material, and what each is for

ResourceReach for it when
The BookYou want the full explanation, in sequence. The canonical text, free online.
RustlingsYou want reps. Small exercises fixed against the compiler.
Rust by ExampleYou'd rather read code than prose. Runnable snippets, minimal talking.
Standard library docsDaily. Learning to read std docs is itself a skill worth deliberate practice.
Error indexYou want the long explanation of an error code — or run rustc --explain E0382.
The PlaygroundTesting an idea without making a project. Shares by URL.
The user forumYou're stuck. Unusually patient with beginners, which is not true of every language community.

All of it is also available offline: rustup doc opens the whole set in your browser with no network. Worth knowing on a train.

The difficulty map, revisited

Now that the terms mean something, here's an honest ranking of what's still ahead — so you can recognise a genuinely hard thing as hard, rather than as evidence you're bad at this.

ConceptWhereWhy it's hard
Ownership & borrowingBook ch. 4Changes how you design data, not just how you type. tier 1
LifetimesBook ch. 10, 20Annotations describe rather than command — and elision hides them until it doesn't. tier 1
AsyncBook ch. 17Piles Pin, Send bounds and cancellation on top of tier 1. tier 1
Traits in depthBook ch. 10, 18, 20Associated types, object safety, and the orphan rule's flat refusals. tier 2
Smart pointersBook ch. 15Knowing which wrapper, rather than how any one works. tier 2
Closures & Fn traitsBook ch. 13Simple until they meet lifetimes. tier 2
Macros, unsafe, varianceReference, NomiconThe genuinely deep end. Optional for years. tier 3

One last thing, and it's the most useful sentence on this page. When the compiler rejects your code, it is almost never being pedantic — it has found a question about your program that you hadn't answered yet. Read the error as a question. That reframe is what turns the first fortnight from a fight into a conversation.