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.
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.
Notice the shape: what went wrong, exactly where, why it is a rule, and a suggested edit. Rust's error messages are documentation delivered at the moment you need it.
Passing greeting into shout didn't copy it and didn't share it —
it handed it over. The function became the owner, and when it
finished, the text was freed. Line 4 is reaching for something that no longer exists.
The fix is to lend it instead of giving it away:
fn main() {
let greeting = String::from("Bonjour");
shout(&greeting); // lend it
shout(&greeting); // lend it again, no problem
}
fn shout(text: &String) { // "I only borrow it"
println!("{}!", text.to_uppercase());
}
One ampersand. That single character is the difference between giving and lending, and it is the idea the rest of this page is built around.
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
| Rust | You know it as | What it does |
|---|---|---|
rustup | nvm, pyenv | Installs and switches Rust versions. |
cargo | composer, pip + make | Dependencies, building, running, testing — one tool. |
Cargo.toml | composer.json, pyproject.toml | Declares your package and its dependencies. |
Cargo.lock | composer.lock, poetry.lock | Pins exact versions. Commit it for applications. |
| crate | package on Packagist / PyPI | A unit of Rust code. Published on crates.io. |
cargo check | — | Type-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.
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.
| Module | Difficulty | What it unlocks |
|---|---|---|
| 1 · Toolchain & first program | gentle | Running code at all. |
| 2 · Values & types | gentle | Reading any snippet. |
| 3 · Ownership | the wall | Every error message you'll get in week one. |
| 4 · Borrowing | the wall | Writing functions that take arguments. |
| 5 · Lifetimes | the wall | Structs that hold references; reading library docs. |
| 6 · Structs & enums | moderate | Modelling your own domain. |
| 7 · Option | moderate | Life without null. |
8 · Result & ? | moderate | Life without exceptions. |
| 9 · Strings & collections | moderate | Getting real work done — and the biggest trap for you specifically. |
| 10 · Traits & generics | moderate | Reuse without inheritance. |
| 11 · Iterators & closures | familiar | Idiomatic, fast data pipelines. |
| 12 · Sharing & concurrency | deep end | Threads 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.
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
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, becausemutsays 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 declaredmut.
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.
let counter = 10;
counter = counter - 3;
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.
| Type | Holds | Your equivalent |
|---|---|---|
i32 i64 | Signed whole numbers | PHP int, Python int (but bounded) |
u8 u32 usize | Unsigned — never negative | No equivalent. usize is for sizes and indexes. |
f64 | Decimals | float |
bool | true / false | Same — but only these two, no truthiness |
char | One Unicode character | No equivalent; a 1-char string isn't a char |
String | Owned, growable text | str in Python, string in PHP |
&str | A borrowed view into text | No equivalent — see module 9 |
(i32, bool) | Tuple: fixed group of mixed types | Python tuple |
[i32; 4] | Array: exactly four numbers | Nothing fixed-size; you want Vec |
Worked example: the conversions Rust won't do
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
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.
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.
- Every value has exactly one owner.
- There can only be one owner at a time.
- 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.
s1 no longer
counts as a thing.
Worked example: moves, and the exception
let s1 = String::from("Bonjour");
let s2 = s1; // s1 moved into s2
println!("{s1}"); // error[E0382]: borrow of moved value
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
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.
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.
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.
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
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.
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
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.
count_overdue— returns how many entries start with"OVERDUE".add_footer— pushes a summary line onto the end of the list.into_report— consumes the list and returns a single joinedString, after which the caller has no further use for it.
// 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.
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:
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.
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:
// 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":
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
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.
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.
The fix — own the data instead of borrowing it:
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.
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:
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.
implblock- Where a type's methods and associated functions are defined.
- method
- A function taking
self,&selfor&mut self. Called with a dot. - associated function
- A function in an
implwith noself. 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
matcharm, 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.
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:
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
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
// 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)orNone. Rust's replacement for null. - generic
<T>— the type works for anyT, 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
Noneuntouched. and_then- Like
map, for functions that themselves return anOption. - 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.
struct User { name: String, nickname: Option<String> }
fn display_name(u: &User) -> String {
u.nickname.clone().unwrap()
}
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 Option → and_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:
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)?;
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 canmatchon precisely what went wrong.anyhow— for applications. Oneanyhow::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)orErr(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
useor Pythonfrom … 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.
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.
let last = &greeting[8..12]; allocates nothing. A slice is a pointer and a
length, which is why passing &str around is essentially free.
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:
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
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?
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.
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
// "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
| Trait | Gives you | Usually via |
|---|---|---|
Debug | Printing with {:?} for developers | #[derive(Debug)] |
Display | Printing with {} for users | Written by hand |
Clone | .clone() | #[derive(Clone)] |
PartialEq | == | #[derive(PartialEq)] |
Default | Type::default() | #[derive(Default)] |
From / Into | Conversion, and what ? uses | Written by hand |
Iterator | for loops and the whole of module 11 | Written 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.
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.
let invoices = vec![4250, 0, 19900, 350];
let big_total: u64 = invoices
.iter() // borrow each element
.filter(|&¢s| 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
| Call | Yields | Afterwards |
|---|---|---|
.iter() | &T — shared borrows | Collection still yours |
.iter_mut() | &mut T — unique borrows | Still yours, elements modified in place |
.into_iter() | T — owned values | Collection 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.
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.
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
}
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.
| Wrapper | Use it when | Cost |
|---|---|---|
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 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.
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
Dropreleases 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.
- A tree node needs to hold children of the same type.
- Two structs in one thread both need to read a shared config that never changes.
- Four worker threads append to one shared log.
- A function needs to read a
Vecthe caller keeps using afterwards.
Box<T>— orVec<Node>for the children, which is aBoxunderneath. A struct can't contain itself directly: its size would be infinite. A pointer has a known size, which breaks the recursion.Rc<Config>— several owners, one thread, read-only. NoRefCell, because nothing mutates. (An&Configalso works if the lifetimes line up;Rcis what you use when they don't.)Arc<Mutex<Vec<String>>>— across threads and mutating, so both wrappers.Rcwould be rejected for not beingSend, and that error message is the compiler teaching you this distinction.- 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
- Week 1 — Rustlings. Small compiler-checked exercises. Perfect for making the ownership rules automatic rather than merely understood.
- 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.
- Week 3 — a command-line tool. Something you'd genuinely use: a log filter, a
CSV summariser, a file renamer. Add
clapfor arguments andanyhowfor errors. Finish it. - 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
| Resource | Reach for it when |
|---|---|
| The Book | You want the full explanation, in sequence. The canonical text, free online. |
| Rustlings | You want reps. Small exercises fixed against the compiler. |
| Rust by Example | You'd rather read code than prose. Runnable snippets, minimal talking. |
| Standard library docs | Daily. Learning to read std docs is itself a skill worth deliberate practice. |
| Error index | You want the long explanation of an error code — or run rustc --explain E0382. |
| The Playground | Testing an idea without making a project. Shares by URL. |
| The user forum | You'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.
| Concept | Where | Why it's hard |
|---|---|---|
| Ownership & borrowing | Book ch. 4 | Changes how you design data, not just how you type. tier 1 |
| Lifetimes | Book ch. 10, 20 | Annotations describe rather than command — and elision hides them until it doesn't. tier 1 |
| Async | Book ch. 17 | Piles Pin, Send bounds and cancellation on top of tier 1. tier 1 |
| Traits in depth | Book ch. 10, 18, 20 | Associated types, object safety, and the orphan rule's flat refusals. tier 2 |
| Smart pointers | Book ch. 15 | Knowing which wrapper, rather than how any one works. tier 2 |
Closures & Fn traits | Book ch. 13 | Simple until they meet lifetimes. tier 2 |
Macros, unsafe, variance | Reference, Nomicon | The 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.