Retention and Mastery Drills
This supplement exists for one reason: deep understanding fades unless it is rehearsed.
Do not treat these as optional extras. This is the part of the handbook that turns “I understood that chapter when I read it” into “I can still use that idea under pressure.”
Drill Deck 1 - Ownership and Borrowing
In Plain English
Ownership answers: “Who is responsible for cleaning this up?”
Borrowing answers: “Who may use it without taking responsibility away from the owner?”
What problem is Rust solving here?
It is solving the class of bugs caused by unclear responsibility:
- leaks
- double frees
- use-after-free
- accidental aliasing
- data races
What invariant is being protected?
At any moment, a value has one cleanup story and one safe-access story.
Common mistake
Thinking “the compiler hates sharing data.” It does not. Rust allows sharing. It forbids ambiguous sharing.
Why the compiler rejects this
When you see move or borrow errors, Rust is usually saying:
- you already transferred responsibility
- you created two incompatible access modes
- you tried to keep a reference alive longer than the owner
How an expert thinks about this
An expert reads function signatures as ownership contracts:
Tmeans transfer&Tmeans observe&mut Tmeans exclusive mutation
If you remember only 3 things
- Ownership is resource management, not syntax trivia.
- Borrowing is about safe access, not convenience only.
- Many readers or one writer is the heart of the model.
Memory hooks / mnemonics
- Own, lend, return nothing dangling
- Many readers or one writer
- Move means old name is done
Flashcards
| Front | Back |
|---|---|
Why does String move by default? | Because it owns heap data and must have one cleanup path |
Why can i32 be Copy? | No destructor, cheap duplication, no ownership ambiguity |
| Why can shared and mutable borrows not overlap? | To prevent aliasing-plus-mutation bugs |
Cheat sheet
- Accept
&strover&String - Accept
&[T]over&Vec<T> - Use ownership transfer only when the callee should truly take over
- Clone late, rarely, and consciously
Code reading drills
- Open a crate and mark each public function as
takes ownership,borrows, ormutably borrows. - Find one place where a
clone()happens and ask whether borrowing could have worked. - Find one type with a
Dropimpl and explain who owns it at every step.
Spot the bug
#![allow(unused)]
fn main() {
fn append_world(s: String) {
let view = &s;
println!("{}", view);
// pretend more work happens here
}
}
Question: what is the ownership smell?
Answer: the function takes ownership even though it only reads. It should likely accept &str or &String.
Compiler-error interpretation practice
E0382: you used the old owner after a moveE0502: your read and write stories overlapE0596: you are trying to mutate through a non-mutable access path
Drill Deck 2 - Lifetimes and Slices
In Plain English
Lifetimes are how Rust proves references stay valid. Slices are borrowed views whose validity depends on that proof.
What problem is Rust solving here?
Dangling references and invalid views into memory.
What invariant is being protected?
A reference must never outlive the data it points to.
Common mistake
Treating lifetimes as timers instead of relationships.
Why the compiler rejects this
The compiler is not saying “your code runs too long.” It is saying “I cannot prove the returned reference is tied to a still-valid owner.”
How an expert thinks about this
An expert asks: “What data owns this memory, and what references are logically derived from it?”
If you remember only 3 things
- Lifetimes are relationships, not durations on a clock.
&strand&[T]are borrowed views.- Borrowed output must be connected to borrowed input.
Memory hooks / mnemonics
- No owner, no reference
- Slices are views, not containers
- Returned borrow must come from an input borrow
Flashcards
| Front | Back |
|---|---|
What does fn f<'a>(x: &'a str) -> &'a str mean? | Output is valid no longer than x |
What is &str? | A borrowed UTF-8 slice |
Why is s[0] not allowed on strings? | UTF-8 indexing by byte is unsafe for text |
Cheat sheet
- Prefer returned owned values when relationships are genuinely complex
- Use slices for borrowed contiguous data
- Annotate lifetimes only when inference cannot express the relationship
Code reading drills
- Find one function returning
&strand identify which input it borrows from. - Find one parser that walks slices and explain how it avoids allocation.
- Find one struct with lifetime parameters and explain what it borrows.
Spot the bug
#![allow(unused)]
fn main() {
fn first_piece() -> &str {
let text = String::from("a,b,c");
text.split(',').next().unwrap()
}
}
Why it fails:
- the returned slice points into
text textis dropped at function end- the borrow cannot outlive the owner
Compiler-error interpretation practice
E0106: you declared a borrowed relationship but did not spell it outE0515: you returned a reference to local data that will be dropped- “does not live long enough”: the owner disappears before the borrow ends
Drill Deck 3 - Traits, Generics, and Error Design
In Plain English
Traits describe capabilities. Generics let one algorithm work for many types. Error design tells callers what can go wrong without hiding the story.
What problem is Rust solving here?
Rust wants abstraction without giving up performance or type clarity.
What invariant is being protected?
Generic code should only assume the capabilities it explicitly asks for.
Common mistake
Using trait bounds like cargo cult boilerplate instead of as precise capability contracts.
Why the compiler rejects this
If a trait bound is missing, Rust is saying: “you are asking for behavior your type contract never promised.”
How an expert thinks about this
Experts design APIs around capabilities and failure surfaces:
- what methods must exist?
- who owns the error vocabulary?
- is this dynamic dispatch or static dispatch?
If you remember only 3 things
- Trait bounds are promises about behavior.
impl Traitand generics are usually static-dispatch tools.- Library errors should be explicit; application errors can be aggregated.
Memory hooks / mnemonics
- Traits say can, not is
- Bounds are promises
- Library errors name causes; app errors collect context
Flashcards
| Front | Back |
|---|---|
When use thiserror? | In libraries with explicit error types |
When use anyhow? | In applications where ergonomic error propagation matters |
dyn Trait or impl Trait? | dyn for runtime polymorphism, impl for static dispatch |
Cheat sheet
- Prefer minimal trait bounds
- Prefer associated types when the output type is conceptually tied to the trait
- Use
Fromto make error conversion clean - Avoid exposing
anyhow::Errorfrom library APIs
Code reading drills
- Find one
whereclause in a real crate and translate it into plain English. - Find one error enum and map which modules produce each variant.
- Find one trait with an associated type and explain why a generic type parameter was not used instead.
Spot the bug
#![allow(unused)]
fn main() {
pub fn parse<T>(input: &str) -> T {
input.parse().unwrap()
}
}
Problems:
- missing trait bound
- panics instead of exposing failure
- poor library API shape
Compiler-error interpretation practice
E0277: your type does not satisfy the promised capabilityE0599: the method exists conceptually, but the trait is not available in this context- object-safety errors: your trait design does not fit runtime dispatch
Drill Deck 4 - Concurrency, Async, and Pin
In Plain English
Threads let work happen in parallel. Async lets one thread manage many waiting tasks. Pin exists because async state machines can contain self-references that must not move after polling begins.
What problem is Rust solving here?
It is trying to give you concurrency and async I/O without silently giving you races, use-after-free, or hidden scheduler magic.
What invariant is being protected?
Shared state across threads or tasks must remain valid, synchronized, and movable only when moving is safe.
Common mistake
Thinking async is “just lighter threads.” In Rust, async is an explicit state machine model with explicit runtime boundaries.
Why the compiler rejects this
Rust rejects async and concurrency code when:
- a future is not
Sendacross task boundaries - mutable state is shared unsafely
- a value must not move after pinning assumptions begin
How an expert thinks about this
An expert asks:
- is this CPU-bound or I/O-bound?
- who owns cancellation?
- where does backpressure happen?
- does this future cross threads?
If you remember only 3 things
- Async in Rust is explicit because hidden lifetime and movement bugs are unacceptable.
SendandSyncare about cross-thread safety guarantees.- Pin matters because some futures become movement-sensitive state machines.
Memory hooks / mnemonics
- Async is a state machine
Sendcrosses threads,Syncshares refs- Pin means stay put
Flashcards
| Front | Back |
|---|---|
Does calling an async fn run it? | No, it creates a future |
Why can Rc<T> break async task spawning? | It is not Send |
| Why does Pin matter for futures? | Polling may create self-referential state assumptions |
Cheat sheet
- prefer channels when ownership transfer is clearer than locking
- prefer
Arc<T>only when ownership truly must be shared - prefer
tokio::sync::Mutexfor async-held locks - design cancellation deliberately
Code reading drills
- Trace one request from Tokio runtime startup to handler completion.
- Find one
select!and explain what happens to losing branches. - Find one
spawncall and verify whether captured state must beSend + 'static.
Spot the bug
#![allow(unused)]
fn main() {
let state = std::rc::Rc::new(String::from("hello"));
tokio::spawn(async move {
println!("{}", state);
});
}
Why it fails: Rc<T> is not thread-safe, and spawned tasks may need Send.
Compiler-error interpretation practice
- “
future cannot be sent between threads safely”: a captured value is notSend - borrow-across-
awaiterrors: a borrow lives through a suspension point in an invalid way - pinning errors: movement assumptions conflict with the future’s internal structure
Drill Deck 5 - Unsafe, FFI, and Memory Layout
In Plain English
Unsafe Rust exists so safe Rust can be powerful. It is not “turning off the compiler.” It is taking manual responsibility for a narrower set of promises.
What problem is Rust solving here?
Some jobs require operations the compiler cannot verify directly:
- raw pointers
- foreign code boundaries
- custom memory management
- lock-free primitives
What invariant is being protected?
Unsafe code must uphold the same safety guarantees that safe callers expect.
Common mistake
Thinking unsafe is acceptable because “I know what this code does.” The real question is whether you can state and uphold the invariants for every caller.
Why the compiler rejects this
Safe Rust rejects code when it cannot prove memory validity. Unsafe lets you proceed only if you manually guarantee:
- pointer validity
- aliasing discipline
- initialization
- lifetime correctness
- thread-safety where required
How an expert thinks about this
Experts isolate unsafe into tiny, documented blocks surrounded by safe APIs.
If you remember only 3 things
- Unsafe is a proof obligation, not a performance badge.
- FFI boundaries need explicit layout and ownership rules.
- Small unsafe cores with safe wrappers are the right pattern.
Memory hooks / mnemonics
- Unsafe means: now you are the borrow checker
- Document the invariant before the block
- Safe outside, unsafe inside
Flashcards
| Front | Back |
|---|---|
What does unsafe permit? | Operations the compiler cannot prove safe, not arbitrary correctness |
Why use #[repr(C)] in FFI? | To make layout compatible with C expectations |
| Should unsafe APIs stay unsafe at the boundary? | Usually no; expose a safe wrapper when you can uphold invariants internally |
Cheat sheet
- state
SAFETY:comments in plain English - validate pointer provenance and alignment
- keep ownership rules explicit across FFI
- benchmark before using unsafe for “performance”
Code reading drills
- Find one
unsafeblock in a crate and write down the exact invariant it assumes. - Find one
#[repr(C)]type and explain who depends on that layout. - Find one safe wrapper around raw pointers and explain how it contains risk.
Spot the bug
#![allow(unused)]
fn main() {
unsafe fn get(ptr: *const i32) -> i32 {
*ptr
}
}
What is missing:
- null validity assumptions
- alignment assumptions
- lifetime/provenance expectations
- caller contract
Compiler-error interpretation practice
- alignment and raw pointer issues mean the compiler cannot prove valid access
- atomics/orderings are rarely compiler errors but often logic bugs; read them as invariant design problems
- FFI bugs often compile cleanly and fail at runtime, so your documentation burden is higher
Drill Deck 6 - Repo Reading and Contribution
In Plain English
Reading a Rust repo is not about reading every file. It is about finding the code paths and invariants that matter.
What problem is Rust solving here?
Large codebases are hard because intent is distributed. Rust helps by making ownership, types, feature flags, and error boundaries more explicit.
What invariant is being protected?
A good first contribution changes behavior while preserving the repo’s existing contracts.
Common mistake
Starting from random internal files instead of README, Cargo.toml, tests, and entry points.
Why the compiler rejects this
In repo work, compiler errors are often telling you that your change crossed a crate boundary, ownership boundary, or feature-gated assumption you did not notice.
How an expert thinks about this
Experts work from outside in:
- what is the public behavior?
- where does input enter?
- where are errors defined?
- what tests already describe the invariant?
If you remember only 3 things
- Read tests earlier.
- Shrink the bug before fixing it.
- Keep first PRs boring and correct.
Memory hooks / mnemonics
- README, Cargo, tests, entry point
- Reproduce, reduce, repair
- One invariant, one PR
Flashcards
| Front | Back |
|---|---|
| First file after README? | Usually Cargo.toml |
| Best first PRs? | Docs, tests, diagnostics, focused bug fixes |
| What should a PR description explain? | Problem, approach, tests, and scope |
Cheat sheet
- run
cargo check,cargo test,cargo fmt,cargo clippy - inspect feature flags before changing behavior
- read error enums and integration tests before editing handlers
- avoid unrelated formatting churn
Code reading drills
- Pick one CLI crate and trace a subcommand from argument parsing to output.
- Pick one async service and map request entry, business logic, and error conversion.
- Pick one multi-crate workspace and explain why each crate boundary exists.
Spot the bug
You found an issue and changed three modules, renamed types, and reformatted half the repo in the same PR.
Bug: your fix is now hard to review, risky to merge, and difficult to revert.
Compiler-error interpretation practice
- feature-gated missing items: your build configuration differs from the issue report
- trait-bound failures across crates: the public API contract changed
- lifetime and ownership failures during refactors: your “small cleanup” was not actually ownership-neutral
Drill Deck 7 - Compiler Thinking and rustc
In Plain English
Rust is easier once you stop treating the compiler as a wall and start treating it as a structured pipeline with specific jobs.
What problem is Rust solving here?
Modern systems languages need strong guarantees, but those guarantees must come from a compiler architecture that can reason about syntax, types, control flow, and ownership.
What invariant is being protected?
Each compiler phase should transform the program while preserving meaning and making later checks more precise.
Common mistake
Assuming borrow checking operates on your original source exactly as written. In reality, later compiler representations matter.
Why the compiler rejects this
Different classes of errors come from different phases:
- parse errors from syntax
- type errors from HIR-level checking
- borrow errors from MIR reasoning
- trait-system errors from obligation solving
How an expert thinks about this
Experts ask: “Which compiler phase is complaining, and what representation is it likely using?”
If you remember only 3 things
- HIR is where high-level structure is normalized for type reasoning.
- MIR is where control flow and borrow logic become clearer.
- Monomorphization is why generics are fast but code size grows.
Memory hooks / mnemonics
- Parse, lower, reason, generate
- HIR for types, MIR for borrows
- Generics specialize late
Flashcards
| Front | Back |
|---|---|
| Where does borrow checking happen conceptually? | On MIR-like control-flow reasoning |
| Why does monomorphization matter? | It gives zero-cost generics and larger binaries |
| Why read RFCs? | They reveal the tradeoff logic behind language features |
Cheat sheet
- parse/AST: syntax structure
- HIR: desugared high-level meaning
- MIR: control-flow and ownership reasoning
- codegen: machine-specific lowering
Code reading drills
- Read one
rustcblog post or compiler-team article and summarize the phase it discusses. - Read one RFC and list the tradeoffs it accepted.
- Take one confusing borrow error from your own code and ask which MIR-level control-flow fact caused it.
Spot the bug
Mistake: “The compiler rejected my code, so Rust cannot express what I want.”
Correction: first ask whether the model is wrong, then whether the current compiler is conservative, then whether a different ownership shape expresses the idea more clearly.
Compiler-error interpretation practice
- syntax errors: you told the parser an incomplete story
- type errors: your value-level story is inconsistent
- borrow errors: your ownership and access story is inconsistent
- trait errors: your capability story is inconsistent
How to Use This Appendix
Repeat this cycle:
- Read one chapter
- Do the matching drill deck
- Read real code using the same concept
- Return one week later and do the flashcards and spot-the-bug section again
That repetition is how the material becomes durable.