๐Ÿฆ€

Rust Intermediate

A Complete 7-Chapter Programming Course

Topics covered:
Lifetimes & Traits · Generics & Monomorphization
Smart Pointers (Box, Rc, RefCell) · Concurrency
Modules & Crates · Testing in Rust

Exercises: 21 hands-on exercises with worked solutions
Format: A4 · Dark-theme code examples · framed throughout against Go
Course 2 of 3 · Advanced course follows

Table of Contents

  1. Lifetimes
  2. Traits
  3. Generics
  4. Smart Pointers
  5. Concurrency
  6. Modules & Crates
  7. Testing in Rust
Chapter 1 of 7

Lifetimes

Course 2 ยท Ch 1
Lifetimes
Giving the borrow checker a hint when a reference's relationship to its data isn't obvious from the code alone

Chapter 4 of Course 1 established that the borrow checker verifies references never outlive their data. Most of the time it does this invisibly. Occasionally โ€” when a function's signature involves multiple references whose relationship to each other isn't clear from the code alone โ€” the compiler needs an explicit hint. That hint is a lifetime annotation.

Why the Borrow Checker Sometimes Needs Help

fn longest(x: &str, y: &str) -> &str { if x.len() > y.len() { x } else { y } } // compile error: missing lifetime specifier

This won't compile as written. The compiler can't determine which input's lifetime the returned reference should be tied to โ€” it depends on which branch actually runs, and that's a runtime decision the compiler can't resolve at compile time without more information.

Lifetime Annotation Syntax

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } let s1 = String::from("long string"); let s2 = String::from("short"); let result = longest(s1.as_str(), s2.as_str());

'a โ€” an apostrophe plus a name, conventionally a single lowercase letter โ€” is a lifetime parameter. This is a common early misconception worth stating plainly: lifetime annotations don't change how long anything lives. They describe a relationship that already exists in the code โ€” here, that the returned reference is valid for at most as long as the shorter of x and y's actual lifetimes.

Lifetime Elision Rules

Most functions with references never need explicit lifetime annotations at all โ€” the compiler applies three elision rules automatically:

  1. Each reference parameter gets its own lifetime.
  2. If there's exactly one input lifetime, it's assigned to every output lifetime.
  3. If one parameter is &self or &mut self (a method), self's lifetime is assigned to every output.
impl Sentence { fn first_word(&self) -> &str { // no explicit lifetime needed โ€” rule 3 applies self.text.split_whitespace().next().unwrap_or("") } }

This is exactly why every &self method returning a reference back in Course 1 (Chapter 5) never needed an explicit lifetime โ€” elision was silently handling it via rule 3 the entire time.

Struct Lifetimes

A struct that holds a reference, rather than an owned value, needs its own lifetime parameter:

struct Excerpt<'a> { part: &'a str, }

This ties the struct instance's own validity to the reference it holds โ€” if the referenced data were dropped before the struct itself, that's a compile error, exactly Course 1 Chapter 4's dangling-reference protection, now extended to cover structs.

The 'static Lifetime

'static is a special, reserved lifetime meaning "valid for the entire duration of the program." String literals are &'static str, since they're baked directly into the compiled binary and never get dropped.

SituationAnnotation Needed?
One input reference, one output referenceNo โ€” elision rule 2 handles it
&self method returning a referenceNo โ€” elision rule 3 handles it
Multiple input references feeding one outputYes โ€” the compiler can't infer the relationship
A struct holding a referenceYes โ€” the struct needs its own lifetime parameter

Lifetime Annotation ('a)

Describes an existing relationship between references' validity โ€” never changes how long anything actually lives.

Elision Rules

Three automatic rules that cover the vast majority of real functions with no annotation required at all.

Struct Lifetimes

A struct storing a reference needs a lifetime parameter, tying the struct's validity to the data it borrows.

'static

Valid for the whole program's duration โ€” string literals are the most common example.

Zero Runtime Cost โ€” Again
Lifetimes are erased entirely at compile time, exactly like the generic types Course 3 will cover โ€” they exist purely for the compiler's own analysis and leave nothing behind in the compiled binary. This is another concrete piece of the same story Course 1 Chapter 4 told about the borrow checker itself: real safety analysis, zero runtime cost.
'static Is Almost Never the Right Quick Fix
It's tempting, when a lifetime error appears, to slap 'static on everything until the compiler stops complaining. This almost never actually solves the underlying ownership problem โ€” it just forces data to live forever, which is often not what's actually wanted, and sometimes isn't even possible for the data in question. A 'static requirement appearing where it doesn't obviously belong is usually a sign the real fix lies elsewhere โ€” often in how ownership is structured, not in the lifetime annotation itself.

Coding Challenges

Challenge 1

Write a function shortest<'a>(x: &'a str, y: &'a str) -> &'a str returning whichever string slice is shorter, following this chapter's longest example as a template.

๐Ÿ“„ View solution
Challenge 2

Define a struct FirstLine<'a> holding a single field line: &'a str, and a method fn announce(&self) -> &str that returns self.line. Explain, citing the specific elision rule, why announce needs no explicit lifetime annotation even though the struct itself does.

๐Ÿ“„ View solution
Challenge 3

Explain why changing a function's return type from &'a str to &'static str to silence a lifetime error is usually the wrong fix โ€” describe what actually goes wrong for the caller if the underlying data genuinely isn't valid for the whole program's lifetime.

๐Ÿ“„ View solution

Chapter 1 Quick Reference

  • 'a โ€” a lifetime parameter; describes an existing relationship between references, never changes actual lifetimes
  • Elision rule 1: each reference parameter gets its own lifetime
  • Elision rule 2: exactly one input lifetime โ†’ assigned to all outputs
  • Elision rule 3: a &self/&mut self parameter's lifetime โ†’ assigned to all outputs
  • struct Name<'a> { field: &'a T } โ€” a struct holding a reference needs its own lifetime parameter
  • 'static โ€” valid for the whole program's duration; string literals are &'static str
  • Reaching for 'static to silence an error usually hides a real ownership-structure problem instead of fixing it
  • Lifetimes are erased at compile time โ€” zero runtime cost, purely a compiler-analysis tool
  • Next chapter: Traits โ€” trait definitions, default implementations, trait bounds, and a contrast with Go's interfaces
Chapter 2 of 7

Traits

Course 2 ยท Ch 2
Traits
Describing shared behavior across different types โ€” Rust's answer to Go's interfaces

Course 1 built individual types with their own methods. This chapter introduces the tool for describing shared behavior across different types โ€” a trait, directly comparable to Go's own interfaces (go2-2).

Defining and Implementing a Trait

trait Summary { fn summarize(&self) -> String; } impl Summary for Article { fn summarize(&self) -> String { format!("{} by {}", self.title, self.author) } } impl Summary for Tweet { fn summarize(&self) -> String { format!("@{}: {}", self.username, self.text) } }

A trait declares a method signature; a type opts in by writing its own impl Trait for Type block โ€” Article and Tweet implement summarize completely differently.

Default Implementations

A trait method can include a default body โ€” implementing types may use it as-is, or override it:

trait Summary { fn summarize(&self) -> String { String::from("(Read more...)") } } impl Summary for Notice {} // empty impl โ€” uses the default impl Summary for Article { fn summarize(&self) -> String { /* overrides the default */ } }

Traits as Parameters (impl Trait)

fn notify(item: &impl Summary) { println!("Breaking news! {}", item.summarize()); }

notify accepts any type implementing Summary โ€” the exact same "program to an interface, not an implementation" idea Go's interfaces exist for.

Trait Bounds

impl Trait is sugar for a more general, more powerful syntax โ€” trait bounds โ€” needed when a constraint can't be expressed with the sugar alone. Two parameters that must be the same concrete type is exactly that case:

fn compare<T: Summary>(a: &T, b: &T) -> bool { a.summarize() == b.summarize() } // Multiple bounds with +: fn notify_and_log<T: Summary + std::fmt::Display>(item: &T) { /* ... */ } // A where clause for readability when bounds get long: fn process<T, U>(t: &T, u: &U) where T: Summary, U: Clone, { /* ... */ }

impl Trait can't express "these two parameters must be the same type" โ€” only a shared generic parameter with a trait bound can.

Contrast With Go's Interfaces

Go interfaces are satisfied implicitly โ€” structural typing means if a type happens to have the right methods, it satisfies the interface automatically, with no explicit declaration anywhere. Rust traits are explicit: a type must write impl Trait for Type even if it already happens to have a method with a matching signature.

GoRust
Interface/trait satisfactionImplicit โ€” structural typingExplicit โ€” impl Trait for Type required
Accidental satisfactionPossible โ€” matching methods are enoughImpossible โ€” must be declared on purpose

Rust's explicitness prevents a type from accidentally satisfying a trait it was never intended to, at the cost of one extra impl block.

trait Definition

Declares method signatures a type can opt into implementing.

Default Implementation

A body a trait method already has; implementers may use it or override it.

impl Trait Parameter

Accept any type implementing a trait โ€” sugar for the more general trait-bound syntax.

Trait Bound (<T: Trait>)

Needed when multiple parameters must share the exact same concrete type.

Default Implementations Let a Trait Evolve Safely
Adding a new method to a trait, with a default implementation, doesn't break any existing implementer โ€” they simply inherit the default. Adding a new method with no default would instead be a compile error for every existing impl block, demanding they implement it too โ€” the same "compiler tells you everywhere that needs updating" theme from Course 1's enum-variant gotcha, just showing up here as the case a default deliberately avoids.
The Orphan Rule: You Can't impl Just Anything for Anything
Rust requires that either the trait or the type being implemented is defined in your own crate โ€” you can't write impl SomeExternalTrait for SomeExternalType when neither belongs to you. This exists specifically to prevent two different crates from providing conflicting implementations of the same trait for the same type, which would make it genuinely ambiguous which one applies. It's a real, sometimes-surprising restriction for newcomers coming from more permissive languages โ€” but it's protecting a real guarantee (coherence), not an arbitrary limitation.

Coding Challenges

Challenge 1

Define a trait Describable with a method describe(&self) -> String that has a default implementation returning "No description available.". Implement it for a struct Book (overriding the default) and a struct Widget (using the default, via an empty impl block).

๐Ÿ“„ View solution
Challenge 2

Write a function print_description(item: &impl Describable) using Challenge 1's trait, then explain why this same function could NOT be used to compare two Describable items of potentially different concrete types for equality, and what syntax change would be needed to require them to be the same type.

๐Ÿ“„ View solution
Challenge 3

Explain, in your own words, why Go's implicit interface satisfaction could lead to a type accidentally satisfying an interface it wasn't designed for, and why Rust's explicit impl Trait for Type requirement makes that specific mistake impossible.

๐Ÿ“„ View solution

Chapter 2 Quick Reference

  • trait Name { fn method(&self) -> T; } โ€” declares shared behavior; types opt in via impl Trait for Type
  • Default implementations โ€” a trait method can have a body; implementers may use it or override it
  • fn f(x: &impl Trait) โ€” accepts any type implementing Trait; sugar for a trait bound
  • fn f<T: Trait>(a: &T, b: &T) โ€” a trait bound; needed when parameters must share the same concrete type
  • T: TraitA + TraitB โ€” multiple bounds; where clauses improve readability for longer bound lists
  • Go interfaces are satisfied implicitly (structural); Rust traits require an explicit impl block โ€” no accidental satisfaction
  • Adding a defaulted trait method doesn't break existing implementers; adding one with no default does
  • The orphan rule: either the trait or the type must be local to your own crate
  • Next chapter: Generics โ€” generic functions/structs, trait bounds on generics, and a contrast with Go's own generics
Chapter 3 of 7

Generics

Course 2 ยท Ch 3
Generics
Writing code once, over many types โ€” and why you've already been using generics since Course 1

Chapter 2 introduced trait bounds as a way to constrain a generic parameter. This chapter is fully about generics themselves โ€” writing logic once instead of duplicating it per type, with trait bounds as the way to say what that type must be able to do.

The Problem Generics Solve

Without generics, comparing the largest value in a list would need a separate, identical function per type โ€” largest_i32, largest_f64, largest_char. Generics let that logic exist once:

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T { let mut largest = list[0]; for &item in list { if item > largest { largest = item; } } largest }

The trait bounds aren't decoration โ€” they're required by what the function's body actually does: PartialOrd because > needs to be defined for T; Copy because a value can't be moved out of a slice reference, only copied.

Generic Structs

struct Point<T> { x: T, y: T, } impl<T> Point<T> { fn x(&self) -> &T { &self.x } }

Both fields share type T here. When fields genuinely need to differ, use multiple type parameters: struct Point<T, U> { x: T, y: U }.

Generic Enums: You've Already Been Using These

Course 1's Option<T> (Chapter 6) and Result<T, E> (Chapter 7) are themselves generic enums โ€” you've been using generics since Chapter 6, just without the formal vocabulary for it until now.

Monomorphization: How Generics Compile

At compile time, Rust generates a separate, fully concrete version of a generic function or struct for every distinct type it's actually used with โ€” calling largest with a slice of i32 and again with a slice of f64 produces two entirely separate compiled functions internally. This means generics carry zero runtime cost โ€” no dynamic dispatch, no boxing โ€” the exact same "real analysis, zero runtime cost" story Course 1 told about the borrow checker and lifetimes, now extended to generics.

Contrast With Go's Generics (go3-1)

Go added generics far later than Rust (Go 1.18, 2022), using comparable constraint syntax ([T any]). The real engineering difference: Rust always fully monomorphizes every generic instantiation. Go's compiler, to control binary size and compile time, has used a mix of monomorphization and shared/dictionary-based code generation for certain cases โ€” meaning not every Go generic instantiation gets its own fully specialized compiled copy the way Rust's always does. This is a genuine, deliberate trade-off both languages made โ€” Go leaning toward smaller binaries and faster compiles in some cases, Rust always prioritizing runtime performance โ€” not a case of one being straightforwardly better.

GoRust
Generics added2022 (Go 1.18)Present since Rust 1.0
Compilation strategyMix of monomorphization and shared/dictionary codeAlways fully monomorphized
Optimizes forBinary size / compile speed in some casesRuntime performance, always

Generic Function

One implementation, parameterized over T, with trait bounds specifying what T must support.

Generic Struct

Fields typed by one or more type parameters, e.g. Point<T> or Point<T, U>.

Option<T> / Result<T, E>

Generic enums you've already used since Course 1 โ€” now named and understood properly.

Monomorphization

A separate compiled version generated per concrete type โ€” zero runtime cost, larger binary.

You Already Know Generics
Every time Some(value), None, Ok(value), or Err(error) appeared back in Course 1, that was a generic type already at work. This chapter didn't introduce a new concept from scratch โ€” it gave a name and a formal mechanism to something already familiar.
Forgetting the Bound an Operation Actually Needs
fn largest<T>(list: &[T]) -> T with a body that uses > internally, but no T: PartialOrd bound, produces a compile error saying > can't be applied to type T. The function looks like it should "just work for any T" โ€” but the compiler has no way to know that unless the bound says so explicitly. Whatever an operation inside a generic function actually needs (comparison, cloning, formatting), the corresponding trait has to be named in the bound.

Coding Challenges

Challenge 1

Write a generic function smallest(list: &[T]) -> T, following this chapter's largest as a template, and call it with both a slice of i32 and a slice of char.

๐Ÿ“„ View solution
Challenge 2

Define a generic struct Pair with fields first: T and second: U, and an impl block with a method describe(&self) -> String requiring T: std::fmt::Display and U: std::fmt::Display. Explain why the bound has to go on the impl block (or the method) rather than the struct definition itself.

๐Ÿ“„ View solution
Challenge 3

Explain what "zero runtime cost" actually means for monomorphized generics, referencing what the compiler produces for two different calls to the same generic function with different types โ€” and contrast this briefly with what a dynamic-dispatch-based alternative would cost at runtime instead.

๐Ÿ“„ View solution

Chapter 3 Quick Reference

  • fn f<T: Bound>(x: T) โ€” one implementation over many types, constrained by what the body actually needs
  • struct Name<T> { field: T } โ€” a generic struct; use multiple parameters (T, U) when fields differ in type
  • Option<T> and Result<T, E> are themselves generic enums, in use since Course 1
  • Monomorphization โ€” the compiler generates a fully separate version per concrete type used; zero runtime cost, larger binary
  • Go's generics (added 2022) sometimes use shared/dictionary code instead of always monomorphizing โ€” a deliberate binary-size/compile-speed trade-off, not a worse design
  • A generic function's trait bounds must name whatever the body's operations actually require โ€” the compiler won't infer this from "it should just work"
  • Next chapter: Smart Pointers โ€” Box<T>, Rc<T>, RefCell<T>, and interior mutability
Chapter 4 of 7

Smart Pointers

Course 2 ยท Ch 4
Smart Pointers
Relocating, not abandoning, Course 1's ownership rules for the cases that need more flexibility

Course 1's ownership rules โ€” exactly one owner, borrow checking enforced entirely at compile time โ€” are strict by design. Some real, legitimate patterns genuinely don't fit that shape. Smart pointers are Rust's controlled, still-safe way to get flexibility where it's actually needed, without abandoning safety altogether.

Box<T>: Heap Allocation With a Single Owner

The simplest smart pointer: puts a value on the heap instead of the stack, while still following ordinary single-ownership rules exactly โ€” Box<T> itself is the single owner, moving per Course 1 Chapter 3's rules like anything else. The classic use case is a recursive type:

enum List { Cons(i32, Box<List>), Nil, }

Without Box, Cons(i32, List) fails to compile โ€” the compiler needs to know a fixed size for List at compile time, but a List containing a List containing a List... has no fixed size at all. Box's size is always just one pointer, regardless of what it points to, breaking the infinite recursion.

Rc<T>: Multiple Owners via Reference Counting

Course 1's rule was exactly one owner. Rc<T> ("Reference Counted") relaxes this specifically for cases where multiple parts of a program genuinely need to jointly own the same data:

use std::rc::Rc; let a = Rc::new(5); let b = Rc::clone(&a); // increments the count โ€” no deep copy println!("count: {}", Rc::strong_count(&a)); // 2

Rc::clone doesn't copy the underlying data โ€” it increments a reference count. The data is only actually dropped once the last Rc pointing to it goes out of scope, bringing the count to zero.

RefCell<T> and Interior Mutability

The borrow checker's rules (Course 1, Chapter 4) are enforced at compile time by default. RefCell<T> moves that same rule โ€” one mutable OR many immutable borrows โ€” to runtime instead, allowing mutation even through an immutable reference to the RefCell itself:

use std::cell::RefCell; let value = RefCell::new(5); *value.borrow_mut() += 1; println!("{}", value.borrow()); // 6 // Violating the rule at runtime โ€” this PANICS instead of failing to compile: // let _b1 = value.borrow_mut(); // let _b2 = value.borrow_mut(); // thread panicked: already borrowed

A genuine trade-off: more flexibility, but a violated rule moves from a compile error to a runtime panic.

Combining Rc<T> and RefCell<T>

The common, idiomatic combination โ€” Rc<RefCell<T>> โ€” gives multiple owners that can each mutate the shared data. This is Rust's deliberate, opt-in answer to a pattern that's simply the default in a garbage-collected language (shared mutable state) โ€” not worse, just something you have to explicitly ask for rather than get for free:

let shared = Rc::new(RefCell::new(0)); let owner_a = Rc::clone(&shared); let owner_b = Rc::clone(&shared); *owner_a.borrow_mut() += 10; *owner_b.borrow_mut() += 5; println!("{}", shared.borrow()); // 15 โ€” both owners mutated the SAME underlying data
Compile-Time Borrow Checking (Ch.4, Course 1)RefCell<T> (Runtime)
When rules are checkedAt compile timeAt runtime, on each borrow()/borrow_mut() call
Violating the ruleCompile errorPanic
FlexibilityLess โ€” some valid patterns rejectedMore โ€” patterns the compiler can't verify statically still work

Box<T>

Single ownership, heap-allocated โ€” no rule relaxation, just placement. Needed for recursive types.

Rc<T>

Relaxes single ownership via reference counting โ€” multiple joint owners of the same data.

RefCell<T>

Relaxes compile-time borrow checking to runtime โ€” mutation through an immutable reference.

Rc<RefCell<T>>

Both relaxations combined โ€” multiple owners, each able to mutate the shared data.

Not Abandoning the Rules โ€” Relocating Them
Course 1 established that ownership and borrowing are checked strictly, at compile time. Smart pointers don't throw that away: Box changes nothing about the rules, only where data lives; Rc relaxes single-ownership via counting; RefCell relaxes borrow-checking from compile time to runtime. Every one of them is a deliberate, scoped exception โ€” not a general escape hatch from Rust's safety model.
Rc<T> Alone Does Not Allow Mutation
A common early mix-up: assuming Rc<T> by itself is "the mutable shared pointer." It isn't โ€” Rc<T>'s data is immutable by default, which is exactly why it's so often paired with RefCell<T> specifically for the mutable case. Also worth flagging ahead: Rc<T> is not thread-safe โ€” Arc<T> (the atomic version) is what Chapter 5's concurrency material uses instead.

Coding Challenges

Challenge 1

Explain why enum Tree { Leaf(i32), Node(Box, Box) } compiles, but enum Tree { Leaf(i32), Node(Tree, Tree) } (without Box) does not โ€” referencing this chapter's List example.

๐Ÿ“„ View solution
Challenge 2

Write code creating an Rc, cloning it twice (three total owners), printing the strong count after each clone, then dropping one clone explicitly with drop() and printing the count once more.

๐Ÿ“„ View solution
Challenge 3

Using Rc>>, write code where two separate "owner" variables each push a different number onto the same shared vector, then print the vector's final contents from a third owner to prove all three see the same underlying data.

๐Ÿ“„ View solution

Chapter 4 Quick Reference

  • Box<T> โ€” single-owner heap allocation; needed for recursive types (fixed-size pointer breaks infinite size)
  • Rc<T> โ€” multiple owners via reference counting; Rc::clone increments the count, no deep copy
  • Rc::strong_count(&x) โ€” inspect how many owners currently exist
  • RefCell<T> โ€” moves borrow checking to runtime; violating the rule panics instead of failing to compile
  • Rc<RefCell<T>> โ€” the idiomatic combo: multiple owners, each able to mutate shared data
  • Rc<T> alone is immutable โ€” RefCell is what actually enables mutation through it
  • Rc<T> is not thread-safe โ€” Arc<T> is the concurrency-safe equivalent, coming next chapter
  • Next chapter: Concurrency โ€” threads, Arc<Mutex<T>>, mpsc channels, and "fearless concurrency" contrasted with Go's goroutines
Chapter 5 of 7

Concurrency

Course 2 ยท Ch 5
Concurrency
Where Chapter 4's forward pointer to Arc<T> pays off โ€” "fearless concurrency," made concrete

Chapter 4 closed with a forward pointer to Arc<T> for thread-safety. This chapter is where that payoff lands: the same ownership and borrowing rules that prevented aliasing bugs in single-threaded code (Course 1, Chapter 4) also prevent data races across threads โ€” enforced at compile time, not discovered at runtime.

Spawning Threads

use std::thread; let handle = thread::spawn(|| { println!("Hello from a spawned thread!"); }); println!("Hello from main!"); handle.join().unwrap(); // waits for the spawned thread to finish

thread::spawn returns a JoinHandle; .join() blocks until that thread completes.

The move Keyword and Ownership Across Threads

A spawned thread might genuinely outlive the function that spawned it โ€” the compiler can't assume it won't. A closure that only borrows data risks that data being dropped before the thread finishes using it, so thread::spawn typically requires a move closure, forcing ownership of captured variables into the thread itself:

let data = vec![1, 2, 3]; // Without `move`, this often fails to compile โ€” the closure only // borrows `data`, and the compiler can't guarantee it outlives the thread. let handle = thread::spawn(move || { println!("{:?}", data); // data is now OWNED by this closure }); handle.join().unwrap();

This is Course 1's move semantics (Chapter 3) and borrow-checker lifetime reasoning (Chapter 4), both directly enforced here โ€” applied to a genuinely new context where a thread's actual lifetime is unpredictable.

Sharing Data Safely with Arc<Mutex<T>>

Chapter 4's Rc<RefCell<T>> pattern has a thread-safe sibling: Arc<T> ("Atomic Reference Counted") replaces Rc, and Mutex<T> replaces RefCell โ€” using a real OS-level lock, where .lock() returns a guard that unlocks automatically when dropped:

use std::sync::{Arc, Mutex}; use std::thread; let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..10 { let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { let mut num = counter.lock().unwrap(); *num += 1; })); } for h in handles { h.join().unwrap(); } println!("Result: {}", *counter.lock().unwrap()); // 10

mpsc Channels: Message Passing

std::sync::mpsc::channel() ("multiple producer, single consumer") returns a (Sender, Receiver) pair โ€” threads communicate by sending owned values rather than sharing memory directly:

use std::sync::mpsc; use std::thread; let (tx, rx) = mpsc::channel(); thread::spawn(move || { tx.send(String::from("hello from the thread")).unwrap(); }); let received = rx.recv().unwrap(); println!("{}", received);

This is a direct expression of a proverb closely associated with Go's own community: "Do not communicate by sharing memory; instead, share memory by communicating." Rust and Go arrive at genuinely similar channel-based thinking here.

Contrast With Go's Goroutines & Channels (go2-3)

Go's goroutines are lightweight, green-thread-style tasks managed by Go's own runtime scheduler โ€” many goroutines multiplex onto relatively few real OS threads. Rust's thread::spawn creates real OS threads directly โ€” heavier per-thread, with no separate runtime scheduler required (Rust's own lightweight async tasks are Course 3's territory, a different mechanism entirely). Go's channels are first-class language syntax (ch <- value, <-ch); Rust's mpsc channels are an ordinary standard-library type, no special syntax needed.

The more important contrast: Go's concurrency safety net is best-effort โ€” its race detector catches some data races, opt-in, typically during testing. Rust's guarantee is enforced by the compiler itself, for every concurrent program, as a hard requirement just to compile at all.

GoRust
Thread modelLightweight goroutines, runtime-scheduledReal OS threads (async tasks are separate, Course 3)
ChannelsFirst-class language syntaxA standard-library type (mpsc)
Data-race safetyBest-effort โ€” race detector, opt-in/testing-timeCompiler-enforced โ€” required to compile at all

thread::spawn / join

Creates a real OS thread; join() blocks until it finishes.

move Closures

Forces ownership transfer into the thread โ€” required since a thread's lifetime is unpredictable.

Arc<Mutex<T>>

The thread-safe sibling of Rc<RefCell<T>> โ€” shared, lockable mutable state.

mpsc Channels

Send owned values between threads instead of sharing memory directly.

The Actual Mechanism Behind "Fearless Concurrency"
Two marker traits, Send (safe to move to another thread) and Sync (safe to share a reference across threads), are what the compiler actually checks. Trying to share a plain Rc<T> โ€” not thread-safe, per Chapter 4's warning โ€” across threads is a compile error, not a data race waiting to happen at runtime. This is the concrete mechanism behind Rust's "fearless concurrency" claim: it isn't a slogan, it's Send/Sync doing real, checked work.
Mutex<T> Prevents Data Races, Not Deadlocks
Two threads, each holding one lock and waiting for the other's lock, will deadlock โ€” and Rust's compiler does not catch this. The compile-time guarantee is specifically about memory safety and data races, not general concurrency correctness. A deadlock is a real, possible runtime bug that the type system has no visibility into at all โ€” an honest limit on what "fearless concurrency" actually promises.

Coding Challenges

Challenge 1

Write code that spawns a thread using a move closure to print a Vec created in main, then explain what compiler error would occur (in general terms) if move were removed.

๐Ÿ“„ View solution
Challenge 2

Using Arc>>, spawn 5 threads that each push their own thread number (0-4) onto a shared vector, join all threads, then print the vector's final length and confirm it's 5.

๐Ÿ“„ View solution
Challenge 3

Explain why Rust's compile-time thread safety is a stronger guarantee than Go's race detector, but is still not a complete guarantee of concurrency correctness โ€” referencing deadlocks specifically as an example of what it doesn't cover.

๐Ÿ“„ View solution

Chapter 5 Quick Reference

  • thread::spawn(|| {...}) โ€” creates a real OS thread; returns a JoinHandle; .join() waits for completion
  • move closures โ€” required to transfer ownership into a thread, since its lifetime is unpredictable
  • Arc<T> โ€” thread-safe Rc; Mutex<T> โ€” thread-safe RefCell, using a real OS lock
  • Arc<Mutex<T>> โ€” the multi-threaded analog of Rc<RefCell<T>>
  • mpsc::channel() โ€” a (Sender, Receiver) pair; send owned values instead of sharing memory
  • Send/Sync โ€” the marker traits the compiler checks to reject unsafe cross-thread sharing at compile time
  • Go's race safety is best-effort tooling; Rust's is a hard, compiler-enforced compile requirement
  • Mutex<T> prevents data races, not deadlocks โ€” a real, uncaught category of concurrency bug remains possible
  • Next chapter: Modules & Crates โ€” the module system, Cargo.toml, workspaces, and publishing to crates.io
Chapter 6 of 7

Modules & Crates

Course 2 ยท Ch 6
Modules & Crates
Organizing a real, growing project โ€” from a single file to a published crate

Course 1 stayed within single small files. This chapter covers organizing a real Rust project as it grows: modules within a crate, Cargo.toml in depth, workspaces for multi-crate projects, and finally publishing a crate to crates.io.

The Module System

mod front_of_house { pub mod hosting { pub fn add_to_waitlist() {} } } front_of_house::hosting::add_to_waitlist();

mod organizes code into a tree of namespaces within a single crate. Everything is private by default โ€” a genuinely different default from many languages. pub makes an item visible to its parent module โ€” not globally, a common early misconception. pub(crate) exposes an item crate-wide, but not to external code.

Splitting Modules Across Files

A module can live in its own file: mod front_of_house; (no body) tells Rust to look for the module's contents elsewhere. The modern convention (2018 edition onward) needs no mod.rs file:

src/ lib.rs // mod front_of_house; front_of_house.rs // pub mod hosting; front_of_house/ hosting.rs // pub fn add_to_waitlist() {}

use and Bringing Paths Into Scope

use crate::front_of_house::hosting; use std::collections::HashMap as Map; // renaming to avoid a clash pub use crate::front_of_house::hosting::add_to_waitlist; // re-export at the crate root

use shortens repeated long paths โ€” already in constant use since Course 1's use std::collections::HashMap (Chapter 8). pub use re-exports an item, making something defined deep in the module tree part of the crate's own public API surface at a shallower, more convenient path.

Cargo.toml in Depth

[package] name = "my_crate" version = "0.1.0" edition = "2021" [dependencies] serde = "1.0" # caret by default: allows 1.x, never 2.0

Cargo.lock pins the exact, fully-resolved dependency versions actually used โ€” directly analogous to npm's package-lock.json. Committing it for a binary/application ensures reproducible builds across machines; libraries conventionally don't commit it, since consumers resolve their own compatible versions.

Workspaces: Multiple Crates, One Project

# root Cargo.toml [workspace] members = ["app", "core-lib"]

A workspace shares one Cargo.lock and one target/ build directory across every member crate โ€” the standard pattern once a project grows past a single crate, e.g. splitting a binary crate from the library crate(s) it depends on.

Publishing to crates.io

cargo publish requires a globally unique crate name (crates.io names can't be reused once claimed), required metadata (license, description), and an API token. Publishing is permanent for a given version number โ€” a published version can be yanked (hidden from new dependents) but never overwritten or deleted outright.

npm (JavaScript)Cargo (Rust)
Lock filepackage-lock.jsonCargo.lock
PurposePin exact resolved versions for reproducibilityIdentical purpose
Committed for libraries?Typically notTypically not (unlike applications, which do)

mod / pub

Organizes code into namespaces; private by default, pub exposes to the parent module.

File-Based Modules

mod name; points at name.rs, with name/submodule.rs for its own children.

Cargo.toml / Cargo.lock

Package metadata and dependencies; the lock file pins exact resolved versions.

Workspaces

Multiple crates sharing one lock file and build directory under one root Cargo.toml.

Private-by-Default Is the Same Philosophy Again
Rust's modules being private unless explicitly marked pub is the same "safety by default" instinct behind Course 1's immutable-by-default variables โ€” an accidentally-exposed item requires deliberately opting in with pub, rather than requiring someone to remember to lock it down after the fact.
pub Alone Doesn't Guarantee External Visibility
Marking a deeply-nested item pub only exposes it to its immediate parent module โ€” it does not automatically make it reachable from outside the crate. Every module along the path to that item must also be pub (or the item must be re-exported via pub use at a more accessible path). Visibility has to be "pub all the way up," not just at the final leaf item โ€” a genuinely common early confusion.

Coding Challenges

Challenge 1

Write a module tree: a top-level module shop containing a submodule inventory with a public function check_stock(). Show the full path needed to call check_stock() from outside the shop module, and explain what pub keywords are required at each level.

๐Ÿ“„ View solution
Challenge 2

Write a Cargo.toml for a binary crate named "task_tracker" at version 0.1.0, using the 2021 edition, depending on serde version "1.0" and clap version "4.0". Explain what Cargo.lock would add on top of this file once the project is built.

๐Ÿ“„ View solution
Challenge 3

Explain the specific bug in this module tree: mod a { pub mod b { pub fn f() {} } } where a itself is NOT marked pub โ€” why can code outside the crate still not call a::b::f() from outside, even though both b and f are marked pub?

๐Ÿ“„ View solution

Chapter 6 Quick Reference

  • mod name { ... } โ€” declares a module; items are private by default
  • pub โ€” exposes an item to its parent module; pub(crate) โ€” exposes crate-wide only
  • mod name; (no body) โ€” loads the module from name.rs, with name/ for its own submodules
  • use path::to::item; โ€” shortens repeated paths; pub use โ€” re-exports at a shallower path
  • Cargo.toml โ€” package metadata + dependencies; Cargo.lock โ€” exact resolved versions, like npm's package-lock.json
  • [workspace] members = [...] โ€” multiple crates sharing one lock file and build directory
  • cargo publish โ€” permanent per version; a bad publish can only be yanked, never overwritten
  • pub must apply all the way up the module path, not just at the final item, for external code to actually reach it
  • Next chapter: Testing in Rust โ€” #[test], cargo test, unit vs. integration tests, contrasted with Go's built-in testing package
Chapter 7 of 7

Testing in Rust

Course 2 ยท Ch 7
Testing in Rust
Verifying everything Course 2 built actually works โ€” the fitting close to this course

Course 2 has built a real project's worth of Rust knowledge โ€” lifetimes, traits, generics, smart pointers, concurrency, modules. This final chapter closes with the tool that lets you trust all of it actually behaves as intended: Rust's built-in testing framework.

The #[test] Attribute

#[test] fn it_adds_two_numbers() { assert_eq!(add(2, 3), 5); }

A function annotated #[test] becomes a test case, run with cargo test. A panic inside a test function is a failed test โ€” no separate failure mechanism exists. This is a genuinely elegant reuse of Course 1 Chapter 7's panic! material: testing failure is just triggering a panic, nothing new to learn.

Organizing Unit Tests

The conventional Rust pattern: unit tests live in the same file as the code they test, inside a #[cfg(test)] submodule โ€” a real departure from many languages' separate-test-directory convention:

pub fn add(a: i32, b: i32) -> i32 { a + b } #[cfg(test)] mod tests { use super::*; #[test] fn it_adds_two_numbers() { assert_eq!(add(2, 3), 5); } }

#[cfg(test)] means this module compiles only when running tests, never in a normal build. use super::* brings the parent module's items โ€” including private ones โ€” into the test module's scope, so unit tests can exercise private implementation details directly, not just the public API.

Integration Tests

A separate top-level tests/ directory, sibling to src/, holds integration tests. Each file inside it compiles as its own separate crate, testing only the library's public API โ€” exactly as an external consumer would use it:

// tests/integration_test.rs use my_crate::add; #[test] fn public_add_works() { assert_eq!(add(2, 2), 4); }
Unit TestsIntegration Tests
LocationSame file, #[cfg(test)] modtests/ directory
Compiled asPart of the same crateA separate crate per file
Can accessPrivate and public codePublic API only

Useful cargo test Options

  • cargo test add โ€” runs only tests whose name contains "add"
  • cargo test -- --nocapture โ€” shows println! output even for passing tests (captured/hidden by default)
  • #[should_panic] โ€” a test that passes only if the function panics, useful for testing error conditions
  • #[ignore] โ€” skips a slow test by default; run explicitly with cargo test -- --ignored
#[test] #[should_panic] fn rejects_a_negative_age() { Age::new(-1); // this test PASSES because new() is expected to panic here }

Contrast With Go's Testing Package (go3-4)

Go's built-in testing uses a naming convention instead of an attribute โ€” a function named TestXxx(t *testing.T) in a _test.go file is automatically discovered, no annotation required. Go failures are reported explicitly via t.Error()/t.Fatal() calls rather than Rust's "a panic is a failure" approach. Both languages ship testing built-in with no external framework needed โ€” the actual mechanics of what counts as a test, and what counts as a failure, genuinely differ.

GoRust
Test discoveryNaming convention (TestXxx in _test.go)#[test] attribute
Reporting failureExplicit t.Error()/t.Fatal() callsA panic โ€” no separate reporting call
Unit vs. integration separationFile naming/build tags conventionSame-file #[cfg(test)] vs. a separate tests/ directory

#[test]

Marks a function as a test case; a panic inside it means the test failed.

#[cfg(test)] mod tests

Same-file unit tests, compiled only when testing, with access to private code.

tests/ Directory

Integration tests, each file its own crate, exercising only the public API.

#[should_panic]

A test that passes specifically because the tested code panics โ€” for verifying error conditions.

A Fitting Close to Course 2
Testing is the right note to end this course on โ€” it's the tool that lets you actually trust that lifetimes, traits, generics, smart pointers, and concurrent code all behave as intended, especially once Chapter 6's workspaces come into play with multiple crates each needing their own test coverage.
Forgetting #[cfg(test)] Bloats Every Regular Build
Without #[cfg(test)] on the test module, its code โ€” and anything it pulls in via use super::* โ€” gets compiled into every normal build, not just test runs. This inflates binary size and can unintentionally require test-only dependencies in production builds. Not catastrophic, but a real, common oversight worth double-checking.

Coding Challenges

Challenge 1

Write a function subtract(a: i32, b: i32) -> i32 alongside a #[cfg(test)] mod tests block containing two #[test] functions: one verifying a normal case, one verifying subtracting a number from itself yields zero.

๐Ÿ“„ View solution
Challenge 2

Write a function divide(a: f64, b: f64) -> f64 that panics if b is 0.0, and a #[test] #[should_panic] test verifying that dividing by zero actually panics.

๐Ÿ“„ View solution
Challenge 3

Explain why a unit test inside a #[cfg(test)] mod tests block can call a private function directly, while a test inside the tests/ directory cannot โ€” referencing what "use super::*" actually brings into scope and what a tests/ file compiles as instead.

๐Ÿ“„ View solution

Chapter 7 Quick Reference

  • #[test] โ€” marks a function as a test case, run by cargo test; a panic = a failure
  • assert!/assert_eq!/assert_ne! โ€” the standard assertion macros used inside tests
  • #[cfg(test)] mod tests { use super::*; } โ€” same-file unit tests, compiled only when testing, private+public access
  • tests/ directory โ€” integration tests, each file its own crate, public API only
  • #[should_panic] โ€” a test that passes only if the code panics; #[ignore] โ€” skip unless run explicitly
  • Go discovers tests by naming convention (TestXxx) and reports failure via explicit t.Error(); Rust uses an attribute and treats any panic as failure
  • Forgetting #[cfg(test)] compiles test code into every regular build, not just test runs

โ˜… Rust Intermediate Complete โ€” 7 / 7 chapters

From lifetimes through traits, generics, smart pointers, concurrency, modules, and finally testing โ€” Course 2 has built the tools needed for real, growing Rust projects. Next up: Rust Advanced, covering advanced traits, unsafe Rust, async Rust, macros, and performance โ€” closing with a capstone CLI tool.