Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rust for Python Programmers: Complete Training Guide

A comprehensive guide to learning Rust for developers with Python experience. This guide covers everything from basic syntax to advanced patterns, focusing on the conceptual shifts required when moving from a dynamically-typed, garbage-collected language to a statically-typed systems language with compile-time memory safety.

How to Use This Book

Self-study format: Work through Part I (ch 1–6) first — these map closely to Python concepts you already know. Part II (ch 7–12) introduces Rust-specific ideas like ownership and traits. Part III (ch 13–16) covers advanced topics and migration.

Pacing recommendations:

ChaptersTopicSuggested TimeCheckpoint
1–4Setup, types, control flow1 dayYou can write a CLI temperature converter in Rust
5–6Data structures, enums, pattern matching1–2 daysYou can define an enum with data and match exhaustively on it
7Ownership and borrowing1–2 daysYou can explain why let s2 = s1 invalidates s1
8–9Modules, error handling1 dayYou can create a multi-file project that propagates errors with ?
10–12Traits, generics, closures, iterators1–2 daysYou can translate a list comprehension to an iterator chain
13Concurrency1 dayYou can write a thread-safe counter with Arc<Mutex<T>>
14Unsafe, PyO3, testing1 dayYou can call a Rust function from Python via PyO3
15–16Migration, best practicesAt your own paceReference material — consult as you write real code
17Capstone project2–3 daysBuild a complete CLI app tying everything together

How to use the exercises:

  • Chapters include hands-on exercises in collapsible <details> blocks with solutions
  • Always try the exercise before expanding the solution. Struggling with the borrow checker is part of learning — the compiler’s error messages are your teacher
  • If you’re stuck for more than 15 minutes, expand the solution, study it, then close it and try again from scratch
  • The Rust Playground lets you run code without a local install

Difficulty indicators:

  • 🟢 Beginner — Direct translation from Python concepts
  • 🟡 Intermediate — Requires understanding ownership or traits
  • 🔴 Advanced — Lifetimes, async internals, or unsafe code

When you hit a wall:

  • Read the compiler error message carefully — Rust’s errors are exceptionally helpful
  • Re-read the relevant section; concepts like ownership (ch7) often click on the second pass
  • The Rust standard library docs are excellent — search for any type or method
  • For deeper async patterns, see the companion Async Rust Training

Table of Contents

Part I — Foundations

1. Introduction and Motivation 🟢

2. Getting Started 🟢

3. Built-in Types and Variables 🟢

4. Control Flow 🟢

5. Data Structures and Collections 🟢

6. Enums and Pattern Matching 🟡

Part II — Core Concepts

7. Ownership and Borrowing 🟡

8. Crates and Modules 🟢

9. Error Handling 🟡

10. Traits and Generics 🟡

11. From and Into Traits 🟡

12. Closures and Iterators 🟡

Part III — Advanced Topics & Migration

13. Concurrency 🔴

14. Unsafe Rust, FFI, and Testing 🔴

15. Migration Patterns 🟡

16. Best Practices 🟡


Part IV — Capstone

17. Capstone Project: CLI Task Manager 🔴


1. Introduction and Motivation

Speaker Intro and General Approach

  • Speaker intro
    • Principal Firmware Architect in Microsoft SCHIE (Silicon and Cloud Hardware Infrastructure Engineering) team
    • Industry veteran with expertise in security, systems programming (firmware, operating systems, hypervisors), CPU and platform architecture, and C++ systems
    • Started programming in Rust in 2017 (@AWS EC2), and have been in love with the language ever since
  • This course is intended to be as interactive as possible
    • Assumption: You know Python and its ecosystem
    • Examples deliberately map Python concepts to Rust equivalents
    • Please feel free to ask clarifying questions at any point of time

The Case for Rust for Python Developers

What you’ll learn: Why Python developers are adopting Rust, real-world performance wins (Dropbox, Discord, Pydantic), when Rust is the right choice vs staying with Python, and the core philosophical differences between the two languages.

Difficulty: 🟢 Beginner

Performance: From Minutes to Milliseconds

Python is famously slow for CPU-bound work. Rust provides C-level performance with a high-level feel.

# Python — ~2 seconds for 10 million calls
import time

def fibonacci(n: int) -> int:
    if n <= 1:
        return n
    a, b = 0, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b

start = time.perf_counter()
results = [fibonacci(n % 30) for n in range(10_000_000)]
elapsed = time.perf_counter() - start
print(f"Elapsed: {elapsed:.2f}s")  # ~2s on typical hardware
// Rust — ~0.07 seconds for the same 10 million calls
use std::time::Instant;

fn fibonacci(n: u64) -> u64 {
    if n <= 1 {
        return n;
    }
    let (mut a, mut b) = (0u64, 1u64);
    for _ in 2..=n {
        let temp = b;
        b = a + b;
        a = temp;
    }
    b
}

fn main() {
    let start = Instant::now();
    let results: Vec<u64> = (0..10_000_000).map(|n| fibonacci(n % 30)).collect();
    println!("Elapsed: {:.2?}", start.elapsed());  // ~0.07s
}

Note: Rust should be run in release mode (cargo run --release) for a fair performance comparison. Why the difference? Python dispatches every + through a dictionary lookup, unboxes integers from heap objects, and checks types at every operation. Rust compiles fibonacci directly to a handful of x86 add/mov instructions — the same code a C compiler would produce.

Memory Safety Without a Garbage Collector

Python’s reference-counting GC has known issues: circular references, unpredictable __del__ timing, and memory fragmentation. Rust eliminates these at compile time.

# Python — circular reference that CPython's ref counter can't free
class Node:
    def __init__(self, value):
        self.value = value
        self.parent = None
        self.children = []

    def add_child(self, child):
        self.children.append(child)
        child.parent = self  # Circular reference!

# These two nodes reference each other — ref count never reaches 0.
# CPython's cycle detector will *eventually* clean them up,
# but you can't control when, and it adds GC pause overhead.
root = Node("root")
child = Node("child")
root.add_child(child)
// Rust — ownership prevents circular references by design
struct Node {
    value: String,
    children: Vec<Node>,  // Children are OWNED — no cycles possible
}

impl Node {
    fn new(value: &str) -> Self {
        Node {
            value: value.to_string(),
            children: Vec::new(),
        }
    }

    fn add_child(&mut self, child: Node) {
        self.children.push(child);  // Ownership transfers here
    }
}

fn main() {
    let mut root = Node::new("root");
    let child = Node::new("child");
    root.add_child(child);
    // When root is dropped, all children are dropped too.
    // Deterministic, zero overhead, no GC.
}

Key insight: In Rust, the child doesn’t hold a reference back to the parent. If you truly need cross-references (like a graph), you use explicit mechanisms like Rc<RefCell<T>> or indices — making the complexity visible and intentional.


Common Python Pain Points That Rust Addresses

1. Runtime Type Errors

The most common Python production bug: passing the wrong type to a function. Type hints help, but they aren’t enforced.

# Python — type hints are suggestions, not rules
def process_user(user_id: int, name: str) -> dict:
    return {"id": user_id, "name": name.upper()}

# These all "work" at the call site — fail at runtime
process_user("not-a-number", 42)        # TypeError at .upper()
process_user(None, "Alice")             # Works until you use user_id as int

# Even with mypy, you can still bypass types:
data = json.loads('{"id": "oops"}')     # Always returns Any
process_user(data["id"], data["name"])  # mypy can't catch this
#![allow(unused)]
fn main() {
// Rust — the compiler catches all of these before the program runs
fn process_user(user_id: i64, name: &str) -> User {
    User {
        id: user_id,
        name: name.to_uppercase(),
    }
}

// process_user("not-a-number", 42);     // ❌ Compile error: expected i64, found &str
// process_user(None, "Alice");           // ❌ Compile error: expected i64, found Option
// Extra arguments are always a compile error.

// Deserializing JSON is type-safe too:
#[derive(Deserialize)]
struct UserInput {
    id: i64,     // Must be a number in the JSON
    name: String, // Must be a string in the JSON
}
let input: UserInput = serde_json::from_str(json_str)?; // Returns Err if types mismatch
process_user(input.id, &input.name); // ✅ Guaranteed correct types
}

2. None: The Billion Dollar Mistake (Python Edition)

None can appear anywhere a value is expected. Python has no compile-time way to prevent AttributeError: 'NoneType' object has no attribute ....

# Python — None sneaks in everywhere
def find_user(user_id: int) -> dict | None:
    users = {1: {"name": "Alice"}, 2: {"name": "Bob"}}
    return users.get(user_id)

user = find_user(999)         # Returns None
print(user["name"])           # 💥 TypeError: 'NoneType' object is not subscriptable

# Even with Optional type hint, nothing enforces the check:
from typing import Optional
def get_name(user_id: int) -> Optional[str]:
    return None

name: Optional[str] = get_name(1)
print(name.upper())          # 💥 AttributeError — mypy warns, runtime doesn't care
#![allow(unused)]
fn main() {
// Rust — None is impossible unless explicitly handled
fn find_user(user_id: i64) -> Option<User> {
    let users = HashMap::from([
        (1, User { name: "Alice".into() }),
        (2, User { name: "Bob".into() }),
    ]);
    users.get(&user_id).cloned()
}

let user = find_user(999);  // Returns None variant of Option<User>
// println!("{}", user.name);  // ❌ Compile error: Option<User> has no field `name`

// You MUST handle the None case:
match find_user(999) {
    Some(user) => println!("{}", user.name),
    None => println!("User not found"),
}

// Or use combinators:
let name = find_user(999)
    .map(|u| u.name)
    .unwrap_or_else(|| "Unknown".to_string());
}

3. The GIL: Python’s Concurrency Ceiling

Python’s Global Interpreter Lock means threads don’t run Python code in parallel. threading is only useful for I/O-bound work; CPU-bound work requires multiprocessing (with its serialization overhead) or C extensions.

# Python — threads DON'T speed up CPU work because of the GIL
import threading
import time

def cpu_work(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

start = time.perf_counter()
threads = [threading.Thread(target=cpu_work, args=(10_000_000,)) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
elapsed = time.perf_counter() - start
print(f"4 threads: {elapsed:.2f}s")  # About the SAME as 1 thread! GIL prevents parallelism.

# multiprocessing "works" but serializes data between processes:
from multiprocessing import Pool
with Pool(4) as p:
    results = p.map(cpu_work, [10_000_000] * 4)  # ~4x faster, but pickle overhead
// Rust — true parallelism, no GIL, no serialization overhead
use std::thread;

fn cpu_work(n: u64) -> u64 {
    (0..n).map(|i| i * i).sum()
}

fn main() {
    let start = std::time::Instant::now();
    let handles: Vec<_> = (0..4)
        .map(|_| thread::spawn(|| cpu_work(10_000_000)))
        .collect();

    let results: Vec<u64> = handles.into_iter()
        .map(|h| h.join().unwrap())
        .collect();

    println!("4 threads: {:.2?}", start.elapsed());  // ~4x faster than single thread
}

With Rayon (Rust’s parallel iterator library), parallelism is even simpler:

#![allow(unused)]
fn main() {
use rayon::prelude::*;
let results: Vec<u64> = inputs.par_iter().map(|&n| cpu_work(n)).collect();
}

4. Deployment and Distribution Pain

Python deployment is notoriously difficult: venvs, system Python conflicts, pip install failures, C extension wheels, Docker images with full Python runtime.

# Python deployment checklist:
# 1. Which Python version? 3.9? 3.10? 3.11? 3.12?
# 2. Virtual environment: venv, conda, poetry, pipenv?
# 3. C extensions: need compiler? manylinux wheels?
# 4. System dependencies: libssl, libffi, etc.?
# 5. Docker: full python:3.12 image is 1.0 GB
# 6. Startup time: 200-500ms for import-heavy apps

# Docker image: ~1 GB
# FROM python:3.12-slim
# COPY requirements.txt .
# RUN pip install -r requirements.txt
# COPY . .
# CMD ["python", "app.py"]
#![allow(unused)]
fn main() {
// Rust deployment: single static binary, no runtime needed
// cargo build --release → one binary, ~5-20 MB
// Copy it anywhere — no Python, no venv, no dependencies

// Docker image: ~5 MB (from scratch or distroless)
// FROM scratch
// COPY target/release/my_app /my_app
// CMD ["/my_app"]

// Startup time: <1ms
// Cross-compile: cargo build --target x86_64-unknown-linux-musl
}

When to Choose Rust Over Python

Choose Rust When:

  • Performance is critical: Data pipelines, real-time processing, compute-heavy services
  • Correctness matters: Financial systems, safety-critical code, protocol implementations
  • Deployment simplicity: Single binary, no runtime dependencies
  • Low-level control: Hardware interaction, OS integration, embedded systems
  • True concurrency: CPU-bound parallelism without GIL workarounds
  • Memory efficiency: Reduce cloud costs for memory-intensive services
  • Long-running services: Where predictable latency matters (no GC pauses)

Stay with Python When:

  • Rapid prototyping: Exploratory data analysis, scripts, one-off tools
  • ML/AI workflows: PyTorch, TensorFlow, scikit-learn ecosystem
  • Glue code: Connecting APIs, data transformation scripts
  • Team expertise: When Rust learning curve doesn’t justify benefits
  • Time to market: When development speed trumps execution speed
  • Interactive work: Jupyter notebooks, REPL-driven development
  • Scripting: Automation, sys-admin tasks, quick utilities

Consider Both (Hybrid Approach with PyO3):

  • Compute-heavy code in Rust: Called from Python via PyO3/maturin
  • Business logic and orchestration in Python: Familiar, productive
  • Gradual migration: Identify hotspots, replace with Rust extensions
  • Best of both: Python’s ecosystem + Rust’s performance

Real-World Impact: Why Companies Choose Rust

Dropbox: Storage Infrastructure

  • Before (Python): High CPU usage, memory overhead in sync engine
  • After (Rust): 10x performance improvement, 50% memory reduction
  • Result: Millions saved in infrastructure costs

Discord: Voice/Video Backend

  • Before (Python → Go): GC pauses causing audio drops
  • After (Rust): Consistent low-latency performance
  • Result: Better user experience, reduced server costs

Cloudflare: Edge Workers

  • Why Rust: WebAssembly compilation, predictable performance at edge
  • Result: Workers run with microsecond cold starts

Pydantic V2

  • Before: Pure Python validation — slow for large payloads
  • After: Rust core (via PyO3) — 5–50x faster validation
  • Result: Same Python API, dramatically faster execution

Why This Matters for Python Developers:

  1. Complementary skills: Rust and Python solve different problems
  2. PyO3 bridge: Write Rust extensions callable from Python
  3. Performance understanding: Learn why Python is slow and how to fix hotspots
  4. Career growth: Systems programming expertise increasingly valuable
  5. Cloud costs: 10x faster code = significantly lower infrastructure spend

Language Philosophy Comparison

Python Philosophy

  • Readability counts: Clean syntax, “one obvious way to do it”
  • Batteries included: Extensive standard library, rapid prototyping
  • Duck typing: “If it walks like a duck and quacks like a duck…”
  • Developer velocity: Optimize for writing speed, not execution speed
  • Dynamic everything: Modify classes at runtime, monkey-patching, metaclasses

Rust Philosophy

  • Performance without sacrifice: Zero-cost abstractions, no runtime overhead
  • Correctness first: If it compiles, entire categories of bugs are impossible
  • Explicit over implicit: No hidden behavior, no implicit conversions
  • Ownership: Resources have exactly one owner — memory, files, sockets
  • Fearless concurrency: The type system prevents data races at compile time
graph LR
    subgraph PY["🐍 Python"]
        direction TB
        PY_CODE["Your Code"] --> PY_INTERP["Interpreter — CPython VM"]
        PY_INTERP --> PY_GC["Garbage Collector — ref count + GC"]
        PY_GC --> PY_GIL["GIL — no true parallelism"]
        PY_GIL --> PY_OS["OS / Hardware"]
    end

    subgraph RS["🦀 Rust"]
        direction TB
        RS_CODE["Your Code"] --> RS_NONE["No runtime overhead"]
        RS_NONE --> RS_OWN["Ownership — compile-time, zero-cost"]
        RS_OWN --> RS_THR["Native threads — true parallelism"]
        RS_THR --> RS_OS["OS / Hardware"]
    end

    style PY_INTERP fill:#fff3e0,color:#000,stroke:#e65100
    style PY_GC fill:#fff3e0,color:#000,stroke:#e65100
    style PY_GIL fill:#ffcdd2,color:#000,stroke:#c62828
    style RS_NONE fill:#c8e6c9,color:#000,stroke:#2e7d32
    style RS_OWN fill:#c8e6c9,color:#000,stroke:#2e7d32
    style RS_THR fill:#c8e6c9,color:#000,stroke:#2e7d32

Quick Reference: Rust vs Python

ConceptPythonRustKey Difference
TypingDynamic (duck typing)Static (compile-time)Errors caught before runtime
MemoryGarbage collected (ref counting + cycle GC)Ownership systemZero-cost, deterministic cleanup
None/nullNone anywhereOption<T>Compile-time None safety
Error handlingraise/try/exceptResult<T, E>Explicit, no hidden control flow
MutabilityEverything mutableImmutable by defaultOpt-in to mutation
SpeedInterpreted (~10–100x slower)Compiled (C/C++ speed)Orders of magnitude faster
ConcurrencyGIL limits threadsNo GIL, Send/Sync traitsTrue parallelism by default
Dependenciespip install / poetry addcargo addBuilt-in dependency management
Build systemsetuptools/poetry/hatchCargoSingle unified tool
Packagingpyproject.tomlCargo.tomlSimilar declarative config
REPLpython interactiveNo REPL (use tests/cargo run)Compile-first workflow
Type hintsOptional, not enforcedRequired, compiler-enforcedTypes are not decorative

Exercises

🏋️ Exercise: Mental Model Check (click to expand)

Challenge: For each Python snippet, predict what Rust would require differently. Don’t write code — just describe the constraint.

  1. x = [1, 2, 3]; y = x; x.append(4) — What happens in Rust?
  2. data = None; print(data.upper()) — How does Rust prevent this?
  3. import threading; shared = []; threading.Thread(target=shared.append, args=(1,)).start() — What does Rust demand?
🔑 Solution
  1. Ownership move: let y = x; moves xx.push(4) is a compile error. You’d need let y = x.clone(); or borrow with let y = &x;.
  2. No null: data can’t be None unless it’s Option<String>. You must match or use .unwrap() / if let — no surprise NoneType errors.
  3. Send + Sync: The compiler requires shared to be wrapped in Arc<Mutex<Vec<i32>>>. Forgetting the lock = compile error, not a race condition.

Key takeaway: Rust shifts runtime failures to compile-time errors. The “friction” you feel is the compiler catching real bugs.


2. Getting Started

Installation and Setup

What you’ll learn: How to install Rust and its toolchain, the Cargo build system vs pip/Poetry, IDE setup, your first Hello, world! program, and essential Rust keywords mapped to Python equivalents.

Difficulty: 🟢 Beginner

Installing Rust

# Install Rust via rustup (Linux/macOS/WSL)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# Verify installation
rustc --version     # Rust compiler
cargo --version     # Build tool + package manager (like pip + setuptools combined)

# Update Rust
rustup update

Rust Tools vs Python Tools

PurposePythonRust
Language runtimepython (interpreter)rustc (compiler, rarely called directly)
Package managerpip / poetry / uvcargo (built-in)
Project configpyproject.tomlCargo.toml
Lock filepoetry.lock / requirements.txtCargo.lock
Virtual envvenv / condaNot needed (deps are per-project)
Formatterblack / ruff formatrustfmt (built-in: cargo fmt)
Linterruff / flake8 / pylintclippy (built-in: cargo clippy)
Type checkermypy / pyrightBuilt into compiler (always on)
Test runnerpytestcargo test (built-in)
Docssphinx / mkdocscargo doc (built-in)
REPLpython / ipythonNone (use cargo test or Rust Playground)

IDE Setup

VS Code (recommended):

Extensions to install:
- rust-analyzer        ← Essential: IDE features, type hints, completions
- Even Better TOML     ← Syntax highlighting for Cargo.toml
- CodeLLDB             ← Debugger support

# Python equivalent mapping:
# rust-analyzer ≈ Pylance (but with 100% type coverage, always)
# cargo clippy  ≈ ruff (but checks correctness, not just style)

Your First Rust Program

Python Hello World

# hello.py — just run it
print("Hello, World!")

# Run:
# python hello.py

Rust Hello World

// src/main.rs — must be compiled first
fn main() {
    println!("Hello, World!");   // println! is a macro (note the !)
}

// Build and run:
// cargo run

Key Differences for Python Developers

Python:                              Rust:
─────────                            ─────
- No main() needed                   - fn main() is the entry point
- Indentation = blocks               - Curly braces {} = blocks
- print() is a function              - println!() is a macro (the ! matters)
- No semicolons                      - Semicolons end statements
- No type declarations               - Types inferred but always known
- Interpreted (run directly)         - Compiled (cargo build, then run)
- Errors at runtime                  - Most errors at compile time

Creating Your First Project

# Python                              # Rust
mkdir myproject                        cargo new myproject
cd myproject                           cd myproject
python -m venv .venv                   # No virtual env needed
source .venv/bin/activate              # No activation needed
# Create files manually               # src/main.rs already created

# Python project structure:            Rust project structure:
# myproject/                           myproject/
# ├── pyproject.toml                   ├── Cargo.toml        (like pyproject.toml)
# ├── src/                             ├── src/
# │   └── myproject/                   │   └── main.rs       (entry point)
# │       ├── __init__.py              └── (no __init__.py needed)
# │       └── main.py
# └── tests/
#     └── test_main.py
graph TD
    subgraph Python ["Python Project"]
        PP["pyproject.toml"] --- PS["src/"]
        PS --- PM["myproject/"]
        PM --- PI["__init__.py"]
        PM --- PMN["main.py"]
        PP --- PT["tests/"]
    end
    subgraph Rust ["Rust Project"]
        RC["Cargo.toml"] --- RS["src/"]
        RS --- RM["main.rs"]
        RC --- RTG["target/ (auto-generated)"]
    end
    style Python fill:#ffeeba
    style Rust fill:#d4edda

Key difference: Rust projects are simpler — no __init__.py, no virtual environments, no setup.py vs setup.cfg vs pyproject.toml confusion. Just Cargo.toml + src/.


Cargo vs pip/Poetry

Project Configuration

# Python — pyproject.toml
[project]
name = "myproject"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
    "requests>=2.28",
    "pydantic>=2.0",
]

[project.optional-dependencies]
dev = ["pytest", "ruff", "mypy"]
# Rust — Cargo.toml
[package]
name = "myproject"
version = "0.1.0"
edition = "2021"          # Rust edition (like Python version)

[dependencies]
reqwest = "0.12"          # HTTP client (like requests)
serde = { version = "1.0", features = ["derive"] }  # Serialization (like pydantic)

[dev-dependencies]
# Test dependencies — only compiled for `cargo test`
# (No separate test config needed — `cargo test` is built in)

Common Cargo Commands

# Python equivalent                # Rust
pip install requests               cargo add reqwest
pip install -r requirements.txt    cargo build           # auto-installs deps
pip install -e .                   cargo build            # always "editable"
python -m pytest                   cargo test
python -m mypy .                   # Built into compiler — always runs
ruff check .                       cargo clippy
ruff format .                      cargo fmt
python main.py                     cargo run
python -c "..."                    # No equivalent — use cargo run or tests

# Rust-specific:
cargo new myproject                # Create new project
cargo build --release              # Optimized build (10-100x faster than debug)
cargo doc --open                   # Generate and browse API docs
cargo update                       # Update deps (like pip install --upgrade)

Essential Rust Keywords for Python Developers

Variable and Mutability Keywords

#![allow(unused)]
fn main() {
// let — declare a variable (like Python assignment, but immutable by default)
let name = "Alice";          // Python: name = "Alice" (but mutable)
// name = "Bob";             // ❌ Compile error! Immutable by default

// mut — opt into mutability
let mut count = 0;           // Python: count = 0 (always mutable in Python)
count += 1;                  // ✅ Allowed because of `mut`

// const — compile-time constant (like Python's convention of UPPER_CASE, but enforced)
const MAX_SIZE: usize = 1024;   // Python: MAX_SIZE = 1024 (convention only)

// static — global variable (use sparingly; Python has module-level globals)
static VERSION: &str = "1.0";
}

Ownership and Borrowing Keywords

#![allow(unused)]
fn main() {
// These have NO Python equivalents — they're Rust-specific concepts

// & — borrow (read-only reference)
fn print_name(name: &str) { }    // Python: def print_name(name: str) — but Python passes ref always

// &mut — mutable borrow
fn append(list: &mut Vec<i32>) { }  // Python: def append(lst: list) — always mutable in Python

// move — transfer ownership (happens implicitly in Rust, never in Python)
let s1 = String::from("hello");
let s2 = s1;    // s1 is MOVED to s2 — s1 is no longer valid
// println!("{}", s1);  // ❌ Compile error: value moved
}

Type Definition Keywords

#![allow(unused)]
fn main() {
// struct — like a Python dataclass or NamedTuple
struct Point {               // @dataclass
    x: f64,                  // class Point:
    y: f64,                  //     x: float
}                            //     y: float

// enum — like Python's enum but MUCH more powerful (carries data)
enum Shape {                 // No direct Python equivalent
    Circle(f64),             // Each variant can hold different data
    Rectangle(f64, f64),
}

// impl — attach methods to a type (like defining methods in a class)
impl Point {                 // class Point:
    fn distance(&self) -> f64 {  //     def distance(self) -> float:
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

// trait — like Python's ABC or Protocol (PEP 544)
trait Drawable {             // class Drawable(Protocol):
    fn draw(&self);          //     def draw(self) -> None: ...
}

// type — type alias (like Python's TypeAlias)
type UserId = i64;           // UserId = int  (or TypeAlias)
}

Control Flow Keywords

#![allow(unused)]
fn main() {
// match — exhaustive pattern matching (like Python 3.10+ match, but enforced)
match value {
    1 => println!("one"),
    2 | 3 => println!("two or three"),
    _ => println!("other"),          // _ = wildcard (like Python's case _:)
}

// if let — destructure + conditional (Pythonic: if (m := regex.match(s)):)
if let Some(x) = optional_value {
    println!("{}", x);
}

// loop — infinite loop (like while True:)
loop {
    break;  // Must break to exit
}

// for — iteration (like Python's for, but needs .iter() more often)
for item in collection.iter() {      // for item in collection:
    println!("{}", item);
}

// while let — loop with destructuring
while let Some(item) = stack.pop() {
    process(item);
}
}

Visibility Keywords

#![allow(unused)]
fn main() {
// pub — public (Python has no real private; uses _ convention)
pub fn greet() { }           // def greet():  — everything is "public" in Python

// pub(crate) — visible within the crate only
pub(crate) fn internal() { } // def _internal():  — single underscore convention

// (no keyword) — private to the module
fn private_helper() { }      // def __private():  — double underscore name mangling

// In Python, "private" is a gentleman's agreement.
// In Rust, private is enforced by the compiler.
}

Exercises

🏋️ Exercise: First Rust Program (click to expand)

Challenge: Create a new Rust project and write a program that:

  1. Declares a variable name with your name (type &str)
  2. Declares a mutable variable count starting at 0
  3. Uses a for loop from 1..=5 to increment count and print "Hello, {name}! (count: {count})"
  4. After the loop, print whether count is even or odd using a match expression
🔑 Solution
cargo new hello_rust && cd hello_rust
// src/main.rs
fn main() {
    let name = "Pythonista";
    let mut count = 0u32;

    for _ in 1..=5 {
        count += 1;
        println!("Hello, {name}! (count: {count})");
    }

    let parity = match count % 2 {
        0 => "even",
        _ => "odd",
    };
    println!("Final count {count} is {parity}");
}

Key takeaways:

  • let is immutable by default (you need mut to change count)
  • 1..=5 is inclusive range (Python’s range(1, 6))
  • match is an expression that returns a value
  • No self, no if __name__ == "__main__" — just fn main()

3. Built-in Types and Variables

Variables and Mutability

What you’ll learn: Immutable-by-default variables, explicit mut, primitive numeric types vs Python’s arbitrary-precision int, String vs &str (the hardest early concept), string formatting, and Rust’s required type annotations.

Difficulty: 🟢 Beginner

Python Variable Declaration

# Python — everything is mutable, dynamically typed
count = 0          # Mutable, type inferred as int
count = 5          # ✅ Works
count = "hello"    # ✅ Works — type can change! (dynamic typing)

# "Constants" are just convention:
MAX_SIZE = 1024    # Nothing prevents MAX_SIZE = 999 later

Rust Variable Declaration

#![allow(unused)]
fn main() {
// Rust — immutable by default, statically typed
let count = 0;           // Immutable, type inferred as i32
// count = 5;            // ❌ Compile error: cannot assign twice to immutable variable
// count = "hello";      // ❌ Compile error: expected integer, found &str

let mut count = 0;       // Explicitly mutable
count = 5;               // ✅ Works
// count = "hello";      // ❌ Still can't change type

const MAX_SIZE: usize = 1024; // True constant — enforced by compiler
}

Key Mental Shift for Python Developers

#![allow(unused)]
fn main() {
// Python: variables are labels that point to objects
// Rust: variables are named storage locations that OWN their values

// Variable shadowing — unique to Rust, very useful
let input = "42";              // &str
let input = input.parse::<i32>().unwrap();  // Now it's i32 — new variable, same name
let input = input * 2;         // Now it's 84 — another new variable

// In Python, you'd just reassign and lose the old type:
input = "42"
input = int(input)   # Same name, different type — Python allows this too
But in Rust, each `let` creates a genuinely new binding. The old one is gone.
}

Practical Example: Counter

# Python version
class Counter:
    def __init__(self):
        self.value = 0
    
    def increment(self):
        self.value += 1
    
    def get_value(self):
        return self.value

c = Counter()
c.increment()
print(c.get_value())  # 1
// Rust version
struct Counter {
    value: i64,
}

impl Counter {
    fn new() -> Self {
        Counter { value: 0 }
    }

    fn increment(&mut self) {     // &mut self = I will modify this
        self.value += 1;
    }

    fn get_value(&self) -> i64 {  // &self = I only read this
        self.value
    }
}

fn main() {
    let mut c = Counter::new();   // Must be `mut` to call increment()
    c.increment();
    println!("{}", c.get_value()); // 1
}

Key difference: In Rust, &mut self in the method signature tells you (and the compiler) that increment modifies the counter. In Python, any method can mutate anything — you have to read the code to know.


Primitive Types Comparison

flowchart LR
    subgraph Python ["Python Types"]
        PI["int\n(arbitrary precision)"] 
        PF["float\n(64-bit only)"]
        PB["bool"]
        PS["str\n(Unicode)"]
    end
    subgraph Rust ["Rust Types"]
        RI["i8 / i16 / i32 / i64 / i128\nu8 / u16 / u32 / u64 / u128"]
        RF["f32 / f64"]
        RB["bool"]
        RS["String / &str"]
    end
    PI -->|"fixed-size"| RI
    PF -->|"choose precision"| RF
    PB -->|"same"| RB
    PS -->|"owned vs borrowed"| RS
    style Python fill:#ffeeba
    style Rust fill:#d4edda

Numeric Types

PythonRustNotes
int (arbitrary precision)i8, i16, i32, i64, i128, isizeRust integers have fixed size
int (unsigned: no separate type)u8, u16, u32, u64, u128, usizeExplicit unsigned types
float (64-bit IEEE 754)f32, f64Python only has 64-bit float
boolboolSame concept
complexNo built-in (use num crate)Rare in systems code
# Python — one integer type, arbitrary precision
x = 42                     # int — can grow to any size
big = 2 ** 1000            # Still works — thousands of digits
y = 3.14                   # float — always 64-bit
#![allow(unused)]
fn main() {
// Rust — explicit sizes, overflow is a compile/runtime error
let x: i32 = 42;           // 32-bit signed integer
let y: f64 = 3.14;         // 64-bit float (Python's float equivalent)
let big: i128 = 2_i128.pow(100); // 128-bit max — no arbitrary precision
// For arbitrary precision: use the `num-bigint` crate

// Underscores for readability (like Python's 1_000_000):
let million = 1_000_000;   // Same syntax as Python!

// Type suffix syntax:
let a = 42u8;              // u8
let b = 3.14f32;           // f32
}

Size Types (Important!)

#![allow(unused)]
fn main() {
// usize and isize — pointer-sized integers, used for indexing
let length: usize = vec![1, 2, 3].len();  // .len() returns usize
let index: usize = 0;                     // Array indices are always usize

// In Python, len() returns int and indices are int — no distinction.
// In Rust, mixing i32 and usize requires explicit conversion:
let i: i32 = 5;
// let item = vec[i];    // ❌ Error: expected usize, found i32
let item = vec[i as usize]; // ✅ Explicit conversion
}

Type Inference

#![allow(unused)]
fn main() {
// Rust infers types but they're FIXED — not dynamic
let x = 42;          // Compiler infers i32 (default integer type)
let y = 3.14;        // Compiler infers f64 (default float type)
let s = "hello";     // Compiler infers &str (string slice)
let v = vec![1, 2];  // Compiler infers Vec<i32>

// You can always be explicit:
let x: i64 = 42;
let y: f32 = 3.14;

// Unlike Python, the type can NEVER change after inference:
let x = 42;
// x = "hello";      // ❌ Error: expected integer, found &str
}

String Types: String vs &str

This is one of the biggest surprises for Python developers. Rust has two main string types where Python has one.

Python String Handling

# Python — one string type, immutable, reference counted
name = "Alice"          # str — immutable, heap allocated
greeting = f"Hello, {name}!"  # f-string formatting
chars = list(name)      # Convert to list of characters
upper = name.upper()    # Returns new string (immutable)

Rust String Types

#![allow(unused)]
fn main() {
// Rust has TWO string types:

// 1. &str (string slice) — borrowed, immutable, like a "view" into string data
let name: &str = "Alice";           // Points to string data in the binary
                                     // Closest to Python's str, but it's a REFERENCE

// 2. String (owned string) — heap-allocated, growable, owned
let mut greeting = String::from("Hello, ");  // Owned, can be modified
greeting.push_str(name);
greeting.push('!');
// greeting is now "Hello, Alice!"
}

When to Use Which?

#![allow(unused)]
fn main() {
// Think of it like this:
// &str  = "I'm looking at a string someone else owns"  (read-only view)
// String = "I own this string and can modify it"        (owned data)

// Function parameters: prefer &str (accepts both types)
fn greet(name: &str) -> String {          // accepts &str AND &String
    format!("Hello, {}!", name)           // format! creates a new String
}

let s1 = "world";                         // &str literal
let s2 = String::from("Rust");            // String

greet(s1);      // ✅ &str works directly
greet(&s2);     // ✅ &String auto-converts to &str (Deref coercion)
}

Practical Examples

# Python string operations
name = "alice"
upper = name.upper()               # "ALICE"
contains = "lic" in name           # True
parts = "a,b,c".split(",")         # ["a", "b", "c"]
joined = "-".join(["a", "b", "c"]) # "a-b-c"
stripped = "  hello  ".strip()     # "hello"
replaced = name.replace("a", "A") # "Alice"
#![allow(unused)]
fn main() {
// Rust equivalents
let name = "alice";
let upper = name.to_uppercase();           // String — new allocation
let contains = name.contains("lic");       // bool
let parts: Vec<&str> = "a,b,c".split(',').collect();  // Vec<&str>
let joined = ["a", "b", "c"].join("-");    // String
let stripped = "  hello  ".trim();         // &str — no allocation!
let replaced = name.replace("a", "A");     // String

// Key insight: some operations return &str (no allocation), others return String.
// .trim() returns a slice of the original — efficient!
// .to_uppercase() must create a new String — allocation required.
}

Python Developers: Think of it This Way

Python str     ≈ Rust &str     (you usually read strings)
Python str     ≈ Rust String   (when you need to own/modify)

Rule of thumb:
- Function parameters → use &str (most flexible)
- Struct fields       → use String (struct owns its data)
- Return values       → use String (caller needs to own it)
- String literals     → automatically &str

Printing and String Formatting

Basic Output

# Python
print("Hello, World!")
print("Name:", name, "Age:", age)    # Space-separated
print(f"Name: {name}, Age: {age}")   # f-string
#![allow(unused)]
fn main() {
// Rust
println!("Hello, World!");
println!("Name: {} Age: {}", name, age);    // Positional {}
println!("Name: {name}, Age: {age}");       // Inline variables (Rust 1.58+, like f-strings!)
}

Format Specifiers

# Python formatting
print(f"{3.14159:.2f}")          # "3.14" — 2 decimal places
print(f"{42:05d}")               # "00042" — zero-padded
print(f"{255:#x}")               # "0xff" — hex
print(f"{42:>10}")               # "        42" — right-aligned
print(f"{'left':<10}|")          # "left      |" — left-aligned
#![allow(unused)]
fn main() {
// Rust formatting (very similar to Python!)
println!("{:.2}", 3.14159);         // "3.14" — 2 decimal places
println!("{:05}", 42);              // "00042" — zero-padded
println!("{:#x}", 255);             // "0xff" — hex
println!("{:>10}", 42);             // "        42" — right-aligned
println!("{:<10}|", "left");        // "left      |" — left-aligned
}

Debug Printing

# Python — repr() and pprint
print(repr([1, 2, 3]))             # "[1, 2, 3]"
from pprint import pprint
pprint({"key": [1, 2, 3]})         # Pretty-printed
#![allow(unused)]
fn main() {
// Rust — {:?} and {:#?}
println!("{:?}", vec![1, 2, 3]);       // "[1, 2, 3]" — Debug format
println!("{:#?}", vec![1, 2, 3]);      // Pretty-printed Debug format

// To make your types printable, derive Debug:
#[derive(Debug)]
struct Point { x: f64, y: f64 }

let p = Point { x: 1.0, y: 2.0 };
println!("{:?}", p);                   // "Point { x: 1.0, y: 2.0 }"
println!("{p:?}");                     // Same, with inline syntax
}

Quick Reference

PythonRustNotes
print(x)println!("{}", x) or println!("{x}")Display format
print(repr(x))println!("{:?}", x)Debug format
f"Hello {name}"format!("Hello {name}")Returns String
print(x, end="")print!("{x}")No newline (print! vs println!)
print(x, file=sys.stderr)eprintln!("{x}")Print to stderr
sys.stdout.write(s)print!("{s}")No newline

Type Annotations: Optional vs Required

Python Type Hints (Optional, Not Enforced)

# Python — type hints are documentation, not enforcement
def add(a: int, b: int) -> int:
    return a + b

add(1, 2)         # ✅
add("a", "b")     # ✅ Python doesn't care — returns "ab"
add(1, "2")       # ✅ Until it crashes at runtime: TypeError

# Union types, Optional
def find(key: str) -> int | None:
    ...

# Generic types
def first(items: list[int]) -> int | None:
    return items[0] if items else None

# Type aliases
UserId = int
Mapping = dict[str, list[int]]

Rust Type Declarations (Required, Compiler-Enforced)

#![allow(unused)]
fn main() {
// Rust — types are enforced. Always. No exceptions.
fn add(a: i32, b: i32) -> i32 {
    a + b
}

add(1, 2);         // ✅
// add("a", "b");  // ❌ Compile error: expected i32, found &str

// Optional values use Option<T>
fn find(key: &str) -> Option<i32> {
    // Returns Some(value) or None
    Some(42)
}

// Generic types
fn first(items: &[i32]) -> Option<i32> {
    items.first().copied()
}

// Type aliases
type UserId = i64;
type Mapping = HashMap<String, Vec<i32>>;
}

Key insight: In Python, type hints help your IDE and mypy but don’t affect runtime. In Rust, types ARE the program — the compiler uses them to guarantee memory safety, prevent data races, and eliminate null pointer errors.

📌 See also: Ch. 6 — Enums and Pattern Matching shows how Rust’s type system replaces Python’s Union types and isinstance() checks.


Exercises

🏋️ Exercise: Temperature Converter (click to expand)

Challenge: Write a function celsius_to_fahrenheit(c: f64) -> f64 and a function classify(temp_f: f64) -> &'static str that returns “cold”, “mild”, or “hot” based on thresholds. Print the result for 0, 20, and 35 degrees Celsius. Use string formatting.

🔑 Solution
fn celsius_to_fahrenheit(c: f64) -> f64 {
    c * 9.0 / 5.0 + 32.0
}

fn classify(temp_f: f64) -> &'static str {
    if temp_f < 50.0 { "cold" }
    else if temp_f < 77.0 { "mild" }
    else { "hot" }
}

fn main() {
    for c in [0.0, 20.0, 35.0] {
        let f = celsius_to_fahrenheit(c);
        println!("{c:.1}°C = {f:.1}°F — {}", classify(f));
    }
}

Key takeaway: Rust requires explicit f64 (no implicit int→float), for iterates over arrays directly (no range()), and if/else blocks are expressions.


4. Control Flow

Conditional Statements

What you’ll learn: if/else without parentheses (but with braces), loop/while/for vs Python’s iteration model, expression blocks (everything returns a value), and function signatures with mandatory return types.

Difficulty: 🟢 Beginner

if/else

# Python
if temperature > 100:
    print("Too hot!")
elif temperature < 0:
    print("Too cold!")
else:
    print("Just right")

# Ternary
status = "hot" if temperature > 100 else "ok"
#![allow(unused)]
fn main() {
// Rust — braces required, no colons, `else if` not `elif`
if temperature > 100 {
    println!("Too hot!");
} else if temperature < 0 {
    println!("Too cold!");
} else {
    println!("Just right");
}

// if is an EXPRESSION — returns a value (like Python ternary, but more powerful)
let status = if temperature > 100 { "hot" } else { "ok" };
}

Important Differences

#![allow(unused)]
fn main() {
// 1. Condition must be a bool — no truthy/falsy
let x = 42;
// if x { }          // ❌ Error: expected bool, found integer
if x != 0 { }        // ✅ Explicit comparison required

// In Python, these are all truthy/falsy:
// if []:      → False    (empty list)
// if "":      → False    (empty string)
// if 0:       → False    (zero)
// if None:    → False

// In Rust, ONLY bool works in conditions:
let items: Vec<i32> = vec![];
// if items { }           // ❌ Error
if !items.is_empty() { }  // ✅ Explicit check

let name = "";
// if name { }             // ❌ Error
if !name.is_empty() { }    // ✅ Explicit check
}

Loops and Iteration

for Loops

# Python
for i in range(5):
    print(i)

for item in ["a", "b", "c"]:
    print(item)

for i, item in enumerate(["a", "b", "c"]):
    print(f"{i}: {item}")

for key, value in {"x": 1, "y": 2}.items():
    print(f"{key} = {value}")
#![allow(unused)]
fn main() {
// Rust
for i in 0..5 {                           // range(5) → 0..5
    println!("{}", i);
}

for item in ["a", "b", "c"] {             // Direct iteration
    println!("{}", item);
}

for (i, item) in ["a", "b", "c"].iter().enumerate() {  // enumerate()
    println!("{}: {}", i, item);
}

// HashMap iteration
use std::collections::HashMap;
let map = HashMap::from([("x", 1), ("y", 2)]);
for (key, value) in &map {                // & borrows the map
    println!("{} = {}", key, value);
}
}

Range Syntax

#![allow(unused)]
fn main() {
Python:              Rust:               Notes:
range(5)             0..5                Half-open (excludes end)
range(1, 10)         1..10               Half-open
range(1, 11)         1..=10              Inclusive (includes end)
range(0, 10, 2)      (0..10).step_by(2)  Step (method, not syntax)
}

while Loops

# Python
count = 0
while count < 5:
    print(count)
    count += 1

# Infinite loop
while True:
    data = get_input()
    if data == "quit":
        break
#![allow(unused)]
fn main() {
// Rust
let mut count = 0;
while count < 5 {
    println!("{}", count);
    count += 1;
}

// Infinite loop — use `loop`, not `while true`
loop {
    let data = get_input();
    if data == "quit" {
        break;
    }
}

// loop can return a value! (unique to Rust)
let result = loop {
    let input = get_input();
    if let Ok(num) = input.parse::<i32>() {
        break num;  // `break` with a value — like return for loops
    }
    println!("Not a number, try again");
};
}

List Comprehensions vs Iterator Chains

# Python — list comprehensions
squares = [x ** 2 for x in range(10)]
evens = [x for x in range(20) if x % 2 == 0]
pairs = [(x, y) for x in range(3) for y in range(3)]
#![allow(unused)]
fn main() {
// Rust — iterator chains (.map, .filter, .collect)
let squares: Vec<i32> = (0..10).map(|x| x * x).collect();
let evens: Vec<i32> = (0..20).filter(|x| x % 2 == 0).collect();
let pairs: Vec<(i32, i32)> = (0..3)
    .flat_map(|x| (0..3).map(move |y| (x, y)))
    .collect();

// These are LAZY — nothing runs until .collect()
// Python comprehensions are eager (run immediately)
// Rust iterators can be more efficient for large datasets
}

Expression Blocks

Everything in Rust is an expression (or can be). This is a big shift from Python, where if/for are statements.

# Python — if is a statement (except ternary)
if condition:
    result = "yes"
else:
    result = "no"

# Or ternary (limited to one expression):
result = "yes" if condition else "no"
#![allow(unused)]
fn main() {
// Rust — if is an expression (returns a value)
let result = if condition { "yes" } else { "no" };

// Blocks are expressions — the last line (without semicolon) is the return value
let value = {
    let x = 5;
    let y = 10;
    x + y    // No semicolon → this is the value of the block (15)
};

// match is an expression too
let description = match temperature {
    t if t > 100 => "boiling",
    t if t > 50 => "hot",
    t if t > 20 => "warm",
    _ => "cold",
};
}

The following diagram illustrates the core difference between Python’s statement-based and Rust’s expression-based control flow:

flowchart LR
    subgraph Python ["Python — Statements"]
        P1["if condition:"] --> P2["result = 'yes'"]
        P1 --> P3["result = 'no'"]
        P2 --> P4["result used later"]
        P3 --> P4
    end
    subgraph Rust ["Rust — Expressions"]
        R1["let result = if cond"] --> R2["{ 'yes' }"]
        R1 --> R3["{ 'no' }"]
        R2 --> R4["value returned directly"]
        R3 --> R4
    end
    style Python fill:#ffeeba
    style Rust fill:#d4edda

The semicolon rule: In Rust, the last expression in a block without a semicolon is the block’s return value. Adding a semicolon makes it a statement (returns ()). This trips up Python developers initially — it’s like an implicit return.


Functions and Type Signatures

Python Functions

# Python — types optional, dynamic dispatch
def greet(name: str, greeting: str = "Hello") -> str:
    return f"{greeting}, {name}!"

# Default args, *args, **kwargs
def flexible(*args, **kwargs):
    pass

# First-class functions
def apply(f, x):
    return f(x)

result = apply(lambda x: x * 2, 5)  # 10

Rust Functions

#![allow(unused)]
fn main() {
// Rust — types REQUIRED on function signatures, no defaults
fn greet(name: &str, greeting: &str) -> String {
    format!("{}, {}!", greeting, name)
}

// No default arguments — use builder pattern or Option
fn greet_with_default(name: &str, greeting: Option<&str>) -> String {
    let greeting = greeting.unwrap_or("Hello");
    format!("{}, {}!", greeting, name)
}

// No *args/**kwargs — use slices or structs
fn sum_all(numbers: &[i32]) -> i32 {
    numbers.iter().sum()
}

// First-class functions and closures
fn apply(f: fn(i32) -> i32, x: i32) -> i32 {
    f(x)
}

let result = apply(|x| x * 2, 5);  // 10
}

Return Values

# Python — return is explicit, None is implicit
def divide(a, b):
    if b == 0:
        return None  # Or raise an exception
    return a / b
#![allow(unused)]
fn main() {
// Rust — last expression is the return value (no semicolon)
fn divide(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 {
        None              // Early return (could also write `return None;`)
    } else {
        Some(a / b)       // Last expression — implicit return
    }
}
}

Multiple Return Values

# Python — return a tuple
def min_max(numbers):
    return min(numbers), max(numbers)

lo, hi = min_max([3, 1, 4, 1, 5])
#![allow(unused)]
fn main() {
// Rust — return a tuple (same concept!)
fn min_max(numbers: &[i32]) -> (i32, i32) {
    let min = *numbers.iter().min().unwrap();
    let max = *numbers.iter().max().unwrap();
    (min, max)
}

let (lo, hi) = min_max(&[3, 1, 4, 1, 5]);
}

Methods: self vs &self vs &mut self

#![allow(unused)]
fn main() {
// In Python, `self` is always a mutable reference to the object.
// In Rust, you choose:

impl MyStruct {
    fn new() -> Self { ... }                // No self — "static method" / "classmethod"
    fn read_only(&self) { ... }             // &self — borrows immutably (can't modify)
    fn modify(&mut self) { ... }            // &mut self — borrows mutably (can modify)
    fn consume(self) { ... }                // self — takes ownership (object is moved)
}

// Python equivalent:
// class MyStruct:
//     @classmethod
//     def new(cls): ...                    # No instance needed
//     def read_only(self): ...             # All three are the same in Python:
//     def modify(self): ...                # Python self is always mutable
//     def consume(self): ...               # Python never "consumes" self
}

Exercises

🏋️ Exercise: FizzBuzz with Expressions (click to expand)

Challenge: Write FizzBuzz for 1..=30 using Rust’s expression-based match. Each number should print “Fizz”, “Buzz”, “FizzBuzz”, or the number. Use match (n % 3, n % 5) as the expression.

🔑 Solution
fn main() {
    for n in 1..=30 {
        let result = match (n % 3, n % 5) {
            (0, 0) => String::from("FizzBuzz"),
            (0, _) => String::from("Fizz"),
            (_, 0) => String::from("Buzz"),
            _ => n.to_string(),
        };
        println!("{result}");
    }
}

Key takeaway: match is an expression that returns a value — no need for if/elif/else chains. The _ wildcard replaces Python’s case _: default.


5. Data Structures and Collections

Tuples and Destructuring

What you’ll learn: Rust tuples vs Python tuples, arrays and slices, structs (Rust’s replacement for classes), Vec<T> vs list, HashMap<K,V> vs dict, and the newtype pattern for domain modeling.

Difficulty: 🟢 Beginner

Python Tuples

# Python — tuples are immutable sequences
point = (3.0, 4.0)
x, y = point                    # Unpacking
print(f"x={x}, y={y}")

# Tuples can hold mixed types
record = ("Alice", 30, True)
name, age, active = record

# Named tuples for clarity
from typing import NamedTuple

class Point(NamedTuple):
    x: float
    y: float

p = Point(3.0, 4.0)
print(p.x)                      # Named access

Rust Tuples

#![allow(unused)]
fn main() {
// Rust — tuples are fixed-size, typed, can hold mixed types
let point: (f64, f64) = (3.0, 4.0);
let (x, y) = point;              // Destructuring (same as Python unpacking)
println!("x={x}, y={y}");

// Mixed types
let record: (&str, i32, bool) = ("Alice", 30, true);
let (name, age, active) = record;

// Access by index (unlike Python, uses .0 .1 .2 syntax)
let first = record.0;            // "Alice"
let second = record.1;           // 30

// Python: record[0]
// Rust:   record.0      ← dot-index, not bracket-index
}

When to Use Tuples vs Structs

#![allow(unused)]
fn main() {
// Tuples: quick grouping, function returns, temporary values
fn min_max(data: &[i32]) -> (i32, i32) {
    (*data.iter().min().unwrap(), *data.iter().max().unwrap())
}
let (lo, hi) = min_max(&[3, 1, 4, 1, 5]);

// Structs: named fields, clear intent, methods
struct Point { x: f64, y: f64 }

// Rule of thumb:
// - 2-3 same-type fields → tuple is fine
// - Named fields needed  → use struct
// - Methods needed       → use struct
// (Same guidance as Python: tuple vs namedtuple vs dataclass)
}

Arrays and Slices

Python Lists vs Rust Arrays

# Python — lists are dynamic, heterogeneous
numbers = [1, 2, 3, 4, 5]       # Can grow, shrink, hold mixed types
numbers.append(6)
mixed = [1, "two", 3.0]         # Mixed types allowed
#![allow(unused)]
fn main() {
// Rust has TWO fixed-size vs dynamic concepts:

// 1. Array — fixed size, stack-allocated (no Python equivalent)
let numbers: [i32; 5] = [1, 2, 3, 4, 5]; // Size is part of the type!
// numbers.push(6);  // ❌ Arrays can't grow

// Initialize all elements to same value:
let zeros = [0; 10];            // [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

// 2. Slice — a view into an array or Vec (like Python slicing, but borrowed)
let slice: &[i32] = &numbers[1..4]; // [2, 3, 4] — a reference, not a copy!

// Python: numbers[1:4] creates a NEW list (copy)
// Rust:   &numbers[1..4] creates a VIEW (no copy, no allocation)
}

Practical Comparison

# Python slicing — creates copies
data = [10, 20, 30, 40, 50]
first_three = data[:3]          # New list: [10, 20, 30]
last_two = data[-2:]            # New list: [40, 50]
reversed_data = data[::-1]      # New list: [50, 40, 30, 20, 10]
#![allow(unused)]
fn main() {
// Rust slicing — creates views (references)
let data = [10, 20, 30, 40, 50];
let first_three = &data[..3];         // &[i32], view: [10, 20, 30]
let last_two = &data[3..];            // &[i32], view: [40, 50]

// No negative indexing — use .len()
let last_two = &data[data.len()-2..]; // &[i32], view: [40, 50]

// Reverse: use an iterator
let reversed: Vec<i32> = data.iter().rev().copied().collect();
}

Structs vs Classes

Python Classes

# Python — class with __init__, methods, properties
from dataclasses import dataclass

@dataclass
class Rectangle:
    width: float
    height: float

    def area(self) -> float:
        return self.width * self.height

    def perimeter(self) -> float:
        return 2.0 * (self.width + self.height)

    def scale(self, factor: float) -> "Rectangle":
        return Rectangle(self.width * factor, self.height * factor)

    def __str__(self) -> str:
        return f"Rectangle({self.width} x {self.height})"

r = Rectangle(10.0, 5.0)
print(r.area())         # 50.0
print(r)                # Rectangle(10.0 x 5.0)

Rust Structs

// Rust — struct + impl blocks (no inheritance!)
#[derive(Debug, Clone)]
struct Rectangle {
    width: f64,
    height: f64,
}

impl Rectangle {
    // "Constructor" — associated function (no self)
    fn new(width: f64, height: f64) -> Self {
        Rectangle { width, height }   // Field shorthand when names match
    }

    fn area(&self) -> f64 {
        self.width * self.height
    }

    fn perimeter(&self) -> f64 {
        2.0 * (self.width + self.height)
    }

    fn scale(&self, factor: f64) -> Rectangle {
        Rectangle::new(self.width * factor, self.height * factor)
    }
}

// Display trait = Python's __str__
impl std::fmt::Display for Rectangle {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Rectangle({} x {})", self.width, self.height)
    }
}

fn main() {
    let r = Rectangle::new(10.0, 5.0);
    println!("{}", r.area());    // 50.0
    println!("{}", r);           // Rectangle(10 x 5)
}
flowchart LR
    subgraph Python ["Python Object (Heap)"]
        PH["PyObject Header\n(refcount + type ptr)"] --> PW["width: float obj"]
        PH --> PHT["height: float obj"]
        PH --> PD["__dict__"]
    end
    subgraph Rust ["Rust Struct (Stack)"]
        RW["width: f64\n(8 bytes)"] --- RH["height: f64\n(8 bytes)"]
    end
    style Python fill:#ffeeba
    style Rust fill:#d4edda

Memory insight: A Python Rectangle object has a 56-byte header + separate heap-allocated float objects. A Rust Rectangle is exactly 16 bytes on the stack — no indirection, no GC pressure.

📌 See also: Ch. 10 — Traits and Generics covers implementing traits like Display, Debug, and operator overloading for your structs.

Key Mapping: Python Dunder Methods → Rust Traits

PythonRustPurpose
__str__impl DisplayHuman-readable string
__repr__#[derive(Debug)]Debug representation
__eq__#[derive(PartialEq)]Equality comparison
__hash__#[derive(Hash)]Hashable (for dict keys / HashSet)
__lt__, __le__, etc.#[derive(PartialOrd, Ord)]Ordering
__add__impl Add+ operator
__iter__impl IteratorIteration
__len__.len() methodLength
__enter__/__exit__impl DropCleanup (automatic in Rust)
__init__fn new() (convention)Constructor
__getitem__impl IndexIndexing with []
__contains__.contains() methodin operator

No Inheritance — Composition Instead

# Python — inheritance
class Animal:
    def __init__(self, name: str):
        self.name = name
    def speak(self) -> str:
        raise NotImplementedError

class Dog(Animal):
    def speak(self) -> str:
        return f"{self.name} says Woof!"

class Cat(Animal):
    def speak(self) -> str:
        return f"{self.name} says Meow!"
#![allow(unused)]
fn main() {
// Rust — traits + composition (no inheritance)
trait Animal {
    fn name(&self) -> &str;
    fn speak(&self) -> String;
}

struct Dog { name: String }
struct Cat { name: String }

impl Animal for Dog {
    fn name(&self) -> &str { &self.name }
    fn speak(&self) -> String {
        format!("{} says Woof!", self.name)
    }
}

impl Animal for Cat {
    fn name(&self) -> &str { &self.name }
    fn speak(&self) -> String {
        format!("{} says Meow!", self.name)
    }
}

// Use trait objects for polymorphism (like Python's duck typing):
fn animal_roll_call(animals: &[&dyn Animal]) {
    for a in animals {
        println!("{}", a.speak());
    }
}
}

Mental model: Python says “inherit behavior”. Rust says “implement contracts”. The result is similar, but Rust avoids the diamond problem and fragile base class issues.


Vec vs list

Vec<T> is Rust’s growable, heap-allocated array — the closest equivalent to Python’s list.

Creating Vectors

# Python
numbers = [1, 2, 3]
empty = []
repeated = [0] * 10
from_range = list(range(1, 6))
#![allow(unused)]
fn main() {
// Rust
let numbers = vec![1, 2, 3];            // vec! macro (like a list literal)
let empty: Vec<i32> = Vec::new();        // Empty vec (type annotation needed)
let repeated = vec![0; 10];              // [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let from_range: Vec<i32> = (1..6).collect(); // [1, 2, 3, 4, 5]
}

Common Operations

# Python list operations
nums = [1, 2, 3]
nums.append(4)                   # [1, 2, 3, 4]
nums.extend([5, 6])             # [1, 2, 3, 4, 5, 6]
nums.insert(0, 0)               # [0, 1, 2, 3, 4, 5, 6]
last = nums.pop()               # 6, nums = [0, 1, 2, 3, 4, 5]
length = len(nums)              # 6
nums.sort()                     # In-place sort
sorted_copy = sorted(nums)     # New sorted list
nums.reverse()                  # In-place reverse
contains = 3 in nums           # True
index = nums.index(3)          # Index of first 3
#![allow(unused)]
fn main() {
// Rust Vec operations
let mut nums = vec![1, 2, 3];
nums.push(4);                          // [1, 2, 3, 4]
nums.extend([5, 6]);                   // [1, 2, 3, 4, 5, 6]
nums.insert(0, 0);                     // [0, 1, 2, 3, 4, 5, 6]
let last = nums.pop();                 // Some(6), nums = [0, 1, 2, 3, 4, 5]
let length = nums.len();               // 6
nums.sort();                           // In-place sort
let mut sorted_copy = nums.clone();
sorted_copy.sort();                    // Sort a clone
nums.reverse();                        // In-place reverse
let contains = nums.contains(&3);      // true
let index = nums.iter().position(|&x| x == 3); // Some(index) or None
}

Quick Reference

PythonRustNotes
lst.append(x)vec.push(x)
lst.extend(other)vec.extend(other)
lst.pop()vec.pop()Returns Option<T>
lst.insert(i, x)vec.insert(i, x)
lst.remove(x)vec.retain(|v| v != &x)
del lst[i]vec.remove(i)Returns the removed element
len(lst)vec.len()
x in lstvec.contains(&x)
lst.sort()vec.sort()
sorted(lst)Clone + sort, or iterator
lst[i]vec[i]Panics if out of bounds
lst.get(i, default)vec.get(i)Returns Option<&T>
lst[1:3]&vec[1..3]Returns a slice (no copy)

HashMap vs dict

HashMap<K, V> is Rust’s hash map — equivalent to Python’s dict.

Creating HashMaps

# Python
scores = {"Alice": 100, "Bob": 85}
empty = {}
from_pairs = dict([("x", 1), ("y", 2)])
comprehension = {k: v for k, v in zip(keys, values)}
#![allow(unused)]
fn main() {
// Rust
use std::collections::HashMap;

let scores = HashMap::from([("Alice", 100), ("Bob", 85)]);
let empty: HashMap<String, i32> = HashMap::new();
let from_pairs: HashMap<&str, i32> = [("x", 1), ("y", 2)].into_iter().collect();
let comprehension: HashMap<_, _> = keys.iter().zip(values.iter()).collect();
}

Common Operations

# Python dict operations
d = {"a": 1, "b": 2}
d["c"] = 3                      # Insert
val = d["a"]                     # 1 (KeyError if missing)
val = d.get("z", 0)             # 0 (default if missing)
del d["b"]                       # Remove
exists = "a" in d               # True
keys = list(d.keys())           # ["a", "c"]
values = list(d.values())       # [1, 3]
items = list(d.items())         # [("a", 1), ("c", 3)]
length = len(d)                 # 2

# setdefault / defaultdict
from collections import defaultdict
word_count = defaultdict(int)
for word in words:
    word_count[word] += 1
#![allow(unused)]
fn main() {
// Rust HashMap operations
use std::collections::HashMap;

let mut d = HashMap::new();
d.insert("a", 1);
d.insert("b", 2);
d.insert("c", 3);                       // Insert or overwrite

let val = d["a"];                        // 1 (panics if missing)
let val = d.get("z").copied().unwrap_or(0); // 0 (safe access)
d.remove("b");                          // Remove
let exists = d.contains_key("a");       // true
let keys: Vec<_> = d.keys().collect();
let values: Vec<_> = d.values().collect();
let length = d.len();

// entry API = Python's setdefault / defaultdict pattern
let mut word_count: HashMap<&str, i32> = HashMap::new();
for word in words {
    *word_count.entry(word).or_insert(0) += 1;
}
}

Quick Reference

PythonRustNotes
d[key] = vald.insert(key, val)Returns Option<V> (old value)
d[key]d[&key]Panics if missing
d.get(key)d.get(&key)Returns Option<&V>
d.get(key, default)d.get(&key).unwrap_or(&default)
key in dd.contains_key(&key)
del d[key]d.remove(&key)Returns Option<V>
d.keys()d.keys()Iterator
d.values()d.values()Iterator
d.items()d.iter()Iterator of (&K, &V)
len(d)d.len()
d.update(other)d.extend(other)
defaultdict(int).entry().or_insert(0)Entry API
d.setdefault(k, v)d.entry(k).or_insert(v)Entry API

Other Collections

PythonRustNotes
set()HashSet<T>use std::collections::HashSet;
collections.dequeVecDeque<T>use std::collections::VecDeque;
heapqBinaryHeap<T>Max-heap by default
collections.OrderedDictIndexMap (crate)HashMap doesn’t preserve order
sortedcontainers.SortedListBTreeSet<T> / BTreeMap<K,V>Tree-based, sorted

Exercises

🏋️ Exercise: Word Frequency Counter (click to expand)

Challenge: Write a function that takes a &str sentence and returns a HashMap<String, usize> of word frequencies (case-insensitive). In Python this is Counter(s.lower().split()). Translate it to Rust.

🔑 Solution
use std::collections::HashMap;

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

fn main() {
    let text = "the quick brown fox jumps over the lazy fox";
    let freq = word_frequencies(text);
    for (word, count) in &freq {
        println!("{word}: {count}");
    }
}

Key takeaway: HashMap::entry().or_insert() is Rust’s equivalent of Python’s defaultdict or Counter. The * dereference is needed because or_insert returns &mut usize.


6. Enums and Pattern Matching

Algebraic Data Types vs Union Types

What you’ll learn: Rust enums with data vs Python Union types, exhaustive match vs match/case, Option<T> as a compile-time replacement for None, and guard patterns.

Difficulty: 🟡 Intermediate

Python 3.10 introduced match statements and type unions. Rust’s enums go further — each variant can carry different data, and the compiler ensures you handle every case.

Python Union Types and Match

# Python 3.10+ — structural pattern matching
from typing import Union
from dataclasses import dataclass

@dataclass
class Circle:
    radius: float

@dataclass
class Rectangle:
    width: float
    height: float

@dataclass
class Triangle:
    base: float
    height: float

Shape = Union[Circle, Rectangle, Triangle]  # Type alias

def area(shape: Shape) -> float:
    match shape:
        case Circle(radius=r):
            return 3.14159 * r * r
        case Rectangle(width=w, height=h):
            return w * h
        case Triangle(base=b, height=h):
            return 0.5 * b * h
        # No compiler warning if you miss a case!
        # Adding a new shape? grep the codebase and hope you find all match blocks.

Rust Enums — Data-Carrying Variants

#![allow(unused)]
fn main() {
// Rust — enum variants carry data, compiler enforces exhaustive matching
enum Shape {
    Circle(f64),                // Circle carries radius
    Rectangle(f64, f64),        // Rectangle carries width, height
    Triangle { base: f64, height: f64 }, // Named fields also work
}

fn area(shape: &Shape) -> f64 {
    match shape {
        Shape::Circle(r) => std::f64::consts::PI * r * r,
        Shape::Rectangle(w, h) => w * h,
        Shape::Triangle { base, height } => 0.5 * base * height,
        // ❌ If you add Shape::Pentagon and forget to handle it here,
        //    the compiler refuses to build. No grep needed.
    }
}
}

Key insight: Rust’s match is exhaustive — the compiler verifies you handle every variant. Add a new variant to an enum and the compiler tells you exactly which match blocks need updating. Python’s match has no such guarantee.

Enums Replace Multiple Python Patterns

# Python — several patterns that Rust enums replace:

# 1. String constants
STATUS_PENDING = "pending"
STATUS_ACTIVE = "active"
STATUS_CLOSED = "closed"

# 2. Python Enum (no data)
from enum import Enum
class Status(Enum):
    PENDING = "pending"
    ACTIVE = "active"
    CLOSED = "closed"

# 3. Tagged unions (class + type field)
class Message:
    def __init__(self, kind, **data):
        self.kind = kind
        self.data = data
# Message(kind="text", content="hello")
# Message(kind="image", url="...", width=100)
#![allow(unused)]
fn main() {
// Rust — one enum does all three and more

// 1. Simple enum (like Python's Enum)
enum Status {
    Pending,
    Active,
    Closed,
}

// 2. Data-carrying enum (tagged union — type-safe!)
enum Message {
    Text(String),
    Image { url: String, width: u32, height: u32 },
    Quit,                    // No data
    Move { x: i32, y: i32 },
}
}
flowchart TD
    E["enum Message"] --> T["Text(String)\n🏷️ tag=0 + String data"]
    E --> I["Image { url, width, height }\n🏷️ tag=1 + 3 fields"]
    E --> Q["Quit\n🏷️ tag=2 + no data"]
    E --> M["Move { x, y }\n🏷️ tag=3 + 2 fields"]
    style E fill:#d4edda,stroke:#28a745
    style T fill:#fff3cd
    style I fill:#fff3cd
    style Q fill:#fff3cd
    style M fill:#fff3cd

Memory insight: Rust enums are “tagged unions” — the compiler stores a discriminant tag + enough space for the largest variant. Python’s equivalent (Union[str, dict, None]) has no compact representation.

📌 See also: Ch. 9 — Error Handling uses enums extensively — Result<T, E> and Option<T> are just enums with match.

#![allow(unused)]
fn main() {
fn process(msg: &Message) {
    match msg {
        Message::Text(content) => println!("Text: {content}"),
        Message::Image { url, width, height } => {
            println!("Image: {url} ({width}x{height})")
        }
        Message::Quit => println!("Quitting"),
        Message::Move { x, y } => println!("Moving to ({x}, {y})"),
    }
}
}

Exhaustive Pattern Matching

Python’s match — Not Exhaustive

# Python — the wildcard case is optional, no compiler help
def describe(value):
    match value:
        case 0:
            return "zero"
        case 1:
            return "one"
        # If you forget the default, Python returns None silently.
        # No warning, no error.

describe(42)  # Returns None — a silent bug

Rust’s match — Compiler-Enforced

#![allow(unused)]
fn main() {
// Rust — MUST handle every possible case
fn describe(value: i32) -> &'static str {
    match value {
        0 => "zero",
        1 => "one",
        // ❌ Compile error: non-exhaustive patterns: `i32::MIN..=-1_i32`
        //    and `2_i32..=i32::MAX` not covered
        _ => "other",   // _ = catch-all (required for open-ended types)
    }
}

// For enums, NO catch-all needed — compiler knows all variants:
enum Color { Red, Green, Blue }

fn color_hex(c: Color) -> &'static str {
    match c {
        Color::Red => "#ff0000",
        Color::Green => "#00ff00",
        Color::Blue => "#0000ff",
        // No _ needed — all variants covered
        // Add Color::Yellow later → compiler error HERE
    }
}
}

Pattern Matching Features

#![allow(unused)]
fn main() {
// Multiple values (like Python's case 1 | 2 | 3:)
match value {
    1 | 2 | 3 => println!("small"),
    4..=9 => println!("medium"),    // Range patterns
    _ => println!("large"),
}

// Guards (like Python's case x if x > 0:)
match temperature {
    t if t > 100 => println!("boiling"),
    t if t < 0 => println!("freezing"),
    t => println!("normal: {t}°"),
}

// Nested destructuring
let point = (3, (4, 5));
match point {
    (0, _) => println!("on y-axis"),
    (_, (0, _)) => println!("y=0"),
    (x, (y, z)) => println!("x={x}, y={y}, z={z}"),
}
}

Option for None Safety

Option<T> is the most important Rust enum for Python developers. It replaces None with a type-safe alternative.

Python None

# Python — None is a value that can appear anywhere
def find_user(user_id: int) -> dict | None:
    users = {1: {"name": "Alice"}}
    return users.get(user_id)

user = find_user(999)
# user is None — but nothing forces you to check!
print(user["name"])  # 💥 TypeError at runtime

Rust Option

#![allow(unused)]
fn main() {
// Rust — Option<T> forces you to handle the None case
fn find_user(user_id: i64) -> Option<User> {
    let users = HashMap::from([(1, User { name: "Alice".into() })]);
    users.get(&user_id).cloned()
}

let user = find_user(999);
// user is Option<User> — you CANNOT use it without handling None

// Method 1: match
match find_user(999) {
    Some(user) => println!("Found: {}", user.name),
    None => println!("Not found"),
}

// Method 2: if let (like Python's if (x := expr) is not None)
if let Some(user) = find_user(1) {
    println!("Found: {}", user.name);
}

// Method 3: unwrap_or
let name = find_user(999)
    .map(|u| u.name)
    .unwrap_or_else(|| "Unknown".to_string());

// Method 4: ? operator (in functions that return Option)
fn get_user_name(id: i64) -> Option<String> {
    let user = find_user(id)?;     // Returns None early if not found
    Some(user.name)
}
}

Option Methods — Python Equivalents

PatternPythonRust
Check if existsif x is not None:if let Some(x) = opt {
Default valuex or defaultopt.unwrap_or(default)
Default factoryx or compute()opt.unwrap_or_else(|| compute())
Transform if existsf(x) if x else Noneopt.map(f)
Chain lookupsx and x.attr and x.attr.method()opt.and_then(|x| x.method())
Crash if NoneNot possible to preventopt.unwrap() (panic) or opt.expect("msg")
Get or raisex if x else raiseopt.ok_or(Error)?

Exercises

🏋️ Exercise: Shape Area Calculator (click to expand)

Challenge: Define an enum Shape with variants Circle(f64) (radius), Rectangle(f64, f64) (width, height), and Triangle(f64, f64) (base, height). Implement a method fn area(&self) -> f64 using match. Create one of each and print the area.

🔑 Solution
use std::f64::consts::PI;

enum Shape {
    Circle(f64),
    Rectangle(f64, f64),
    Triangle(f64, f64),
}

impl Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle(r) => PI * r * r,
            Shape::Rectangle(w, h) => w * h,
            Shape::Triangle(b, h) => 0.5 * b * h,
        }
    }
}

fn main() {
    let shapes = [
        Shape::Circle(5.0),
        Shape::Rectangle(4.0, 6.0),
        Shape::Triangle(3.0, 8.0),
    ];
    for shape in &shapes {
        println!("Area: {:.2}", shape.area());
    }
}

Key takeaway: Rust enums replace Python’s Union[Circle, Rectangle, Triangle] + isinstance() checks. The compiler ensures you handle every variant — adding a new shape without updating area() is a compile error.


7. Ownership and Borrowing

Understanding Ownership

What you’ll learn: Why Rust has ownership (no GC!), move semantics vs Python’s reference counting, borrowing (& and &mut), lifetime basics, and smart pointers (Box, Rc, Arc).

Difficulty: 🟡 Intermediate

This is the hardest concept for Python developers. In Python, you never think about who “owns” data — the garbage collector handles it. In Rust, every value has exactly one owner, and the compiler tracks this at compile time.

Python: Shared References Everywhere

# Python — everything is a reference, gc cleans up
a = [1, 2, 3]
b = a              # b and a point to the SAME list
b.append(4)
print(a)            # [1, 2, 3, 4] — surprise! a changed too

# Who owns the list? Both a and b reference it.
# The garbage collector frees it when no references remain.
# You never think about this.

Rust: Single Ownership

#![allow(unused)]
fn main() {
// Rust — every value has exactly ONE owner
let a = vec![1, 2, 3];
let b = a;           // Ownership MOVES from a to b
// println!("{:?}", a); // ❌ Compile error: value used after move

// a no longer exists. b is the sole owner.
println!("{:?}", b); // ✅ [1, 2, 3]

// When b goes out of scope, the Vec is freed. Deterministic. No GC.
}

The Three Ownership Rules

#![allow(unused)]
fn main() {
1. Each value has exactly ONE owner variable.
2. When the owner goes out of scope, the value is dropped (freed).
3. Ownership can be transferred (moved) but not duplicated (unless Clone).
}

Move Semantics — The Biggest Python Shock

# Python — assignment copies the reference, not the data
def process(data):
    data.append(42)
    # Original list is modified!

my_list = [1, 2, 3]
process(my_list)
print(my_list)       # [1, 2, 3, 42] — modified by process!
#![allow(unused)]
fn main() {
// Rust — passing to a function MOVES ownership (for non-Copy types)
fn process(mut data: Vec<i32>) -> Vec<i32> {
    data.push(42);
    data  // Must return it to give ownership back!
}

let my_vec = vec![1, 2, 3];
let my_vec = process(my_vec);  // Ownership moves in and back out
println!("{:?}", my_vec);      // [1, 2, 3, 42]

// Or better — borrow instead of moving:
fn process_borrowed(data: &mut Vec<i32>) {
    data.push(42);
}

let mut my_vec = vec![1, 2, 3];
process_borrowed(&mut my_vec);  // Lend it temporarily
println!("{:?}", my_vec);       // [1, 2, 3, 42] — still ours
}

Ownership Visualized

Python:                              Rust:

  a ──────┐                           a ──→ [1, 2, 3]
           ├──→ [1, 2, 3]
  b ──────┘                           After: let b = a;

  (a and b share one object)          a  (invalid, moved)
  (refcount = 2)                      b ──→ [1, 2, 3]
                                      (only b owns the data)

  del a → refcount = 1                drop(b) → data freed
  del b → refcount = 0 → freed        (deterministic, no GC)
stateDiagram-v2
    state "Python (Reference Counting)" as PY {
        [*] --> a_owns: a = [1,2,3]
        a_owns --> shared: b = a
        shared --> b_only: del a (refcount 2→1)
        b_only --> freed: del b (refcount 1→0)
        note right of shared: Both a and b point\nto the SAME object
    }
    state "Rust (Ownership Move)" as RS {
        [*] --> a_owns2: let a = vec![1,2,3]
        a_owns2 --> b_owns: let b = a (MOVE)
        b_owns --> freed2: b goes out of scope
        note right of b_owns: a is INVALID after move\nCompile error if used
    }

Move Semantics vs Reference Counting

Copy vs Move

#![allow(unused)]
fn main() {
// Simple types (integers, floats, bools, chars) are COPIED, not moved
let x = 42;
let y = x;    // x is COPIED to y (both valid)
println!("{x} {y}");  // ✅ 42 42

// Heap-allocated types (String, Vec, HashMap) are MOVED
let s1 = String::from("hello");
let s2 = s1;  // s1 is MOVED to s2
// println!("{s1}");  // ❌ Error: value used after move

// To explicitly copy heap data, use .clone()
let s1 = String::from("hello");
let s2 = s1.clone();  // Deep copy
println!("{s1} {s2}");  // ✅ hello hello (both valid)
}

Python Developer’s Mental Model

Python:                    Rust:
─────────                  ─────
int, float, bool           Copy types (i32, f64, bool, char)
→ copied on assignment     → copied on assignment (similar behavior)
                           (Note: Python caches small ints; Rust copies are always predictable)

list, dict, str            Move types (Vec, HashMap, String)
→ shared reference         → ownership transfer (different behavior!)
→ gc cleans up             → owner drops data
→ clone with list(x)       → clone with x.clone()
   or copy.deepcopy(x)

When Python’s Sharing Model Causes Bugs

# Python — accidental aliasing
def remove_duplicates(items):
    seen = set()
    result = []
    for item in items:
        if item not in seen:
            seen.add(item)
            result.append(item)
    return result

original = [1, 2, 2, 3, 3, 3]
alias = original          # Alias, NOT a copy
unique = remove_duplicates(alias)
# original is still [1, 2, 2, 3, 3, 3] — but only because we didn't mutate
# If remove_duplicates modified the input, original would be affected too
#![allow(unused)]
fn main() {
use std::collections::HashSet;

// Rust — ownership prevents accidental aliasing
fn remove_duplicates(items: &[i32]) -> Vec<i32> {
    let mut seen = HashSet::new();
    items.iter()
        .filter(|&&item| seen.insert(item))
        .copied()
        .collect()
}

let original = vec![1, 2, 2, 3, 3, 3];
let unique = remove_duplicates(&original); // Borrows — can't modify
// original is guaranteed unchanged — compiler prevented mutation via &
}

Borrowing and Lifetimes

Borrowing = Lending a Book

#![allow(unused)]
fn main() {
Think of ownership like a physical book:

Python:  Everyone has a photocopy (shared references + GC)
Rust:    One person owns the book. Others can:
         - &book     = look at it (immutable borrow, many allowed)
         - &mut book = write in it (mutable borrow, exclusive)
         - book      = give it away (move)
}

Borrowing Rules

flowchart TD
    R["Borrowing Rules"] --> IMM["✅ Many &T\n(shared/immutable)"]
    R --> MUT["✅ One &mut T\n(exclusive/mutable)"]
    R --> CONFLICT["❌ &T + &mut T\n(NEVER at same time)"]
    IMM --> SAFE["Multiple readers, safe"]
    MUT --> SAFE2["Single writer, safe"]
    CONFLICT --> ERR["Compile error!"]
    style IMM fill:#d4edda
    style MUT fill:#d4edda
    style CONFLICT fill:#f8d7da
    style ERR fill:#f8d7da,stroke:#dc3545
#![allow(unused)]
fn main() {
// Rule 1: You can have MANY immutable borrows OR ONE mutable borrow (not both)

let mut data = vec![1, 2, 3];

// Multiple immutable borrows — fine
let a = &data;
let b = &data;
println!("{:?} {:?}", a, b);  // ✅

// Mutable borrow — must be exclusive
let c = &mut data;
c.push(4);
// println!("{:?}", a);  // ❌ Error: can't use immutable borrow while mutable exists

// This prevents data races at compile time!
// Python has no equivalent — it's why Python dict modified-during-iteration crashes at runtime.
}

Lifetimes — A Brief Introduction

#![allow(unused)]
fn main() {
// Lifetimes answer: "How long does this reference live?"
// Usually the compiler infers them. You rarely write them explicitly.

// Simple case — compiler handles it:
fn first_word(s: &str) -> &str {
    s.split_whitespace().next().unwrap_or("")
}
// The compiler knows: the returned &str lives as long as the input &str

// When you need explicit lifetimes (rare):
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() { a } else { b }
}
// 'a says: "the return value lives as long as both inputs"
}

For Python developers: Don’t worry about lifetimes initially. The compiler will tell you when you need them, and 95% of the time it infers them automatically. Think of lifetime annotations as hints you give the compiler when it can’t figure out the relationships on its own.


Smart Pointers

For cases where single ownership is too restrictive, Rust provides smart pointers. These are closer to Python’s reference model — but explicit and opt-in.

#![allow(unused)]
fn main() {
// Box<T> — heap allocation with single owner (like Python's normal allocation)
let boxed = Box::new(42);  // Heap-allocated i32

// Rc<T> — reference counted (like Python's refcount!)
use std::rc::Rc;
let shared = Rc::new(vec![1, 2, 3]);
let clone1 = Rc::clone(&shared);  // Increment refcount
let clone2 = Rc::clone(&shared);  // Increment refcount
// All three point to the same Vec. When all are dropped, Vec is freed.
// Similar to Python's reference counting, but Rc does NOT handle cycles —
// use Weak<T> to break cycles (Python's GC handles cycles automatically)

// Arc<T> — atomic reference counting (Rc for multi-threaded code)
use std::sync::Arc;
let thread_safe = Arc::new(vec![1, 2, 3]);
// Use Arc when sharing across threads (Rc is single-threaded)

// RefCell<T> — runtime borrow checking (like Python's "anything goes" model)
use std::cell::RefCell;
let cell = RefCell::new(42);
*cell.borrow_mut() = 99;  // Mutable borrow at runtime (panics if double-borrowed)
}

When to Use Each

Smart PointerPython AnalogyUse Case
Box<T>Normal allocationLarge data, recursive types, trait objects
Rc<T>Python’s default refcountShared ownership, single-threaded
Arc<T>Thread-safe refcountShared ownership, multi-threaded
RefCell<T>Python’s “just mutate it”Interior mutability (escape hatch)
Rc<RefCell<T>>Python’s normal object modelShared + mutable (graph structures)

Key insight: Rc<RefCell<T>> gives you Python-like semantics (shared, mutable data) but you have to opt in explicitly. Rust’s default (owned, moved) is faster and avoids the overhead of reference counting. For graph-like structures with cycles, use Weak<T> to break reference loops — unlike Python, Rust’s Rc has no cycle collector.

📌 See also: Ch. 13 — Concurrency covers Arc<Mutex<T>> for multi-threaded shared state.


Exercises

🏋️ Exercise: Spot the Borrow Checker Error (click to expand)

Challenge: The following code has 3 borrow checker errors. Identify each one and fix them without using .clone():

fn main() {
    let mut names = vec!["Alice".to_string(), "Bob".to_string()];
    let first = &names[0];
    names.push("Charlie".to_string());
    println!("First: {first}");

    let greeting = make_greeting(names[0]);
    println!("{greeting}");
}

fn make_greeting(name: String) -> String {
    format!("Hello, {name}!")
}
🔑 Solution
fn main() {
    let mut names = vec!["Alice".to_string(), "Bob".to_string()];
    let first = &names[0];
    println!("First: {first}"); // Use borrow BEFORE mutating
    names.push("Charlie".to_string()); // Now safe — no live immutable borrow

    let greeting = make_greeting(&names[0]); // Pass reference, not owned
    println!("{greeting}");
}

fn make_greeting(name: &str) -> String { // Accept &str, not String
    format!("Hello, {name}!")
}

Errors fixed:

  1. Immutable borrow + mutation: first borrows names, then push mutates it. Fix: use first before pushing.
  2. Move out of Vec: names[0] tries to move a String out of Vec (not allowed). Fix: borrow with &names[0].
  3. Function takes ownership: make_greeting(String) consumes the value. Fix: take &str instead.

8. Crates and Modules

Rust Modules vs Python Packages

What you’ll learn: mod and use vs import, visibility (pub) vs Python’s convention-based privacy, Cargo.toml vs pyproject.toml, crates.io vs PyPI, and workspaces vs monorepos.

Difficulty: 🟢 Beginner

Python Module System

# Python — files are modules, directories with __init__.py are packages

# myproject/
# ├── __init__.py          # Makes it a package
# ├── main.py
# ├── utils/
# │   ├── __init__.py      # Makes utils a sub-package
# │   ├── helpers.py
# │   └── validators.py
# └── models/
#     ├── __init__.py
#     ├── user.py
#     └── product.py

# Importing:
from myproject.utils.helpers import format_name
from myproject.models.user import User
import myproject.utils.validators as validators

Rust Module System

#![allow(unused)]
fn main() {
// Rust — mod declarations create the module tree, files provide content

// src/
// ├── main.rs             # Crate root — declares modules
// ├── utils/
// │   ├── mod.rs           # Module declaration (like __init__.py)
// │   ├── helpers.rs
// │   └── validators.rs
// └── models/
//     ├── mod.rs
//     ├── user.rs
//     └── product.rs

// In src/main.rs:
mod utils;       // Tells Rust to look for src/utils/mod.rs
mod models;      // Tells Rust to look for src/models/mod.rs

use utils::helpers::format_name;
use models::user::User;

// In src/utils/mod.rs:
pub mod helpers;      // Declares and re-exports helpers.rs
pub mod validators;   // Declares and re-exports validators.rs
}
graph TD
    A["main.rs<br/>(crate root)"] --> B["mod utils"]
    A --> C["mod models"]
    B --> D["utils/mod.rs"]
    D --> E["helpers.rs"]
    D --> F["validators.rs"]
    C --> G["models/mod.rs"]
    G --> H["user.rs"]
    G --> I["product.rs"]
    style A fill:#d4edda,stroke:#28a745
    style D fill:#fff3cd,stroke:#ffc107
    style G fill:#fff3cd,stroke:#ffc107

Python equivalent: Think of mod.rs as __init__.py — it declares what the module exports. The crate root (main.rs / lib.rs) is like your top-level package __init__.py.

Key Differences

ConceptPythonRust
Module = file✅ AutomaticMust declare with mod
Package = directory__init__.pymod.rs
Public by default✅ Everything❌ Private by default
Make public_prefix conventionpub keyword
Import syntaxfrom x import yuse x::y;
Wildcard importfrom x import *use x::*; (discouraged)
Relative importsfrom . import siblinguse super::sibling;
Re-export__all__ or explicitpub use inner::Thing;

Visibility — Private by Default

# Python — "we're all adults here"
class User:
    def __init__(self):
        self.name = "Alice"       # Public (by convention)
        self._age = 30            # "Private" (convention: single underscore)
        self.__secret = "shhh"    # Name-mangled (not truly private)

# Nothing stops you from accessing _age or even __secret
print(user._age)                  # Works fine
print(user._User__secret)        # Works too (name mangling)
#![allow(unused)]
fn main() {
// Rust — private is enforced by the compiler
pub struct User {
    pub name: String,      // Public — anyone can access
    age: i32,              // Private — only this module can access
}

impl User {
    pub fn new(name: &str, age: i32) -> Self {
        User { name: name.to_string(), age }
    }

    pub fn age(&self) -> i32 {   // Public getter
        self.age
    }

    fn validate(&self) -> bool { // Private method
        self.age > 0
    }
}

// Outside the module:
let user = User::new("Alice", 30);
println!("{}", user.name);        // ✅ Public
// println!("{}", user.age);      // ❌ Compile error: field is private
println!("{}", user.age());       // ✅ Public method (getter)
}

Crates vs PyPI Packages

Python Packages (PyPI)

# Python
pip install requests           # Install from PyPI
pip install "requests>=2.28"   # Version constraint
pip freeze > requirements.txt  # Lock versions
pip install -r requirements.txt # Reproduce environment

Rust Crates (crates.io)

# Rust
cargo add reqwest              # Install from crates.io (adds to Cargo.toml)
cargo add reqwest@0.12         # Version constraint
# Cargo.lock is auto-generated — no manual step
cargo build                    # Downloads and compiles dependencies

Cargo.toml vs pyproject.toml

# Rust — Cargo.toml
[package]
name = "my-project"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1.0", features = ["derive"] }  # With feature flags
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
log = "0.4"

[dev-dependencies]
mockall = "0.13"

Essential Crates for Python Developers

Python LibraryRust CratePurpose
requestsreqwestHTTP client
json (stdlib)serde_jsonJSON parsing
pydanticserdeSerialization/validation
pathlibstd::path (stdlib)Path handling
os / shutilstd::fs (stdlib)File operations
reregexRegular expressions
loggingtracing / logLogging
click / argparseclapCLI argument parsing
asynciotokioAsync runtime
datetimechronoDate and time
pytestBuilt-in + rstestTesting
dataclasses#[derive(...)]Data structures
typing.ProtocolTraitsStructural typing
subprocessstd::process (stdlib)Run external commands
sqlite3rusqliteSQLite
sqlalchemydiesel / sqlxORM / SQL toolkit
fastapiaxum / actix-webWeb framework

Workspaces vs Monorepos

Python Monorepo (typical)

# Python monorepo (various approaches, no standard)
myproject/
├── pyproject.toml           # Root project
├── packages/
│   ├── core/
│   │   ├── pyproject.toml   # Each package has its own config
│   │   └── src/core/...
│   ├── api/
│   │   ├── pyproject.toml
│   │   └── src/api/...
│   └── cli/
│       ├── pyproject.toml
│       └── src/cli/...
# Tools: poetry workspaces, pip -e ., uv workspaces — no standard

Rust Workspace

# Rust — Cargo.toml at root
[workspace]
members = [
    "core",
    "api",
    "cli",
]

# Shared dependencies across workspace
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
# Rust workspace structure — standardized, built into Cargo
myproject/
├── Cargo.toml               # Workspace root
├── Cargo.lock               # Single lock file for all crates
├── core/
│   ├── Cargo.toml            # [dependencies] serde.workspace = true
│   └── src/lib.rs
├── api/
│   ├── Cargo.toml
│   └── src/lib.rs
└── cli/
    ├── Cargo.toml
    └── src/main.rs
# Workspace commands
cargo build                  # Build everything
cargo test                   # Test everything
cargo build -p core          # Build just the core crate
cargo test -p api            # Test just the api crate
cargo clippy --all           # Lint everything

Key insight: Rust workspaces are first-class, built into Cargo. Python monorepos require third-party tools (poetry, uv, pants) with varying levels of support. In a Rust workspace, all crates share a single Cargo.lock, ensuring consistent dependency versions across the project.


Exercises

🏋️ Exercise: Module Visibility (click to expand)

Challenge: Given this module structure, predict which lines compile and which don’t:

mod kitchen {
    fn secret_recipe() -> &'static str { "42 spices" }
    pub fn menu() -> &'static str { "Today's special" }

    pub mod staff {
        pub fn cook() -> String {
            format!("Cooking with {}", super::secret_recipe())
        }
    }
}

fn main() {
    println!("{}", kitchen::menu());             // Line A
    println!("{}", kitchen::secret_recipe());     // Line B
    println!("{}", kitchen::staff::cook());       // Line C
}
🔑 Solution
  • Line A: ✅ Compiles — menu() is pub
  • Line B: ❌ Compile error — secret_recipe() is private to kitchen
  • Line C: ✅ Compiles — staff::cook() is pub, and cook() can access secret_recipe() via super:: (child modules can access parent’s private items)

Key takeaway: In Rust, child modules can see parent’s privates (like Python’s _private convention, but enforced). Outsiders cannot. This is the opposite of Python where _private is just a hint.


9. Error Handling

Exceptions vs Result

What you’ll learn: Result<T, E> vs try/except, the ? operator for concise error propagation, custom error types with thiserror, anyhow for applications, and why explicit errors prevent hidden bugs.

Difficulty: 🟡 Intermediate

This is one of the biggest mindset changes for Python developers. Python uses exceptions for error handling — errors can be thrown from anywhere and caught anywhere (or not at all). Rust uses Result<T, E> — errors are values that must be explicitly handled.

Python Exception Handling

# Python — exceptions can be thrown from anywhere
import json

def load_config(path: str) -> dict:
    try:
        with open(path) as f:
            data = json.load(f)     # Can raise JSONDecodeError
            if "version" not in data:
                raise ValueError("Missing version field")
            return data
    except FileNotFoundError:
        print(f"Config file not found: {path}")
        return {}
    except json.JSONDecodeError as e:
        print(f"Invalid JSON: {e}")
        return {}
    # What other exceptions can this throw?
    # IOError? PermissionError? UnicodeDecodeError?
    # You can't tell from the function signature!

Rust Result-Based Error Handling

#![allow(unused)]
fn main() {
// Rust — errors are return values, visible in the function signature
use std::fs;
use serde_json::Value;

fn load_config(path: &str) -> Result<Value, ConfigError> {
    let contents = fs::read_to_string(path)    // Returns Result
        .map_err(|e| ConfigError::FileError(e.to_string()))?;

    let data: Value = serde_json::from_str(&contents)  // Returns Result
        .map_err(|e| ConfigError::ParseError(e.to_string()))?;

    if data.get("version").is_none() {
        return Err(ConfigError::MissingField("version".to_string()));
    }

    Ok(data)
}

#[derive(Debug)]
enum ConfigError {
    FileError(String),
    ParseError(String),
    MissingField(String),
}
}

Key Differences

Python:                                 Rust:
─────────                               ─────
- Errors are exceptions (thrown)        - Errors are values (returned)
- Hidden control flow (stack unwinding) - Explicit control flow (? operator)
- Can't tell what errors from signature- MUST see errors in return type
- Uncaught exceptions crash at runtime - Unhandled Results are compile warnings
- try/except is optional               - Handling Result is required
- Broad except catches everything      - match arms are exhaustive

The Two Result Variants

#![allow(unused)]
fn main() {
// Result<T, E> has exactly two variants:
enum Result<T, E> {
    Ok(T),    // Success — contains the value (like Python's return value)
    Err(E),   // Failure — contains the error (like Python's raised exception)
}

// Using Result:
fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err("Division by zero".to_string())  // Like: raise ValueError("...")
    } else {
        Ok(a / b)                             // Like: return a / b
    }
}

// Handling Result — like try/except but explicit
match divide(10.0, 0.0) {
    Ok(result) => println!("Result: {result}"),
    Err(msg) => println!("Error: {msg}"),
}
}

The ? Operator

The ? operator is Rust’s equivalent of letting exceptions propagate up the call stack, but it’s visible and explicit.

Python — Implicit Propagation

# Python — exceptions propagate silently up the call stack
def read_username() -> str:
    with open("config.txt") as f:      # FileNotFoundError propagates
        return f.readline().strip()    # IOError propagates

def greet():
    name = read_username()             # If this throws, greet() also throws
    print(f"Hello, {name}!")           # This is skipped on error

# The error propagation is INVISIBLE — you have to read the implementation
# to know what exceptions might escape.

Rust — Explicit Propagation with ?

#![allow(unused)]
fn main() {
// Rust — ? propagates errors, but it's visible in the code AND the signature
use std::fs;
use std::io;

fn read_username() -> Result<String, io::Error> {
    let contents = fs::read_to_string("config.txt")?;  // ? = propagate on Err
    Ok(contents.lines().next().unwrap_or("").to_string())
}

fn greet() -> Result<(), io::Error> {
    let name = read_username()?;       // ? = if Err, return Err immediately
    println!("Hello, {name}!");        // Only reached on Ok
    Ok(())
}

// The ? says: "if this is Err, return it from THIS function immediately."
// It's like Python's exception propagation, but:
// 1. It's visible (you see the ?)
// 2. It's in the return type (Result<..., io::Error>)
// 3. The compiler ensures you handle it somewhere
}

Chaining with ?

# Python — multiple operations that might fail
def process_file(path: str) -> dict:
    with open(path) as f:                    # Might fail
        text = f.read()                       # Might fail
    data = json.loads(text)                   # Might fail
    validate(data)                            # Might fail
    return transform(data)                    # Might fail
    # Any of these can throw — and the exception type varies!
#![allow(unused)]
fn main() {
// Rust — same chain, but explicit
fn process_file(path: &str) -> Result<Data, AppError> {
    let text = fs::read_to_string(path)?;     // ? propagates io::Error
    let data: Value = serde_json::from_str(&text)?;  // ? propagates serde error
    let validated = validate(&data)?;          // ? propagates validation error
    let result = transform(&validated)?;       // ? propagates transform error
    Ok(result)
}
// Every ? is a potential early return — and they're all visible!
}
flowchart TD
    A["read_to_string(path)?"] -->|Ok| B["serde_json::from_str?"] 
    A -->|Err| X["Return Err(io::Error)"]
    B -->|Ok| C["validate(&data)?"]
    B -->|Err| Y["Return Err(serde::Error)"]
    C -->|Ok| D["transform(&validated)?"]
    C -->|Err| Z["Return Err(ValidationError)"]
    D -->|Ok| E["Ok(result) ✅"]
    D -->|Err| W["Return Err(TransformError)"]
    style E fill:#d4edda,stroke:#28a745
    style X fill:#f8d7da,stroke:#dc3545
    style Y fill:#f8d7da,stroke:#dc3545
    style Z fill:#f8d7da,stroke:#dc3545
    style W fill:#f8d7da,stroke:#dc3545

Each ? is an exit point — unlike Python’s try/except where you can’t see which line might throw without reading the docs.

📌 See also: Ch. 15 — Migration Patterns covers translating Python try/except patterns to Rust in real codebases.


Custom Error Types with thiserror

graph TD
    AE["AppError (enum)"] --> NF["NotFound\n{ entity, id }"]
    AE --> VE["Validation\n{ field, message }"]
    AE --> IO["Io(std::io::Error)\n#[from]"]
    AE --> JSON["Json(serde_json::Error)\n#[from]"]
    IO2["std::io::Error"] -->|"auto-convert via From"| IO
    JSON2["serde_json::Error"] -->|"auto-convert via From"| JSON
    style AE fill:#d4edda,stroke:#28a745
    style NF fill:#fff3cd
    style VE fill:#fff3cd
    style IO fill:#fff3cd
    style JSON fill:#fff3cd
    style IO2 fill:#f8d7da
    style JSON2 fill:#f8d7da

The #[from] attribute auto-generates impl From<io::Error> for AppError, so ? converts library errors into your app errors automatically.

Python Custom Exceptions

# Python — custom exception classes
class AppError(Exception):
    pass

class NotFoundError(AppError):
    def __init__(self, entity: str, id: int):
        self.entity = entity
        self.id = id
        super().__init__(f"{entity} with id {id} not found")

class ValidationError(AppError):
    def __init__(self, field: str, message: str):
        self.field = field
        super().__init__(f"Validation error on {field}: {message}")

# Usage:
def find_user(user_id: int) -> dict:
    if user_id not in users:
        raise NotFoundError("User", user_id)
    return users[user_id]

Rust Custom Errors with thiserror

#![allow(unused)]
fn main() {
// Rust — error enums with thiserror (most popular approach)
// Cargo.toml: thiserror = "2"

use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("{entity} with id {id} not found")]
    NotFound { entity: String, id: i64 },

    #[error("Validation error on {field}: {message}")]
    Validation { field: String, message: String },

    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),        // Auto-convert from io::Error

    #[error("JSON error: {0}")]
    Json(#[from] serde_json::Error),   // Auto-convert from serde error
}

// Usage:
fn find_user(user_id: i64) -> Result<User, AppError> {
    users.get(&user_id)
        .cloned()
        .ok_or(AppError::NotFound {
            entity: "User".to_string(),
            id: user_id,
        })
}

// The #[from] attribute means ? auto-converts io::Error → AppError::Io
fn load_users(path: &str) -> Result<Vec<User>, AppError> {
    let data = fs::read_to_string(path)?;  // io::Error → AppError::Io automatically
    let users: Vec<User> = serde_json::from_str(&data)?;  // → AppError::Json
    Ok(users)
}
}

Error Handling Quick Reference

PythonRustNotes
raise ValueError("msg")return Err(AppError::Validation {...})Explicit return
try: ... except:match result { Ok(v) => ..., Err(e) => ... }Exhaustive
except ValueError as e:Err(AppError::Validation { .. }) =>Pattern match
raise ... from e#[from] attribute or .map_err()Error chaining
finally:Drop trait (automatic)Deterministic cleanup
with open(...):Scope-based drop (automatic)RAII pattern
Exception propagates silently? propagates visiblyAlways in return type
isinstance(e, ValueError)matches!(e, AppError::Validation {..})Type checking

Exercises

🏋️ Exercise: Parse Config Value (click to expand)

Challenge: Write a function parse_port(s: &str) -> Result<u16, String> that:

  1. Rejects empty strings with error "empty input"
  2. Parses the string to u16, mapping the parse error to "invalid number: {original_error}"
  3. Rejects ports below 1024 with "port {n} is privileged"

Call it with "", "hello", "80", and "8080" and print the results.

🔑 Solution
fn parse_port(s: &str) -> Result<u16, String> {
    if s.is_empty() {
        return Err("empty input".to_string());
    }
    let port: u16 = s.parse().map_err(|e| format!("invalid number: {e}"))?;
    if port < 1024 {
        return Err(format!("port {port} is privileged"));
    }
    Ok(port)
}

fn main() {
    for input in ["", "hello", "80", "8080"] {
        match parse_port(input) {
            Ok(port) => println!("✅ {input} → {port}"),
            Err(e) => println!("❌ {input:?} → {e}"),
        }
    }
}

Key takeaway: ? with .map_err() is Rust’s replacement for try/except ValueError as e: raise ConfigError(...) from e. Every error path is visible in the return type.


10. Traits and Generics

Traits vs Duck Typing

What you’ll learn: Traits as explicit contracts (vs Python duck typing), Protocol (PEP 544) ≈ Trait, generic type bounds with where clauses, trait objects (dyn Trait) vs static dispatch, and common std traits.

Difficulty: 🟡 Intermediate

This is where Rust’s type system really shines for Python developers. Python’s “duck typing” says: “if it walks like a duck and quacks like a duck, it’s a duck.” Rust’s traits say: “I’ll tell you exactly which duck behaviors I need, at compile time.”

Python Duck Typing

# Python — duck typing: anything with the right methods works
def total_area(shapes):
    """Works with anything that has an .area() method."""
    return sum(shape.area() for shape in shapes)

class Circle:
    def __init__(self, radius): self.radius = radius
    def area(self): return 3.14159 * self.radius ** 2

class Rectangle:
    def __init__(self, w, h): self.w, self.h = w, h
    def area(self): return self.w * self.h

# Works at runtime — no inheritance needed!
shapes = [Circle(5), Rectangle(3, 4)]
print(total_area(shapes))  # 90.54

# But what if something doesn't have .area()?
class Dog:
    def bark(self): return "Woof!"

total_area([Dog()])  # 💥 AttributeError: 'Dog' has no attribute 'area'
# Error happens at RUNTIME, not at definition time

Rust Traits — Explicit Duck Typing

#![allow(unused)]
fn main() {
// Rust — traits make the "duck" contract explicit
trait HasArea {
    fn area(&self) -> f64;      // Any type that implements this trait has .area()
}

struct Circle { radius: f64 }
struct Rectangle { width: f64, height: f64 }

impl HasArea for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
}

impl HasArea for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
}

// The trait constraint is explicit — compiler checks at compile time
fn total_area(shapes: &[&dyn HasArea]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

// Using it:
let shapes: Vec<&dyn HasArea> = vec![&Circle { radius: 5.0 }, &Rectangle { width: 3.0, height: 4.0 }];
println!("{}", total_area(&shapes));  // 90.54

// struct Dog;
// total_area(&[&Dog {}]);  // ❌ Compile error: Dog doesn't implement HasArea
}

Key insight: Python’s duck typing defers errors to runtime. Rust’s traits catch them at compile time. Same flexibility, earlier error detection.


Protocols (PEP 544) vs Traits

Python 3.8 introduced Protocol (PEP 544) for structural subtyping — it’s the closest Python concept to Rust traits.

Python Protocol

# Python — Protocol (structural typing, like Rust traits)
from typing import Protocol, runtime_checkable

@runtime_checkable
class Printable(Protocol):
    def to_string(self) -> str: ...

class User:
    def __init__(self, name: str):
        self.name = name
    def to_string(self) -> str:
        return f"User({self.name})"

class Product:
    def __init__(self, name: str, price: float):
        self.name = name
        self.price = price
    def to_string(self) -> str:
        return f"Product({self.name}, ${self.price:.2f})"

def print_all(items: list[Printable]) -> None:
    for item in items:
        print(item.to_string())

# Works because User and Product both have to_string()
print_all([User("Alice"), Product("Widget", 9.99)])

# BUT: mypy checks this, Python runtime does NOT enforce it
# print_all([42])  # mypy warns, but Python runs it and crashes

Rust Trait (Equivalent, but enforced!)

#![allow(unused)]
fn main() {
// Rust — traits are enforced at compile time
trait Printable {
    fn to_string(&self) -> String;
}

struct User { name: String }
struct Product { name: String, price: f64 }

impl Printable for User {
    fn to_string(&self) -> String {
        format!("User({})", self.name)
    }
}

impl Printable for Product {
    fn to_string(&self) -> String {
        format!("Product({}, ${:.2})", self.name, self.price)
    }
}

fn print_all(items: &[&dyn Printable]) {
    for item in items {
        println!("{}", item.to_string());
    }
}

// print_all(&[&42i32]);  // ❌ Compile error: i32 doesn't implement Printable
}

Comparison Table

FeaturePython ProtocolRust Trait
Structural typing✅ (implicit)❌ (explicit impl)
Checked atRuntime (or mypy)Compile time (always)
Default implementations
Can add to foreign types✅ (within limits)
Multiple protocols✅ (multiple traits)
Associated types
Generic constraints✅ (with TypeVar)✅ (trait bounds)

Generic Constraints

Python Generics

# Python — TypeVar for generic functions
from typing import TypeVar, Sequence

T = TypeVar('T')

def first(items: Sequence[T]) -> T | None:
    return items[0] if items else None

# Bounded TypeVar
from typing import SupportsFloat
T = TypeVar('T', bound=SupportsFloat)

def average(items: Sequence[T]) -> float:
    return sum(float(x) for x in items) / len(items)

Rust Generics with Trait Bounds

#![allow(unused)]
fn main() {
// Rust — generics with trait bounds
fn first<T>(items: &[T]) -> Option<&T> {
    items.first()
}

// With trait bounds — "T must implement these traits"
fn average<T>(items: &[T]) -> f64
where
    T: Into<f64> + Copy,   // T must convert to f64 and be copyable
{
    let sum: f64 = items.iter().map(|&x| x.into()).sum();
    sum / items.len() as f64
}

// Multiple bounds — "T must implement Display AND Debug AND Clone"
fn log_and_clone<T: std::fmt::Display + std::fmt::Debug + Clone>(item: &T) -> T {
    println!("Display: {}", item);
    println!("Debug: {:?}", item);
    item.clone()
}

// Shorthand with impl Trait (for simple cases)
fn print_it(item: &impl std::fmt::Display) {
    println!("{}", item);
}
}

Generics Quick Reference

PythonRustNotes
TypeVar('T')<T>Unbounded generic
TypeVar('T', bound=X)<T: X>Bounded generic
Union[int, str]enum or trait objectRust has no union types
Sequence[T]&[T] (slice)Borrowed sequence
Callable[[A], R]Fn(A) -> RFunction trait
Optional[T]Option<T>Built into the language

Common Standard Library Traits

These are Rust’s version of Python’s “dunder methods” — they define how types behave in common situations.

Display and Debug (Printing)

#![allow(unused)]
fn main() {
use std::fmt;

// Debug — like __repr__ (auto-derivable)
#[derive(Debug)]
struct Point { x: f64, y: f64 }
// Now you can: println!("{:?}", point);

// Display — like __str__ (must implement manually)
impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}
// Now you can: println!("{}", point);
}

Comparison Traits

#![allow(unused)]
fn main() {
// PartialEq — like __eq__
// Eq — total equality (f64 is PartialEq but not Eq because NaN != NaN)
// PartialOrd — like __lt__, __le__, etc.
// Ord — total ordering

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
struct Student {
    name: String,
    grade: i32,
}

// Now students can be: compared, sorted, used as HashMap keys, cloned
let mut students = vec![
    Student { name: "Charlie".into(), grade: 85 },
    Student { name: "Alice".into(), grade: 92 },
];
students.sort();  // Uses Ord — sorts by name then grade (struct field order)
}

Iterator Trait

#![allow(unused)]
fn main() {
// Implementing Iterator — like Python's __iter__/__next__
struct Countdown { value: i32 }

impl Iterator for Countdown {
    type Item = i32;       // What the iterator yields

    fn next(&mut self) -> Option<Self::Item> {
        if self.value > 0 {
            self.value -= 1;
            Some(self.value + 1)
        } else {
            None             // Iteration complete
        }
    }
}

// Usage:
for n in (Countdown { value: 5 }) {
    println!("{n}");  // 5, 4, 3, 2, 1
}
}

Common Traits at a Glance

Rust TraitPython EquivalentPurpose
Display__str__Human-readable string
Debug__repr__Debug string (derivable)
Clonecopy.deepcopyDeep copy
Copy(int/float auto-copy)Implicit copy for simple types
PartialEq / Eq__eq__Equality comparison
PartialOrd / Ord__lt__ etc.Ordering
Hash__hash__Hashable (for dict keys)
DefaultDefault __init__Default values
From / Into__init__ overloadsType conversions
Iterator__iter__ / __next__Iteration
Drop__del__ / __exit__Cleanup
Add, Sub, Mul__add__, __sub__, __mul__Operator overloading
Index__getitem__Indexing with []
Deref(no equivalent)Smart pointer dereferencing
Send / Sync(no equivalent)Thread safety markers
flowchart TB
    subgraph Static ["Static Dispatch (impl Trait)"]
        G["fn notify(item: &impl Summary)"] --> M1["Compiled: notify_Article()"]
        G --> M2["Compiled: notify_Tweet()"]
        M1 --> O1["Inlined, zero-cost"]
        M2 --> O2["Inlined, zero-cost"]
    end
    subgraph Dynamic ["Dynamic Dispatch (dyn Trait)"]
        D["fn notify(item: &dyn Summary)"] --> VT["vtable lookup"]
        VT --> I1["Article::summarize()"]
        VT --> I2["Tweet::summarize()"]
    end
    style Static fill:#d4edda
    style Dynamic fill:#fff3cd

Python equivalent: Python always uses dynamic dispatch (getattr at runtime). Rust defaults to static dispatch (monomorphization — the compiler generates specialized code for each concrete type). Use dyn Trait only when you need runtime polymorphism.

📌 See also: Ch. 11 — From/Into Traits covers the conversion traits (From, Into, TryFrom) in depth.

Associated Types

Rust traits can define associated types — type placeholders that each implementor fills in. Python has no equivalent:

#![allow(unused)]
fn main() {
// Iterator defines an associated type 'Item'
trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

struct Countdown { remaining: u32 }

impl Iterator for Countdown {
    type Item = u32;  // This iterator yields u32 values
    fn next(&mut self) -> Option<u32> {
        if self.remaining > 0 {
            self.remaining -= 1;
            Some(self.remaining)
        } else {
            None
        }
    }
}
}

In Python, __iter__ / __next__ return Any — there’s no way to declare “this iterator yields int” and have it enforced (type hints with Iterator[int] are advisory only).

Operator Overloading: __add__impl Add

Python uses magic methods (__add__, __mul__). Rust uses trait implementations — same idea, but type-checked at compile time:

# Python
class Vec2:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __add__(self, other):
        return Vec2(self.x + other.x, self.y + other.y)  # No type checking on 'other'
#![allow(unused)]
fn main() {
use std::ops::Add;

#[derive(Debug, Clone, Copy)]
struct Vec2 { x: f64, y: f64 }

impl Add for Vec2 {
    type Output = Vec2;  // Associated type: what does + return?
    fn add(self, rhs: Vec2) -> Vec2 {
        Vec2 { x: self.x + rhs.x, y: self.y + rhs.y }
    }
}

let a = Vec2 { x: 1.0, y: 2.0 };
let b = Vec2 { x: 3.0, y: 4.0 };
let c = a + b;  // Type-safe: only Vec2 + Vec2 is allowed
}

Key difference: Python’s __add__ accepts any other at runtime (you check types manually or get a TypeError). Rust’s Add trait enforces the operand types at compile time — Vec2 + i32 is a compile error unless you explicitly impl Add<i32> for Vec2.


Exercises

🏋️ Exercise: Generic Summary Trait (click to expand)

Challenge: Define a trait Summary with a method fn summarize(&self) -> String. Implement it for two structs: Article { title: String, body: String } and Tweet { username: String, content: String }. Then write a function fn notify(item: &impl Summary) that prints the summary.

🔑 Solution
trait Summary {
    fn summarize(&self) -> String;
}

struct Article { title: String, body: String }
struct Tweet { username: String, content: String }

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{} — {}...", self.title, &self.body[..20.min(self.body.len())])
    }
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("@{}: {}", self.username, self.content)
    }
}

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

fn main() {
    let article = Article {
        title: "Rust is great".into(),
        body: "Here is why Rust beats Python for systems...".into(),
    };
    let tweet = Tweet {
        username: "rustacean".into(),
        content: "Just shipped my first crate!".into(),
    };
    notify(&article);
    notify(&tweet);
}

Key takeaway: &impl Summary is the Rust equivalent of Python’s Protocol with a summarize method. But Rust checks it at compile time — passing a type that doesn’t implement Summary is a compile error, not a runtime AttributeError.


11. From and Into Traits

Type Conversions in Rust

What you’ll learn: From and Into traits for zero-cost type conversions, TryFrom for fallible conversions, how impl From<A> for B auto-generates Into, and string conversion patterns.

Difficulty: 🟡 Intermediate

Python handles type conversions with constructor calls (int("42"), str(42), float("3.14")). Rust uses the From and Into traits for type-safe conversions.

Python Type Conversion

# Python — explicit constructors for conversion
x = int("42")           # str → int (can raise ValueError)
s = str(42)             # int → str
f = float("3.14")       # str → float
lst = list((1, 2, 3))   # tuple → list

# Custom conversion via __init__ or class methods
class Celsius:
    def __init__(self, temp: float):
        self.temp = temp

    @classmethod
    def from_fahrenheit(cls, f: float) -> "Celsius":
        return cls((f - 32.0) * 5.0 / 9.0)

c = Celsius.from_fahrenheit(212.0)  # 100.0°C

Rust From/Into

#![allow(unused)]
fn main() {
// Rust — From trait defines conversions
// Implementing From<T> gives you Into<U> automatically!

struct Celsius(f64);
struct Fahrenheit(f64);

impl From<Fahrenheit> for Celsius {
    fn from(f: Fahrenheit) -> Self {
        Celsius((f.0 - 32.0) * 5.0 / 9.0)
    }
}

// Now both work:
let c1 = Celsius::from(Fahrenheit(212.0));    // Explicit From
let c2: Celsius = Fahrenheit(212.0).into();   // Into (automatically derived)

// String conversions:
let s: String = String::from("hello");         // &str → String
let s: String = "hello".to_string();           // Same thing
let s: String = "hello".into();                // Also works (From is implemented)

let num: i64 = 42i32.into();                   // i32 → i64 (lossless, so From exists)
// let small: i32 = 42i64.into();              // ❌ i64 → i32 might lose data — no From

// For fallible conversions, use TryFrom:
let n: Result<i32, _> = "42".parse();          // str → i32 (might fail)
let n: i32 = "42".parse().unwrap();            // Panic if not a number
let n: i32 = "42".parse()?;                    // Propagate error with ?
}

The From/Into Relationship

flowchart LR
    A["impl From&lt;A&gt; for B"] -->|"auto-generates"| B["impl Into&lt;B&gt; for A"]
    C["Celsius::from(Fahrenheit(212.0))"] ---|"same as"| D["Fahrenheit(212.0).into()"]
    style A fill:#d4edda
    style B fill:#d4edda

Rule of thumb: Always implement From, never implement Into directly. Implementing From<A> for B gives you Into<B> for A for free.


When to Use From/Into

#![allow(unused)]
fn main() {
// Implement From<T> for your types to enable ergonomic API design:

#[derive(Debug)]
struct UserId(i64);

impl From<i64> for UserId {
    fn from(id: i64) -> Self {
        UserId(id)
    }
}

// Now functions can accept anything convertible to UserId:
fn find_user(id: impl Into<UserId>) -> Option<String> {
    let user_id = id.into();
    // ... lookup logic
    Some(format!("User #{:?}", user_id))
}

find_user(42i64);              // ✅ i64 auto-converts to UserId
find_user(UserId(42));         // ✅ UserId stays as-is
}

TryFrom — Fallible Conversions

Not all conversions can succeed. Python raises exceptions; Rust uses TryFrom which returns a Result:

# Python — fallible conversions raise exceptions
try:
    port = int("not_a_number")   # ValueError
except ValueError as e:
    print(f"Invalid: {e}")

# Custom validation in __init__
class Port:
    def __init__(self, value: int):
        if not (1 <= value <= 65535):
            raise ValueError(f"Invalid port: {value}")
        self.value = value

try:
    p = Port(99999)  # ValueError at runtime
except ValueError:
    pass
#![allow(unused)]
fn main() {
use std::num::ParseIntError;

// TryFrom for built-in types
let n: Result<i32, ParseIntError> = "42".try_into();   // Ok(42)
let n: Result<i32, ParseIntError> = "bad".try_into();  // Err(...)

// Custom TryFrom for validation
#[derive(Debug)]
struct Port(u16);

#[derive(Debug)]
enum PortError {
    OutOfRange(u16),
    Zero,
}

impl TryFrom<u16> for Port {
    type Error = PortError;

    fn try_from(value: u16) -> Result<Self, Self::Error> {
        match value {
            0 => Err(PortError::Zero),
            1..=65535 => Ok(Port(value)),
            // Note: u16 max is 65535, so this covers all cases
        }
    }
}

impl std::fmt::Display for PortError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PortError::Zero => write!(f, "port cannot be zero"),
            PortError::OutOfRange(v) => write!(f, "port {v} out of range"),
        }
    }
}

// Usage:
let p: Result<Port, _> = 8080u16.try_into();   // Ok(Port(8080))
let p: Result<Port, _> = 0u16.try_into();       // Err(PortError::Zero)
}

Python → Rust mental model: TryFrom = __init__ that validates and can fail. But instead of raising an exception, it returns Result — so callers must handle the error case.


String Conversion Patterns

Strings are the most common source of conversion confusion for Python developers:

#![allow(unused)]
fn main() {
// String → &str (borrowing, free)
let s = String::from("hello");
let r: &str = &s;              // Automatic Deref coercion
let r: &str = s.as_str();     // Explicit

// &str → String (allocating, costs memory)
let r: &str = "hello";
let s1 = String::from(r);     // From trait
let s2 = r.to_string();       // ToString trait (via Display)
let s3: String = r.into();    // Into trait

// Number → String
let s = 42.to_string();       // "42" — like Python's str(42)
let s = format!("{:.2}", 3.14); // "3.14" — like Python's f"{3.14:.2f}"

// String → Number
let n: i32 = "42".parse().unwrap();       // like Python's int("42")
let f: f64 = "3.14".parse().unwrap();     // like Python's float("3.14")

// Custom types → String (implement Display)
use std::fmt;

struct Point { x: f64, y: f64 }

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

let p = Point { x: 1.0, y: 2.0 };
println!("{p}");                // (1, 2) — like Python's __str__
let s = p.to_string();         // Also works! Display gives you ToString for free.
}

Conversion Quick Reference

PythonRustNotes
str(x)x.to_string()Requires Display impl
int("42")"42".parse::<i32>()Returns Result
float("3.14")"3.14".parse::<f64>()Returns Result
list(iter)iter.collect::<Vec<_>>()Type annotation needed
dict(pairs)pairs.collect::<HashMap<_,_>>()Type annotation needed
bool(x)No direct equivalentUse explicit checks
MyClass(x)MyClass::from(x)Implement From<T>
MyClass(x) (validates)MyClass::try_from(x)?Implement TryFrom<T>

Conversion Chains and Error Handling

Real-world code often chains multiple conversions. Compare the approaches:

# Python — chain of conversions with try/except
def parse_config(raw: str) -> tuple[str, int]:
    try:
        host, port_str = raw.split(":")
        port = int(port_str)
        if not (1 <= port <= 65535):
            raise ValueError(f"Bad port: {port}")
        return (host, port)
    except (ValueError, AttributeError) as e:
        raise ConfigError(f"Invalid config: {e}") from e
fn parse_config(raw: &str) -> Result<(String, u16), String> {
    let (host, port_str) = raw
        .split_once(':')
        .ok_or_else(|| "missing ':' separator".to_string())?;

    let port: u16 = port_str
        .parse()
        .map_err(|e| format!("invalid port: {e}"))?;

    if port == 0 {
        return Err("port cannot be zero".to_string());
    }

    Ok((host.to_string(), port))
}

fn main() {
    match parse_config("localhost:8080") {
        Ok((host, port)) => println!("Connecting to {host}:{port}"),
        Err(e) => eprintln!("Config error: {e}"),
    }
}

Key insight: Each ? is a visible exit point. In Python, any line inside try could be the one that throws — in Rust, only lines ending with ? can fail.

📌 See also: Ch. 9 — Error Handling covers Result, ?, and custom error types with thiserror in depth.


Exercises

🏋️ Exercise: Temperature Conversion Library (click to expand)

Challenge: Build a mini temperature conversion library:

  1. Define Celsius(f64), Fahrenheit(f64), and Kelvin(f64) structs
  2. Implement From<Celsius> for Fahrenheit and From<Celsius> for Kelvin
  3. Implement TryFrom<f64> for Kelvin that rejects values below absolute zero (-273.15°C = 0K)
  4. Implement Display for all three types (e.g., "100.00°C")
🔑 Solution
use std::fmt;

struct Celsius(f64);
struct Fahrenheit(f64);
struct Kelvin(f64);

impl From<Celsius> for Fahrenheit {
    fn from(c: Celsius) -> Self {
        Fahrenheit(c.0 * 9.0 / 5.0 + 32.0)
    }
}

impl From<Celsius> for Kelvin {
    fn from(c: Celsius) -> Self {
        Kelvin(c.0 + 273.15)
    }
}

#[derive(Debug)]
struct BelowAbsoluteZero;

impl fmt::Display for BelowAbsoluteZero {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "temperature below absolute zero")
    }
}

impl TryFrom<f64> for Kelvin {
    type Error = BelowAbsoluteZero;

    fn try_from(value: f64) -> Result<Self, Self::Error> {
        if value < 0.0 {
            Err(BelowAbsoluteZero)
        } else {
            Ok(Kelvin(value))
        }
    }
}

impl fmt::Display for Celsius    { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:.2}°C", self.0) } }
impl fmt::Display for Fahrenheit { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:.2}°F", self.0) } }
impl fmt::Display for Kelvin     { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:.2}K",  self.0) } }

fn main() {
    let boiling = Celsius(100.0);
    let f: Fahrenheit = Celsius(100.0).into();
    let k: Kelvin = Celsius(100.0).into();
    println!("{boiling} = {f} = {k}");

    match Kelvin::try_from(-10.0) {
        Ok(k) => println!("{k}"),
        Err(e) => println!("Error: {e}"),
    }
}

Key takeaway: From handles infallible conversions (Celsius→Fahrenheit always works). TryFrom handles fallible ones (negative Kelvin is impossible). Python conflates both in __init__ — Rust makes the distinction explicit in the type system.


12. Closures and Iterators

Rust Closures vs Python Lambdas

What you’ll learn: Multi-line closures (not just one-expression lambdas), Fn/FnMut/FnOnce capture semantics, iterator chains vs list comprehensions, map/filter/fold, and macro_rules! basics.

Difficulty: 🟡 Intermediate

Python Closures and Lambdas

# Python — lambdas are one-expression anonymous functions
double = lambda x: x * 2
result = double(5)  # 10

# Full closures capture variables from enclosing scope:
def make_adder(n):
    def adder(x):
        return x + n    # Captures `n` from outer scope
    return adder

add_5 = make_adder(5)
print(add_5(10))  # 15

# Higher-order functions:
numbers = [1, 2, 3, 4, 5]
doubled = list(map(lambda x: x * 2, numbers))
evens = list(filter(lambda x: x % 2 == 0, numbers))

Rust Closures

#![allow(unused)]
fn main() {
// Rust — closures use |args| body syntax
let double = |x: i32| x * 2;
let result = double(5);  // 10

// Closures capture variables from enclosing scope:
fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
    move |x| x + n    // `move` transfers ownership of `n` into the closure
}

let add_5 = make_adder(5);
println!("{}", add_5(10));  // 15

// Higher-order functions with iterators:
let numbers = vec![1, 2, 3, 4, 5];
let doubled: Vec<i32> = numbers.iter().map(|x| x * 2).collect();
let evens: Vec<i32> = numbers.iter().filter(|&&x| x % 2 == 0).copied().collect();
}

Closure Syntax Comparison

Python:                              Rust:
─────────                            ─────
lambda x: x * 2                      |x| x * 2
lambda x, y: x + y                   |x, y| x + y
lambda: 42                           || 42

# Multi-line
def f(x):                            |x| {
    y = x * 2                            let y = x * 2;
    return y + 1                         y + 1
                                      }

Closure Capture — How Rust Differs

# Python — closures capture by reference (late binding!)
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])  # [2, 2, 2] — surprise! All captured the same `i`

# Fix with default arg trick:
funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs])  # [0, 1, 2]
#![allow(unused)]
fn main() {
// Rust — closures capture correctly (no late-binding gotcha)
let funcs: Vec<Box<dyn Fn() -> i32>> = (0..3)
    .map(|i| Box::new(move || i) as Box<dyn Fn() -> i32>)
    .collect();

let results: Vec<i32> = funcs.iter().map(|f| f()).collect();
println!("{:?}", results);  // [0, 1, 2] — correct!

// `move` captures a COPY of `i` for each closure — no late-binding surprise.
}

Three Closure Traits

#![allow(unused)]
fn main() {
// Rust closures implement one or more of these traits:

// Fn — can be called multiple times, doesn't mutate captures (most common)
fn apply(f: impl Fn(i32) -> i32, x: i32) -> i32 { f(x) }

// FnMut — can be called multiple times, MAY mutate captures
fn apply_mut(mut f: impl FnMut(i32) -> i32, x: i32) -> i32 { f(x) }

// FnOnce — can only be called ONCE (consumes captures)
fn apply_once(f: impl FnOnce() -> String) -> String { f() }

// Python has no equivalent — closures are always Fn-like.
// In Rust, the compiler automatically determines which trait to use.
}

Iterators vs Generators

Python Generators

# Python — generators with yield
def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

# Lazy — values computed on demand
fib = fibonacci()
first_10 = [next(fib) for _ in range(10)]

# Generator expressions — like lazy list comprehensions
squares = (x ** 2 for x in range(1000000))  # No memory allocation
first_5 = [next(squares) for _ in range(5)]

Rust Iterators

#![allow(unused)]
fn main() {
// Rust — Iterator trait (similar concept, different syntax)
struct Fibonacci {
    a: u64,
    b: u64,
}

impl Fibonacci {
    fn new() -> Self {
        Fibonacci { a: 0, b: 1 }
    }
}

impl Iterator for Fibonacci {
    type Item = u64;

    fn next(&mut self) -> Option<Self::Item> {
        let current = self.a;
        self.a = self.b;
        self.b = current + self.b;
        Some(current)
    }
}

// Lazy — values computed on demand (just like Python generators)
let first_10: Vec<u64> = Fibonacci::new().take(10).collect();

// Iterator chains — like generator expressions
let squares: Vec<u64> = (0..1_000_000u64).map(|x| x * x).take(5).collect();
}

Comprehensions vs Iterator Chains

This section maps Python’s comprehension syntax to Rust’s iterator chains.

List Comprehension → map/filter/collect

# Python comprehensions:
squares = [x ** 2 for x in range(10)]
evens = [x for x in range(20) if x % 2 == 0]
names = [user.name for user in users if user.active]
pairs = [(x, y) for x in range(3) for y in range(3)]
flat = [item for sublist in nested for item in sublist]
flowchart LR
    A["Source\n[1,2,3,4,5]"] -->|.iter\(\)| B["Iterator"]
    B -->|.filter\(\|x\| x%2==0\)| C["[2, 4]"]
    C -->|.map\(\|x\| x*x\)| D["[4, 16]"]
    D -->|.collect\(\)| E["Vec&lt;i32&gt;\n[4, 16]"]
    style A fill:#ffeeba
    style E fill:#d4edda

Key insight: Rust iterators are lazy — nothing happens until .collect(). Python’s generators work similarly, but list comprehensions evaluate eagerly.

#![allow(unused)]
fn main() {
// Rust iterator chains:
let squares: Vec<i32> = (0..10).map(|x| x * x).collect();
let evens: Vec<i32> = (0..20).filter(|x| x % 2 == 0).collect();
let names: Vec<&str> = users.iter()
    .filter(|u| u.active)
    .map(|u| u.name.as_str())
    .collect();
let pairs: Vec<(i32, i32)> = (0..3)
    .flat_map(|x| (0..3).map(move |y| (x, y)))
    .collect();
let flat: Vec<i32> = nested.iter()
    .flat_map(|sublist| sublist.iter().copied())
    .collect();
}

Dict Comprehension → collect into HashMap

# Python
word_lengths = {word: len(word) for word in words}
inverted = {v: k for k, v in mapping.items()}
#![allow(unused)]
fn main() {
// Rust
let word_lengths: HashMap<&str, usize> = words.iter()
    .map(|w| (*w, w.len()))
    .collect();
let inverted: HashMap<&V, &K> = mapping.iter()
    .map(|(k, v)| (v, k))
    .collect();
}

Set Comprehension → collect into HashSet

# Python
unique_lengths = {len(word) for word in words}
#![allow(unused)]
fn main() {
// Rust
let unique_lengths: HashSet<usize> = words.iter()
    .map(|w| w.len())
    .collect();
}

Common Iterator Methods

PythonRustNotes
map(f, iter).map(f)Transform each element
filter(f, iter).filter(f)Keep matching elements
sum(iter).sum()Sum all elements
min(iter) / max(iter).min() / .max()Returns Option
any(f(x) for x in iter).any(f)True if any match
all(f(x) for x in iter).all(f)True if all match
enumerate(iter).enumerate()Index + value
zip(a, b)a.zip(b)Pair elements
len(list).count() (consumes!) or .len()Count elements
list(reversed(x)).rev()Reverse iteration
itertools.chain(a, b)a.chain(b)Concatenate iterators
next(iter).next()Get next element
next(iter, default).next().unwrap_or(default)With default
list(iter).collect::<Vec<_>>()Materialize into collection
sorted(iter)Collect, then .sort()No lazy sorted iterator
functools.reduce(f, iter).fold(init, f) or .reduce(f)Accumulate

Key Differences

Python iterators:                     Rust iterators:
─────────────────                     ──────────────
- Lazy by default (generators)       - Lazy by default (all iterator chains)
- yield creates generators            - impl Iterator { fn next() }
- StopIteration to end               - None to end
- Can be consumed once               - Can be consumed once
- No type safety                      - Fully type-safe
- Slightly slower (interpreter)       - Zero-cost (compiled away)

Why Macros Exist in Rust

Python has no macro system — it uses decorators, metaclasses, and runtime introspection for metaprogramming. Rust uses macros for compile-time code generation.

Python Metaprogramming vs Rust Macros

# Python — decorators and metaclasses for metaprogramming
from dataclasses import dataclass
from functools import wraps

@dataclass              # Generates __init__, __repr__, __eq__ at import time
class Point:
    x: float
    y: float

# Custom decorator
def log_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@log_calls
def process(data):
    return data.upper()
#![allow(unused)]
fn main() {
// Rust — derive macros and declarative macros for code generation
#[derive(Debug, Clone, PartialEq)]  // Generates Debug, Clone, PartialEq impls at COMPILE time
struct Point {
    x: f64,
    y: f64,
}

// Declarative macro (like a template)
macro_rules! log_call {
    ($func_name:expr, $body:expr) => {
        println!("Calling {}", $func_name);
        $body
    };
}

fn process(data: &str) -> String {
    log_call!("process", data.to_uppercase())
}
}

Common Built-in Macros

#![allow(unused)]
fn main() {
// These macros are used everywhere in Rust:

println!("Hello, {}!", name);           // Print with formatting
format!("Value: {}", x);               // Create formatted String
vec![1, 2, 3];                          // Create a Vec
assert_eq!(2 + 2, 4);                  // Test assertion
assert!(value > 0, "must be positive"); // Boolean assertion
dbg!(expression);                       // Debug print: prints expression AND value
todo!();                                // Placeholder — compiles but panics if reached
unimplemented!();                       // Mark code as unimplemented
panic!("something went wrong");         // Crash with message (like raise RuntimeError)

// Why are these macros instead of functions?
// - println! accepts variable arguments (Rust functions can't)
// - vec! generates code for any type and size
// - assert_eq! knows the SOURCE CODE of what you compared
// - dbg! knows the FILE NAME and LINE NUMBER
}

Writing a Simple Macro with macro_rules!

#![allow(unused)]
fn main() {
// Python dict() equivalent
// Python: d = dict(a=1, b=2)
// Rust:   let d = hashmap!{ "a" => 1, "b" => 2 };

macro_rules! hashmap {
    ($($key:expr => $value:expr),* $(,)?) => {
        {
            let mut map = std::collections::HashMap::new();
            $(map.insert($key, $value);)*
            map
        }
    };
}

let scores = hashmap! {
    "Alice" => 100,
    "Bob" => 85,
    "Charlie" => 90,
};
}

Derive Macros — Auto-Implementing Traits

#![allow(unused)]
fn main() {
// #[derive(...)] is the Rust equivalent of Python's @dataclass decorator

// Python:
// @dataclass(frozen=True, order=True)
// class Student:
//     name: str
//     grade: int

// Rust:
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct Student {
    name: String,
    grade: i32,
}

// Common derive macros:
// Debug         → {:?} formatting (like __repr__)
// Clone         → .clone() deep copy
// Copy          → implicit copy (only for simple types)
// PartialEq, Eq → == comparison (like __eq__)
// PartialOrd, Ord → <, >, sorting (like __lt__ etc.)
// Hash          → usable as HashMap key (like __hash__)
// Default       → MyType::default() (like __init__ with no args)

// Crate-provided derive macros:
// Serialize, Deserialize (serde) → JSON/YAML/TOML serialization
//                                  (like Python's json.dumps/loads but type-safe)
}

Python Decorator vs Rust Derive

Python DecoratorRust DerivePurpose
@dataclass#[derive(Debug, Clone, PartialEq)]Data class
@dataclass(frozen=True)Immutable by defaultImmutability
@dataclass(order=True)#[derive(Ord, PartialOrd)]Comparison/sorting
@total_ordering#[derive(PartialOrd, Ord)]Full ordering
JSON json.dumps(obj.__dict__)#[derive(Serialize)]Serialization
JSON MyClass(**json.loads(s))#[derive(Deserialize)]Deserialization

Exercises

🏋️ Exercise: Derive and Custom Debug (click to expand)

Challenge: Create a User struct with fields name: String, email: String, and password_hash: String. Derive Clone and PartialEq, but implement Debug manually so it prints the name and email but redacts the password (shows "***" instead).

🔑 Solution
use std::fmt;

#[derive(Clone, PartialEq)]
struct User {
    name: String,
    email: String,
    password_hash: String,
}

impl fmt::Debug for User {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("User")
            .field("name", &self.name)
            .field("email", &self.email)
            .field("password_hash", &"***")
            .finish()
    }
}

fn main() {
    let user = User {
        name: "Alice".into(),
        email: "alice@example.com".into(),
        password_hash: "a1b2c3d4e5f6".into(),
    };
    println!("{user:?}");
    // Output: User { name: "Alice", email: "alice@example.com", password_hash: "***" }
}

Key takeaway: Unlike Python’s __repr__, Rust lets you derive Debug for free — but you can override it for sensitive fields. This is safer than Python where print(user) might accidentally leak secrets.


13. Concurrency

No GIL: True Parallelism

What you’ll learn: Why the GIL limits Python concurrency, Rust’s Send/Sync traits for compile-time thread safety, Arc<Mutex<T>> vs Python threading.Lock, channels vs queue.Queue, and async/await differences.

Difficulty: 🔴 Advanced

The GIL (Global Interpreter Lock) is Python’s biggest limitation for CPU-bound work. Rust has no GIL — threads run truly in parallel, and the type system prevents data races at compile time.

gantt
    title CPU-bound Work: Python GIL vs Rust Threads
    dateFormat X
    axisFormat %s
    section Python (GIL)
        Thread 1 :a1, 0, 4
        Thread 2 :a2, 4, 8
        Thread 3 :a3, 8, 12
        Thread 4 :a4, 12, 16
    section Rust (no GIL)
        Thread 1 :b1, 0, 4
        Thread 2 :b2, 0, 4
        Thread 3 :b3, 0, 4
        Thread 4 :b4, 0, 4

Key insight: Python threads run sequentially for CPU work (GIL serializes them). Rust threads run truly in parallel — 4 threads = ~4x speedup.

📌 Prerequisite: Make sure you’re comfortable with Ch. 7 — Ownership and Borrowing before tackling this chapter. Arc, Mutex, and move closures all build on ownership concepts.

Python’s GIL Problem

# Python — threads don't help for CPU-bound work
import threading
import time

counter = 0

def increment(n):
    global counter
    for _ in range(n):
        counter += 1  # NOT thread-safe! But GIL "protects" simple operations

threads = [threading.Thread(target=increment, args=(1_000_000,)) for _ in range(4)]
start = time.perf_counter()
for t in threads:
    t.start()
for t in threads:
    t.join()
elapsed = time.perf_counter() - start

print(f"Counter: {counter}")    # Might not be 4,000,000!
print(f"Time: {elapsed:.2f}s")  # About the SAME as single-threaded (GIL)

# For true parallelism, Python requires multiprocessing:
from multiprocessing import Pool
with Pool(4) as pool:
    results = pool.map(cpu_work, data)  # Separate processes, pickle overhead

Rust — True Parallelism, Compile-Time Safety

use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Arc;
use std::thread;

fn main() {
    let counter = Arc::new(AtomicI64::new(0));

    let handles: Vec<_> = (0..4).map(|_| {
        let counter = Arc::clone(&counter);
        thread::spawn(move || {
            for _ in 0..1_000_000 {
                counter.fetch_add(1, Ordering::Relaxed);
            }
        })
    }).collect();

    for h in handles {
        h.join().unwrap();
    }

    println!("Counter: {}", counter.load(Ordering::Relaxed)); // Always 4,000,000
    // Runs on ALL cores — true parallelism, no GIL
}

Thread Safety: Type System Guarantees

Python — Runtime Errors

# Python — data races caught at runtime (or not at all)
import threading

shared_list = []

def append_items(items):
    for item in items:
        shared_list.append(item)  # "Thread-safe" due to GIL for append
        # But complex operations are NOT safe:
        # if item not in shared_list:
        #     shared_list.append(item)  # RACE CONDITION!

# Using Lock for safety:
lock = threading.Lock()
def safe_append(items):
    for item in items:
        with lock:
            if item not in shared_list:
                shared_list.append(item)
# Forgetting the lock? No compiler warning. Bug discovered in production.

Rust — Compile-Time Errors

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    // Trying to share a Vec across threads without protection:
    // let shared = vec![];
    // thread::spawn(move || shared.push(1));
    // ❌ Compile error: Vec is not Send/Sync without protection

    // With Mutex (Rust's equivalent of threading.Lock):
    let shared = Arc::new(Mutex::new(Vec::new()));

    let handles: Vec<_> = (0..4).map(|i| {
        let shared = Arc::clone(&shared);
        thread::spawn(move || {
            let mut data = shared.lock().unwrap(); // Lock is REQUIRED to access
            data.push(i);
            // Lock is automatically released when `data` goes out of scope
            // No "forgetting to unlock" — RAII guarantees it
        })
    }).collect();

    for h in handles {
        h.join().unwrap();
    }

    println!("{:?}", shared.lock().unwrap()); // [0, 1, 2, 3] (order may vary)
}

Send and Sync Traits

#![allow(unused)]
fn main() {
// Rust uses two marker traits to enforce thread safety:

// Send — "this type can be transferred to another thread"
// Most types are Send. Rc<T> is NOT (use Arc<T> for threads).

// Sync — "this type can be referenced from multiple threads"
// Most types are Sync. Cell<T>/RefCell<T> are NOT (use Mutex<T>).

// The compiler checks these automatically:
// thread::spawn(move || { ... })
//   ↑ The closure's captures must be Send
//   ↑ Shared references must be Sync
//   ↑ If they're not → compile error

// Python has no equivalent. Thread safety bugs are discovered at runtime.
// Rust catches them at compile time. This is "fearless concurrency."
}

Concurrency Primitives Comparison

PythonRustPurpose
threading.Lock()Mutex<T>Mutual exclusion
threading.RLock()Mutex<T> (no reentrant)Reentrant lock (use differently)
threading.RWLock (N/A)RwLock<T>Multiple readers OR one writer
threading.Event()CondvarCondition variable
queue.Queue()mpsc::channel()Thread-safe channel
multiprocessing.Poolrayon::ThreadPoolThread pool
concurrent.futuresrayon / tokio::spawnTask-based parallelism
threading.local()thread_local!Thread-local storage
N/AAtomic* typesLock-free counters and flags

Mutex Poisoning

If a thread panics while holding a Mutex, the lock becomes poisoned. Python has no equivalent — if a thread crashes holding a threading.Lock(), the lock stays stuck.

#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};
use std::thread;

let data = Arc::new(Mutex::new(vec![1, 2, 3]));
let data2 = Arc::clone(&data);

let _ = thread::spawn(move || {
    let mut guard = data2.lock().unwrap();
    guard.push(4);
    panic!("oops!");  // Lock is now poisoned
}).join();

// Subsequent lock attempts return Err(PoisonError)
match data.lock() {
    Ok(guard) => println!("Data: {guard:?}"),
    Err(poisoned) => {
        println!("Lock was poisoned! Recovering...");
        let guard = poisoned.into_inner();
        println!("Recovered: {guard:?}");  // [1, 2, 3, 4]
    }
}
}

Atomic Ordering (brief note)

The Ordering parameter on atomic operations controls memory visibility guarantees:

OrderingWhen to use
RelaxedSimple counters where ordering doesn’t matter
Acquire/ReleaseProducer-consumer: writer uses Release, reader uses Acquire
SeqCstWhen in doubt — strictest ordering, most intuitive

Python’s threading module hides these details behind the GIL. In Rust, you choose explicitly — use SeqCst until profiling shows you need something weaker.


async/await Comparison

Python and Rust both have async/await syntax, but they work very differently under the hood.

Python async/await

# Python — asyncio for concurrent I/O
import asyncio
import aiohttp

async def fetch_url(session, url):
    async with session.get(url) as resp:
        return await resp.text()

async def main():
    urls = ["https://example.com", "https://httpbin.org/get"]

    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url(session, url) for url in urls]
        results = await asyncio.gather(*tasks)

    for url, result in zip(urls, results):
        print(f"{url}: {len(result)} bytes")

asyncio.run(main())

# Python async is single-threaded (still GIL)!
# It only helps with I/O-bound work (waiting for network/disk).
# CPU-bound work in async still blocks the event loop.

Rust async/await

// Rust — tokio for concurrent I/O (and CPU parallelism!)
use reqwest;
use tokio;

async fn fetch_url(url: &str) -> Result<String, reqwest::Error> {
    reqwest::get(url).await?.text().await
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let urls = vec!["https://example.com", "https://httpbin.org/get"];

    let tasks: Vec<_> = urls.iter()
        .map(|url| tokio::spawn(fetch_url(url)))  // No GIL limitation
        .collect();                                 // Can use all CPU cores

    let results = futures::future::join_all(tasks).await;

    for (url, result) in urls.iter().zip(results) {
        match result {
            Ok(Ok(body)) => println!("{url}: {} bytes", body.len()),
            Ok(Err(e)) => println!("{url}: error {e}"),
            Err(e) => println!("{url}: task failed {e}"),
        }
    }

    Ok(())
}

Key Differences

AspectPython asyncioRust tokio
GILStill appliesNo GIL
CPU parallelism❌ Single-threaded✅ Multi-threaded
RuntimeBuilt-in (asyncio)External crate (tokio)
Ecosystemaiohttp, asyncpg, etc.reqwest, sqlx, etc.
PerformanceGood for I/OExcellent for I/O AND CPU
Error handlingExceptionsResult<T, E>
Cancellationtask.cancel()Drop the future
Color problemSync ↔ async boundarySame issue exists

Simple Parallelism with Rayon

# Python — multiprocessing for CPU parallelism
from multiprocessing import Pool

def process_item(item):
    return heavy_computation(item)

with Pool(8) as pool:
    results = pool.map(process_item, items)
#![allow(unused)]
fn main() {
// Rust — rayon for effortless CPU parallelism (one line change!)
use rayon::prelude::*;

// Sequential:
let results: Vec<_> = items.iter().map(|item| heavy_computation(item)).collect();

// Parallel (change .iter() to .par_iter() — that's it!):
let results: Vec<_> = items.par_iter().map(|item| heavy_computation(item)).collect();

// No pickle, no process overhead, no serialization.
// Rayon automatically distributes work across cores.
}

💼 Case Study: Parallel Image Processing Pipeline

A data science team processes 50,000 satellite images nightly. Their Python pipeline uses multiprocessing.Pool:

# Python — multiprocessing for CPU-bound image work
import multiprocessing
from PIL import Image
import numpy as np

def process_image(path: str) -> dict:
    img = np.array(Image.open(path))
    # CPU-intensive: histogram equalization, edge detection, classification
    histogram = np.histogram(img, bins=256)[0]
    edges = detect_edges(img)       # ~200ms per image
    label = classify(edges)          # ~100ms per image
    return {"path": path, "label": label, "edge_count": len(edges)}

# Problem: each subprocess copies the full Python interpreter
# Memory: 50MB per worker × 16 workers = 800MB overhead
# Startup: 2-3 seconds to fork and pickle arguments
with multiprocessing.Pool(16) as pool:
    results = pool.map(process_image, image_paths)  # ~4.5 hours for 50k images

Pain points: 800MB memory overhead from forking, pickle serialization of arguments/results, GIL prevents using threads, error handling is opaque (exceptions in workers are hard to debug).

use rayon::prelude::*;
use image::GenericImageView;

struct ImageResult {
    path: String,
    label: String,
    edge_count: usize,
}

fn process_image(path: &str) -> Result<ImageResult, image::ImageError> {
    let img = image::open(path)?;
    let histogram = compute_histogram(&img);       // ~50ms (no numpy overhead)
    let edges = detect_edges(&img);                // ~40ms (SIMD-optimized)
    let label = classify(&edges);                  // ~20ms
    Ok(ImageResult {
        path: path.to_string(),
        label,
        edge_count: edges.len(),
    })
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let paths: Vec<String> = load_image_paths()?;

    // Rayon automatically uses all CPU cores — no forking, no pickle, no GIL
    let results: Vec<ImageResult> = paths
        .par_iter()                                // Parallel iterator
        .filter_map(|p| process_image(p).ok())     // Skip errors gracefully
        .collect();                                // Collect in parallel

    println!("Processed {} images", results.len());
    Ok(())
}
// 50k images in ~35 minutes (vs 4.5 hours in Python)
// Memory: ~50MB total (shared threads, no forking)

Results:

MetricPython (multiprocessing)Rust (rayon)
Time (50k images)~4.5 hours~35 minutes
Memory overhead800MB (16 workers)~50MB (shared)
Error handlingOpaque pickle errorsResult<T, E> at every step
Startup cost2–3s (fork + pickle)None (threads)

Key lesson: For CPU-bound parallel work, Rust’s threads + rayon replace Python’s multiprocessing with zero serialization overhead, shared memory, and compile-time safety.


Exercises

🏋️ Exercise: Thread-Safe Counter (click to expand)

Challenge: In Python, you might use threading.Lock to protect a shared counter. Translate this to Rust: spawn 10 threads, each incrementing a shared counter 1000 times. Print the final value (should be 10000). Use Arc<Mutex<u64>>.

🔑 Solution
use std::sync::{Arc, Mutex};
use std::thread;

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

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            for _ in 0..1000 {
                let mut num = counter.lock().unwrap();
                *num += 1;
            }
        }));
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Final count: {}", *counter.lock().unwrap());
}

Key takeaway: Arc<Mutex<T>> is Rust’s equivalent of Python’s lock = threading.Lock() + shared variable — but Rust won’t compile if you forget the Arc or Mutex. Python happily runs a racy program and gives you wrong answers silently.


14. Unsafe Rust and FFI

When and Why to Use Unsafe

What you’ll learn: What unsafe permits and why it exists, writing Python extensions with PyO3 (the killer feature for Python devs), Rust’s testing framework vs pytest, mocking with mockall, and benchmarking.

Difficulty: 🔴 Advanced

unsafe in Rust is an escape hatch — it tells the compiler “I’m doing something you can’t verify, but I promise it’s correct.” Python has no equivalent because Python never gives you direct memory access.

flowchart TB
    subgraph Safe ["Safe Rust (99% of code)"]
        S1["Your application logic"]
        S2["pub fn safe_api\(&self\) -> Result"]
    end
    subgraph Unsafe ["unsafe block (minimal, audited)"]
        U1["Raw pointer dereference"]
        U2["FFI call to C/Python"]
    end
    subgraph External ["External (C / Python / OS)"]
        E1["libc / PyO3 / system calls"]
    end
    S1 --> S2
    S2 --> U1
    S2 --> U2
    U1 --> E1
    U2 --> E1
    style Safe fill:#d4edda,stroke:#28a745
    style Unsafe fill:#fff3cd,stroke:#ffc107
    style External fill:#f8d7da,stroke:#dc3545

The pattern: Safe API wraps a small unsafe block. Callers never see unsafe. Python’s ctypes has no such boundary — every FFI call is implicitly unsafe.

📌 See also: Ch. 13 — Concurrency covers Send/Sync traits which are unsafe auto-traits that the compiler checks for thread safety.

What unsafe Allows

// unsafe lets you do FIVE things that safe Rust forbids:
// 1. Dereference raw pointers
// 2. Call unsafe functions/methods
// 3. Access mutable static variables
// 4. Implement unsafe traits
// 5. Access union fields

// Example: calling a C function
extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    // SAFETY: abs() is a well-defined C standard library function.
    let result = unsafe { abs(-42) };  // Safe Rust can't verify C code
    println!("{result}");               // 42
}

When to Use unsafe

#![allow(unused)]
fn main() {
// 1. FFI — calling C libraries (most common reason)
// 2. Performance-critical inner loops (rare)
// 3. Data structures the borrow checker can't express (rare)

// As a Python developer, you'll mostly encounter unsafe in:
// - PyO3 internals (Python ↔ Rust bridge)
// - C library bindings
// - Low-level system calls

// Rule of thumb: if you're writing application code (not library code),
// you should almost never need unsafe. If you think you do, ask in the
// Rust community first — there's usually a safe alternative.
}

PyO3: Rust Extensions for Python

PyO3 is the bridge between Python and Rust. It lets you write Rust functions and classes that are callable from Python — perfect for replacing slow Python hotspots.

Creating a Python Extension in Rust

# Setup
pip install maturin    # Build tool for Rust Python extensions
maturin init           # Creates project structure

# Project structure:
# my_extension/
# ├── Cargo.toml
# ├── pyproject.toml
# └── src/
#     └── lib.rs
# Cargo.toml
[package]
name = "my_extension"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]    # Shared library for Python

[dependencies]
pyo3 = { version = "0.22", features = ["extension-module"] }
#![allow(unused)]
fn main() {
// src/lib.rs — Rust functions callable from Python
use pyo3::prelude::*;

/// A fast Fibonacci function written in Rust.
#[pyfunction]
fn fibonacci(n: u64) -> u64 {
    let (mut a, mut b) = (0u64, 1u64);
    for _ in 0..n {
        let temp = b;
        b = a.wrapping_add(b);
        a = temp;
    }
    a
}

/// Find all prime numbers up to n (Sieve of Eratosthenes).
#[pyfunction]
fn primes_up_to(n: usize) -> Vec<usize> {
    let mut is_prime = vec![true; n + 1];
    is_prime[0] = false;
    if n > 0 { is_prime[1] = false; }
    for i in 2..=((n as f64).sqrt() as usize) {
        if is_prime[i] {
            for j in (i * i..=n).step_by(i) {
                is_prime[j] = false;
            }
        }
    }
    (2..=n).filter(|&i| is_prime[i]).collect()
}

/// A Rust class usable from Python.
#[pyclass]
struct Counter {
    value: i64,
}

#[pymethods]
impl Counter {
    #[new]
    fn new(start: i64) -> Self {
        Counter { value: start }
    }

    fn increment(&mut self) {
        self.value += 1;
    }

    fn get_value(&self) -> i64 {
        self.value
    }

    fn __repr__(&self) -> String {
        format!("Counter(value={})", self.value)
    }
}

/// The Python module definition.
#[pymodule]
fn my_extension(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(fibonacci, m)?)?;
    m.add_function(wrap_pyfunction!(primes_up_to, m)?)?;
    m.add_class::<Counter>()?;
    Ok(())
}
}

Using from Python

# Build and install:
maturin develop --release   # Builds and installs into current venv
# Python — use the Rust extension like any Python module
import my_extension

# Call Rust function
result = my_extension.fibonacci(50)
print(result)  # 12586269025 — computed in microseconds

# Use Rust class
counter = my_extension.Counter(0)
counter.increment()
counter.increment()
print(counter.get_value())  # 2
print(counter)              # Counter(value=2)

# Performance comparison:
import time

# Python version
def py_primes(n):
    sieve = [True] * (n + 1)
    for i in range(2, int(n**0.5) + 1):
        if sieve[i]:
            for j in range(i*i, n+1, i):
                sieve[j] = False
    return [i for i in range(2, n+1) if sieve[i]]

start = time.perf_counter()
py_result = py_primes(10_000_000)
py_time = time.perf_counter() - start

start = time.perf_counter()
rs_result = my_extension.primes_up_to(10_000_000)
rs_time = time.perf_counter() - start

print(f"Python: {py_time:.3f}s")    # ~3.5s
print(f"Rust:   {rs_time:.3f}s")    # ~0.05s — 70x faster!
print(f"Same results: {py_result == rs_result}")  # True

PyO3 Quick Reference

Python ConceptPyO3 AttributeNotes
Function#[pyfunction]Exposed to Python
Class#[pyclass]Python-visible class
Method#[pymethods]Methods on a pyclass
__init__#[new]Constructor
__repr__fn __repr__()String representation
__str__fn __str__()Display string
__len__fn __len__()Length
__getitem__fn __getitem__()Indexing
Property#[getter] / #[setter]Attribute access
Static method#[staticmethod]No self
Class method#[classmethod]Takes cls

FFI Safety Patterns

When exposing Rust to Python (via PyO3 or raw C FFI), these rules prevent the most common bugs:

  1. Never let a panic cross the FFI boundary — a Rust panic unwinding into Python (or C) is undefined behavior. PyO3 handles this automatically for #[pyfunction], but raw extern "C" functions need explicit protection:

    #![allow(unused)]
    fn main() {
    #[no_mangle]
    pub extern "C" fn raw_ffi_function() -> i32 {
        match std::panic::catch_unwind(|| {
            // actual logic
            42
        }) {
            Ok(result) => result,
            Err(_) => -1,  // Return error code instead of panicking into C/Python
        }
    }
    }
  2. #[repr(C)] for shared structs — if Python/C reads struct fields directly, you must use #[repr(C)] to guarantee C-compatible layout. If you’re passing opaque pointers (which PyO3 does for #[pyclass]), it’s not needed.

  3. extern "C" — required for raw FFI functions so the calling convention matches what C/Python expects. PyO3’s #[pyfunction] handles this for you.

PyO3 advantage: PyO3 wraps most of these safety concerns for you — panic catching, type conversion, GIL management. Prefer PyO3 over raw FFI unless you have a specific reason not to.


Unit Tests vs pytest

Python Testing with pytest

# test_calculator.py
import pytest
from calculator import add, divide

def test_add():
    assert add(2, 3) == 5

def test_add_negative():
    assert add(-1, 1) == 0

def test_divide():
    assert divide(10, 2) == 5.0

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        divide(1, 0)

# Parameterized tests
@pytest.mark.parametrize("a,b,expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, -1, -2),
    (100, 200, 300),
])
def test_add_parametrized(a, b, expected):
    assert add(a, b) == expected

# Fixtures
@pytest.fixture
def sample_data():
    return [1, 2, 3, 4, 5]

def test_sum(sample_data):
    assert sum(sample_data) == 15
# Running tests
pytest                      # Run all tests
pytest test_calculator.py   # Run one file
pytest -k "test_add"        # Run matching tests
pytest -v                   # Verbose output
pytest --tb=short           # Short tracebacks

Rust Built-in Testing

#![allow(unused)]
fn main() {
// src/calculator.rs — tests live in the SAME file!
fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err("Division by zero".to_string())
    } else {
        Ok(a / b)
    }
}

// Tests go in a #[cfg(test)] module — only compiled during `cargo test`
#[cfg(test)]
mod tests {
    use super::*;  // Import everything from parent module

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

    #[test]
    fn test_add_negative() {
        assert_eq!(add(-1, 1), 0);
    }

    #[test]
    fn test_divide() {
        assert_eq!(divide(10.0, 2.0), Ok(5.0));
    }

    #[test]
    fn test_divide_by_zero() {
        assert!(divide(1.0, 0.0).is_err());
    }

    // Test that something panics (like pytest.raises)
    #[test]
    #[should_panic(expected = "out of bounds")]
    fn test_out_of_bounds() {
        let v = vec![1, 2, 3];
        let _ = v[99];  // Panics
    }
}
}
# Running tests
cargo test                         # Run all tests
cargo test test_add                # Run matching tests
cargo test -- --nocapture          # Show println! output
cargo test -p my_crate             # Test one crate in workspace
cargo test -- --test-threads=1     # Sequential (for tests with side effects)

Testing Quick Reference

pytestRustNotes
assert x == yassert_eq!(x, y)Equality
assert x != yassert_ne!(x, y)Inequality
assert conditionassert!(condition)Boolean
assert condition, "msg"assert!(condition, "msg")With message
pytest.raises(E)#[should_panic]Expect panic
@pytest.fixtureSetup in test or helper fnNo built-in fixtures
@pytest.mark.parametrizerstest crateParameterized tests
conftest.pytests/common/mod.rsShared test helpers
pytest.skip()#[ignore]Skip a test
tmp_path fixturetempfile crateTemporary directories

Parameterized Tests with rstest

#![allow(unused)]
fn main() {
// Cargo.toml: rstest = "0.23"

use rstest::rstest;

// Like @pytest.mark.parametrize
#[rstest]
#[case(1, 2, 3)]
#[case(0, 0, 0)]
#[case(-1, -1, -2)]
#[case(100, 200, 300)]
fn test_add(#[case] a: i32, #[case] b: i32, #[case] expected: i32) {
    assert_eq!(add(a, b), expected);
}

// Like @pytest.fixture
use rstest::fixture;

#[fixture]
fn sample_data() -> Vec<i32> {
    vec![1, 2, 3, 4, 5]
}

#[rstest]
fn test_sum(sample_data: Vec<i32>) {
    assert_eq!(sample_data.iter().sum::<i32>(), 15);
}
}

Mocking with mockall

# Python — mocking with unittest.mock
from unittest.mock import Mock, patch

def test_fetch_user():
    mock_db = Mock()
    mock_db.get_user.return_value = {"name": "Alice"}

    result = fetch_user_name(mock_db, 1)
    assert result == "Alice"
    mock_db.get_user.assert_called_once_with(1)
#![allow(unused)]
fn main() {
// Rust — mocking with mockall crate
// Cargo.toml: mockall = "0.13"

use mockall::{automock, predicate::*};

#[automock]                          // Generates MockDatabase automatically
trait Database {
    fn get_user(&self, id: i64) -> Option<User>;
}

fn fetch_user_name(db: &dyn Database, id: i64) -> Option<String> {
    db.get_user(id).map(|u| u.name)
}

#[test]
fn test_fetch_user() {
    let mut mock = MockDatabase::new();
    mock.expect_get_user()
        .with(eq(1))                   // assert_called_with(1)
        .times(1)                      // assert_called_once
        .returning(|_| Some(User { name: "Alice".into() }));

    let result = fetch_user_name(&mock, 1);
    assert_eq!(result, Some("Alice".to_string()));
}
}

Exercises

🏋️ Exercise: Safe Wrapper Around Unsafe (click to expand)

Challenge: Write a safe function split_at_mid that takes a &mut [i32] and returns two mutable slices (&mut [i32], &mut [i32]) split at the midpoint. Internally, use unsafe with raw pointers (simulating what split_at_mut does). Then wrap it in a safe API.

🔑 Solution
fn split_at_mid(slice: &mut [i32]) -> (&mut [i32], &mut [i32]) {
    let mid = slice.len() / 2;
    let ptr = slice.as_mut_ptr();
    let len = slice.len();

    assert!(mid <= len); // Safety check before unsafe

    // SAFETY: mid <= len (asserted above), and ptr comes from a valid &mut slice,
    // so both sub-slices are within bounds and non-overlapping.
    unsafe {
        (
            std::slice::from_raw_parts_mut(ptr, mid),
            std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut data = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mid(&mut data);
    left[0] = 99;
    right[0] = 88;
    println!("left: {left:?}, right: {right:?}");
    // left: [99, 2, 3], right: [88, 5, 6]
}

Key takeaway: The unsafe block is small and guarded by the assert!. The public API is fully safe — callers never see unsafe. This is the Rust pattern: unsafe internals, safe interfaces. Python’s ctypes gives you no such guarantees.


15. Migration Patterns

Common Python Patterns in Rust

What you’ll learn: How to translate dict→struct, class→struct+impl, list comprehension→iterator chain, decorator→trait, and context manager→Drop/RAII. Plus essential crates and an incremental adoption strategy.

Difficulty: 🟡 Intermediate

Dictionary → Struct

# Python — dict as data container (very common)
user = {
    "name": "Alice",
    "age": 30,
    "email": "alice@example.com",
    "active": True,
}
print(user["name"])
#![allow(unused)]
fn main() {
// Rust — struct with named fields
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct User {
    name: String,
    age: i32,
    email: String,
    active: bool,
}

let user = User {
    name: "Alice".into(),
    age: 30,
    email: "alice@example.com".into(),
    active: true,
};
println!("{}", user.name);
}

Context Manager → RAII (Drop)

# Python — context manager for resource cleanup
class FileManager:
    def __init__(self, path):
        self.file = open(path, 'w')

    def __enter__(self):
        return self.file

    def __exit__(self, *args):
        self.file.close()

with FileManager("output.txt") as f:
    f.write("hello")
# File automatically closed when exiting `with`
#![allow(unused)]
fn main() {
// Rust — RAII: Drop trait runs when value goes out of scope
use std::fs::File;
use std::io::Write;

fn write_file() -> std::io::Result<()> {
    let mut file = File::create("output.txt")?;
    file.write_all(b"hello")?;
    Ok(())
    // File automatically closed when `file` goes out of scope
    // No `with` needed — RAII handles it!
}
}

Decorator → Higher-Order Function or Macro

# Python — decorator for timing
import functools, time

def timed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timed
def slow_function():
    time.sleep(1)
#![allow(unused)]
fn main() {
// Rust — no decorators, use wrapper functions or macros
use std::time::Instant;

fn timed<F, R>(name: &str, f: F) -> R
where
    F: FnOnce() -> R,
{
    let start = Instant::now();
    let result = f();
    println!("{} took {:.4?}", name, start.elapsed());
    result
}

// Usage:
let result = timed("slow_function", || {
    std::thread::sleep(std::time::Duration::from_secs(1));
    42
});
}

Iterator Pipeline (Data Processing)

# Python — chain of transformations
import csv
from collections import Counter

def analyze_sales(filename):
    with open(filename) as f:
        reader = csv.DictReader(f)
        sales = [
            row for row in reader
            if float(row["amount"]) > 100
        ]
    by_region = Counter(sale["region"] for sale in sales)
    top_regions = by_region.most_common(5)
    return top_regions
#![allow(unused)]
fn main() {
// Rust — iterator chains with strong types
use std::collections::HashMap;

#[derive(Debug, serde::Deserialize)]
struct Sale {
    region: String,
    amount: f64,
}

fn analyze_sales(filename: &str) -> Vec<(String, usize)> {
    let data = std::fs::read_to_string(filename).unwrap();
    let mut reader = csv::Reader::from_reader(data.as_bytes());

    let mut by_region: HashMap<String, usize> = HashMap::new();
    for sale in reader.deserialize::<Sale>().flatten() {
        if sale.amount > 100.0 {
            *by_region.entry(sale.region).or_insert(0) += 1;
        }
    }

    let mut top: Vec<_> = by_region.into_iter().collect();
    top.sort_by(|a, b| b.1.cmp(&a.1));
    top.truncate(5);
    top
}
}

Global Config / Singleton

# Python — module-level singleton (common pattern)
# config.py
import json

class Config:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            with open("config.json") as f:
                cls._instance.data = json.load(f)
        return cls._instance

config = Config()  # Module-level singleton
#![allow(unused)]
fn main() {
// Rust — OnceLock for lazy static initialization (Rust 1.70+)
use std::sync::OnceLock;
use serde_json::Value;

static CONFIG: OnceLock<Value> = OnceLock::new();

fn get_config() -> &'static Value {
    CONFIG.get_or_init(|| {
        let data = std::fs::read_to_string("config.json")
            .expect("Failed to read config");
        serde_json::from_str(&data)
            .expect("Failed to parse config")
    })
}

// Usage anywhere:
let db_host = get_config()["database"]["host"].as_str().unwrap();
}

Essential Crates for Python Developers

Data Processing & Serialization

TaskPythonRust CrateNotes
JSONjsonserde_jsonType-safe serialization
CSVcsv, pandascsvStreaming, low memory
YAMLpyyamlserde_yamlConfig files
TOMLtomllibtomlConfig files
Data validationpydanticserde + customCompile-time validation
Date/timedatetimechronoFull timezone support
RegexreregexVery fast
UUIDuuiduuidSame concept

Web & Network

TaskPythonRust CrateNotes
HTTP clientrequestsreqwestAsync-first
Web frameworkFastAPI/Flaskaxum / actix-webVery fast
WebSocketwebsocketstokio-tungsteniteAsync
gRPCgrpciotonicFull support
Database (SQL)sqlalchemysqlx / dieselCompile-time checked SQL
Redisredis-pyredisAsync support

CLI & System

TaskPythonRust CrateNotes
CLI argsargparse/clickclapDerive macros
Colored outputcoloramacoloredTerminal colors
Progress bartqdmindicatifSame UX
File watchingwatchdognotifyCross-platform
LoggingloggingtracingStructured, async-ready
Env varsos.environstd::env + dotenvy.env support
Subprocesssubprocessstd::process::CommandBuilt-in
Temp filestempfiletempfileSame name!

Testing

TaskPythonRust CrateNotes
Test frameworkpytestBuilt-in + rstestcargo test
Mockingunittest.mockmockallTrait-based
Property testinghypothesisproptestSimilar API
Snapshot testingsyrupyinstaSnapshot approval
Benchmarkingpytest-benchmarkcriterionStatistical
Code coveragecoverage.pycargo-tarpaulinLLVM-based

Incremental Adoption Strategy

flowchart LR
    A["1️⃣ Profile Python\n(find hotspots)"] --> B["2️⃣ Write Rust Extension\n(PyO3 + maturin)"]
    B --> C["3️⃣ Replace Python Call\n(same API)"]
    C --> D["4️⃣ Expand Gradually\n(more functions)"]
    D --> E{"Full rewrite\nworth it?"}
    E -->|Yes| F["Pure Rust🦀"]
    E -->|No| G["Hybrid🐍+🦀"]
    style A fill:#ffeeba
    style B fill:#fff3cd
    style C fill:#d4edda
    style D fill:#d4edda
    style F fill:#c3e6cb
    style G fill:#c3e6cb

📌 See also: Ch. 14 — Unsafe Rust and FFI covers the low-level FFI details needed for PyO3 bindings.

Step 1: Identify Hotspots

# Profile your Python code first
import cProfile
cProfile.run('main()')  # Find the CPU-intensive functions

# Or use py-spy for sampling profiler:
# py-spy top --pid <python-pid>
# py-spy record -o profile.svg -- python main.py

Step 2: Write Rust Extension for Hotspot

# Create a Rust extension with maturin
cd my_python_project
maturin init --bindings pyo3

# Write the hot function in Rust (see PyO3 section above)
# Build and install:
maturin develop --release

Step 3: Replace Python Call with Rust Call

# Before:
result = python_hot_function(data)  # Slow

# After:
import my_rust_extension
result = my_rust_extension.hot_function(data)  # Fast!

# Same API, same tests, 10-100x faster

Step 4: Expand Gradually

#![allow(unused)]
fn main() {
Week 1-2: Replace one CPU-bound function with Rust
Week 3-4: Replace data parsing/validation layer
Month 2:  Replace core data pipeline
Month 3+: Consider full Rust rewrite if benefits justify it

Key principle: keep Python for orchestration, use Rust for computation.
}

💼 Case Study: Accelerating a Data Pipeline with PyO3

A fintech startup has a Python data pipeline that processes 2GB of daily transaction CSV files. The critical bottleneck is a validation + transformation step:

# Python — the slow part (~12 minutes for 2GB)
import csv
from decimal import Decimal
from datetime import datetime

def validate_and_transform(filepath: str) -> list[dict]:
    results = []
    with open(filepath) as f:
        reader = csv.DictReader(f)
        for row in reader:
            # Parse and validate each field
            amount = Decimal(row["amount"])
            if amount < 0:
                raise ValueError(f"Negative amount: {amount}")
            date = datetime.strptime(row["date"], "%Y-%m-%d")
            category = categorize(row["merchant"])  # String matching, ~50 rules

            results.append({
                "amount_cents": int(amount * 100),
                "date": date.isoformat(),
                "category": category,
                "merchant": row["merchant"].strip().lower(),
            })
    return results
# ~12 minutes for 15M rows. Tried pandas — got to ~8 minutes but 6GB RAM.

Step 1: Profile and identify the hotspot (CSV parsing + Decimal conversion + string matching = 95% of time).

Step 2: Write the Rust extension:

#![allow(unused)]
fn main() {
// src/lib.rs — PyO3 extension
use pyo3::prelude::*;
use pyo3::types::PyList;
use std::fs::File;
use std::io::BufReader;

#[derive(Debug)]
struct Transaction {
    amount_cents: i64,
    date: String,
    category: String,
    merchant: String,
}

fn categorize(merchant: &str) -> &'static str {
    // Aho-Corasick or simple rules — compiled once, blazing fast
    if merchant.contains("amazon") { "shopping" }
    else if merchant.contains("uber") || merchant.contains("lyft") { "transport" }
    else if merchant.contains("starbucks") { "food" }
    else { "other" }
}

#[pyfunction]
fn process_transactions(path: &str) -> PyResult<Vec<(i64, String, String, String)>> {
    let file = File::open(path).map_err(|e| pyo3::exceptions::PyIOError::new_err(e.to_string()))?;
    let mut reader = csv::Reader::from_reader(BufReader::new(file));

    let mut results = Vec::with_capacity(15_000_000); // Pre-allocate

    for record in reader.records() {
        let record = record.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
        let amount_str = &record[0];
        let amount_cents = parse_amount_cents(amount_str)?;  // Custom parser, no Decimal
        let date = &record[1];  // Already in ISO format, just validate
        let merchant = record[2].trim().to_lowercase();
        let category = categorize(&merchant).to_string();

        results.push((amount_cents, date.to_string(), category, merchant));
    }
    Ok(results)
}

#[pymodule]
fn fast_pipeline(_py: Python, m: &PyModule) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(process_transactions, m)?)?;
    Ok(())
}
}

Step 3: Replace one line in Python:

# Before:
results = validate_and_transform("transactions.csv")  # 12 minutes

# After:
import fast_pipeline
results = fast_pipeline.process_transactions("transactions.csv")  # 45 seconds

# Same Python orchestration, same tests, same deployment
# Just one function replaced

Results:

MetricPython (csv + Decimal)Rust (PyO3 + csv crate)
Time (2GB / 15M rows)12 minutes45 seconds
Peak memory6GB (pandas) / 2GB (csv)200MB
Lines changed in Python1 (import + call)
Rust code written~60 lines
Tests passing47/4747/47 (unchanged)

Key lesson: You don’t need to rewrite your whole application. Find the 5% of code that takes 95% of the time, rewrite that in Rust with PyO3, and keep everything else in Python. The team went from “we need to add more servers” to “one server is enough.”


Exercises

🏋️ Exercise: Migration Decision Matrix (click to expand)

Challenge: You have a Python web application with these components. For each one, decide: Keep in Python, Rewrite in Rust, or PyO3 bridge. Justify each choice.

  1. Flask route handlers (request parsing, JSON responses)
  2. Image thumbnail generation (CPU-bound, processes 10k images/day)
  3. Database ORM queries (SQLAlchemy)
  4. CSV parser for 2GB financial files (runs nightly)
  5. Admin dashboard (Jinja2 templates)
🔑 Solution
ComponentDecisionRationale
Flask route handlers🐍 Keep PythonI/O-bound, framework-heavy, low benefit from Rust
Image thumbnail generation🦀 PyO3 bridgeCPU-bound hot path, keep Python API, Rust internals
Database ORM queries🐍 Keep PythonSQLAlchemy is mature, queries are I/O-bound
CSV parser (2GB)🦀 PyO3 bridge or full RustCPU + memory bound, Rust’s zero-copy parsing shines
Admin dashboard🐍 Keep PythonUI/template code, no performance concern

Key takeaway: The migration sweet spot is CPU-bound, performance-critical code that has a clean boundary. Don’t rewrite glue code or I/O-bound handlers — the gains don’t justify the cost.


16. Best Practices

Idiomatic Rust for Python Developers

What you’ll learn: Top 10 habits to build, common pitfalls with fixes, a structured 3-month learning path, the complete Python→Rust “Rosetta Stone” reference table, and recommended learning resources.

Difficulty: 🟡 Intermediate

flowchart LR
    A["🟢 Week 1-2\nFoundations\n'Why won't this compile?'"] --> B["🟡 Week 3-4\nCore Concepts\n'Oh, it's protecting me'"] 
    B --> C["🟡 Month 2\nIntermediate\n'I see why this matters'"] 
    C --> D["🔴 Month 3+\nAdvanced\n'Caught a bug at compile time!'"] 
    D --> E["🏆 Month 6\nFluent\n'Better programmer everywhere'"]
    style A fill:#d4edda
    style B fill:#fff3cd
    style C fill:#fff3cd
    style D fill:#f8d7da
    style E fill:#c3e6cb,stroke:#28a745

Top 10 Habits to Build

  1. Use match on enums instead of if isinstance()

    # Python                              # Rust
    if isinstance(shape, Circle): ...     match shape { Shape::Circle(r) => ... }
    
  2. Let the compiler guide you — Read error messages carefully. Rust’s compiler is the best in any language. It tells you what’s wrong AND how to fix it.

  3. Prefer &str over String in function parameters — Accept the most general type. &str works with both String and string literals.

  4. Use iterators instead of index loops — Iterator chains are more idiomatic and often faster than for i in 0..vec.len().

  5. Embrace Option and Result — Don’t .unwrap() everything. Use ?, map, and_then, unwrap_or_else.

  6. Derive traits liberally#[derive(Debug, Clone, PartialEq)] should be on most structs. It’s free and makes testing easier.

  7. Use cargo clippy religiously — It catches hundreds of style and correctness issues. Treat it like ruff for Rust.

  8. Don’t fight the borrow checker — If you’re fighting it, you’re probably structuring data wrong. Refactor to make ownership clear.

  9. Use enums for state machines — Instead of string flags or booleans, use enums. The compiler ensures you handle every state.

  10. Clone first, optimize later — When learning, use .clone() freely to avoid ownership complexity. Optimize only when profiling shows a need.

Common Mistakes from Python Developers

MistakeWhyFix
.unwrap() everywherePanics at runtimeUse ? or match
String instead of &strUnnecessary allocationUse &str for params
for i in 0..vec.len()Not idiomaticfor item in &vec
Ignoring clippy warningsMiss easy improvementscargo clippy
Too many .clone() callsPerformance overheadRefactor ownership
Giant main() functionHard to testExtract into lib.rs
Not using #[derive()]Re-inventing the wheelDerive common traits
Panicking on errorsNot recoverableReturn Result<T, E>

Performance Comparison

Benchmark: Common Operations

Operation              Python 3.12    Rust (release)    Speedup
─────────────────────  ────────────   ──────────────    ─────────
Fibonacci(40)          ~25s           ~0.3s             ~80x
Sort 10M integers      ~5.2s          ~0.6s             ~9x
JSON parse 100MB       ~8.5s          ~0.4s             ~21x
Regex 1M matches       ~3.1s          ~0.3s             ~10x
HTTP server (req/s)    ~5,000         ~150,000          ~30x
SHA-256 1GB file       ~12s           ~1.2s             ~10x
CSV parse 1M rows      ~4.5s          ~0.2s             ~22x
String concatenation   ~2.1s          ~0.05s            ~42x

Note: Python with C extensions (NumPy, etc.) dramatically narrows the gap for numerical work. These benchmarks compare pure Python vs pure Rust.

Memory Usage

Python:                                 Rust:
─────────                               ─────
- Object header: 28 bytes/object       - No object header
- int: 28 bytes (even for 0)           - i32: 4 bytes, i64: 8 bytes
- str "hello": 54 bytes                - &str "hello": 16 bytes (ptr + len)
- list of 1000 ints: ~36 KB            - Vec<i32>: ~4 KB
  (8 KB pointers + 28 KB int objects)
- dict of 100 items: ~5.5 KB           - HashMap of 100: ~2.4 KB

Total for typical application:
- Python: 50-200 MB baseline           - Rust: 1-5 MB baseline

Common Pitfalls and Solutions

Pitfall 1: “The Borrow Checker Won’t Let Me”

#![allow(unused)]
fn main() {
// Problem: trying to iterate and modify
let mut items = vec![1, 2, 3, 4, 5];
// for item in &items {
//     if *item > 3 { items.push(*item * 2); }  // ❌ Can't borrow mut while borrowed
// }

// Solution 1: collect changes, apply after
let additions: Vec<i32> = items.iter()
    .filter(|&&x| x > 3)
    .map(|&x| x * 2)
    .collect();
items.extend(additions);

// Solution 2: use retain/extend
items.retain(|&x| x <= 3);
}

Pitfall 2: “Too Many String Types”

#![allow(unused)]
fn main() {
// When in doubt:
// - &str for function parameters
// - String for struct fields and return values
// - &str literals ("hello") work everywhere &str is expected

fn process(input: &str) -> String {    // Accept &str, return String
    format!("Processed: {}", input)
}
}

Pitfall 3: “I Miss Python’s Simplicity”

#![allow(unused)]
fn main() {
// Python one-liner:
// result = [x**2 for x in data if x > 0]

// Rust equivalent:
let result: Vec<i32> = data.iter()
    .filter(|&&x| x > 0)
    .map(|&x| x * x)
    .collect();

// It's more verbose, but:
// - Type-safe at compile time
// - 10-100x faster
// - No runtime type errors possible
// - Explicit about memory allocation (.collect())
}

Pitfall 4: “Where’s My REPL?”

#![allow(unused)]
fn main() {
// Rust has no REPL. Instead:
// 1. Use `cargo test` as your REPL — write small tests to try things
// 2. Use Rust Playground (play.rust-lang.org) for quick experiments
// 3. Use `dbg!()` macro for quick debug output
// 4. Use `cargo watch -x test` for auto-running tests on save

#[test]
fn playground() {
    // Use this as your "REPL" — run with `cargo test playground`
    let result = "hello world"
        .split_whitespace()
        .map(|w| w.to_uppercase())
        .collect::<Vec<_>>();
    dbg!(&result);  // Prints: [src/main.rs:5] &result = ["HELLO", "WORLD"]
}
}

Learning Path and Resources

Week 1-2: Foundations

  • Install Rust, set up VS Code with rust-analyzer
  • Complete chapters 1-4 of this guide (types, control flow)
  • Write 5 small programs converting Python scripts to Rust
  • Get comfortable with cargo build, cargo test, cargo clippy

Week 3-4: Core Concepts

  • Complete chapters 5-8 (structs, enums, ownership, modules)
  • Rewrite a Python data processing script in Rust
  • Practice with Option<T> and Result<T, E> until natural
  • Read compiler error messages carefully — they’re teaching you

Month 2: Intermediate

  • Complete chapters 9-12 (error handling, traits, iterators)
  • Build a CLI tool with clap and serde
  • Write a PyO3 extension for a Python project hotspot
  • Practice iterator chains until they feel like comprehensions

Month 3: Advanced

  • Complete chapters 13-16 (concurrency, unsafe, testing)
  • Build a web service with axum and tokio
  • Contribute to an open-source Rust project
  • Read “Programming Rust” (O’Reilly) for deeper understanding
  • The Rust Book: https://doc.rust-lang.org/book/ (official, excellent)
  • Rust by Example: https://doc.rust-lang.org/rust-by-example/ (learn by doing)
  • Rustlings: https://github.com/rust-lang/rustlings (exercises)
  • Rust Playground: https://play.rust-lang.org/ (online compiler)
  • This Week in Rust: https://this-week-in-rust.org/ (newsletter)
  • PyO3 Guide: https://pyo3.rs/ (Python ↔ Rust bridge)
  • Comprehensive Rust (Google): https://google.github.io/comprehensive-rust/

Python → Rust Rosetta Stone

PythonRustChapter
listVec<T>5
dictHashMap<K,V>5
setHashSet<T>5
tuple(T1, T2, ...)5
classstruct + impl5
@dataclass#[derive(...)]5, 12a
Enumenum6
NoneOption<T>6
raise/try/exceptResult<T,E> + ?9
Protocol (PEP 544)trait10
TypeVarGenerics <T>10
__dunder__ methodsTraits (Display, Add, etc.)10
lambda|args| body12
generator yieldimpl Iterator12
list comprehension.map().filter().collect()12
@decoratorHigher-order fn or macro12a, 15
asynciotokio13
threadingstd::thread13
multiprocessingrayon13
unittest.mockmockall14a
pytestcargo test + rstest14a
pip installcargo add8
requirements.txtCargo.lock8
pyproject.tomlCargo.toml8
with (context mgr)Scope-based Drop15
json.dumps/loadsserde_json15

Final Thoughts for Python Developers

#![allow(unused)]
fn main() {
What you'll miss from Python:
- REPL and interactive exploration
- Rapid prototyping speed
- Rich ML/AI ecosystem (PyTorch, etc.)
- "Just works" dynamic typing
- pip install and immediate use

What you'll gain from Rust:
- "If it compiles, it works" confidence
- 10-100x performance improvement
- No more runtime type errors
- No more None/null crashes
- True parallelism (no GIL!)
- Single binary deployment
- Predictable memory usage
- The best compiler error messages in any language

The journey:
Week 1:   "Why does the compiler hate me?"
Week 2:   "Oh, it's actually protecting me from bugs"
Month 1:  "I see why this matters"
Month 2:  "I caught a bug at compile time that would've been a production incident"
Month 3:  "I don't want to go back to untyped code"
Month 6:  "Rust has made me a better programmer in every language"
}

Exercises

🏋️ Exercise: Code Review Checklist (click to expand)

Challenge: Review this Rust code (written by a Python developer) and identify 5 idiomatic improvements:

fn get_name(names: Vec<String>, index: i32) -> String {
    if index >= 0 && (index as usize) < names.len() {
        return names[index as usize].clone();
    } else {
        return String::from("");
    }
}

fn main() {
    let mut result = String::from("");
    let names = vec!["Alice".to_string(), "Bob".to_string()];
    result = get_name(names.clone(), 0);
    println!("{}", result);
}
🔑 Solution

Five improvements:

// 1. Take &[String] not Vec<String> (don't take ownership of the whole vec)
// 2. Use usize for index (not i32 — indices are always non-negative)
// 3. Return Option<&str> instead of empty string (use the type system!)
// 4. Use .get() instead of bounds-checking manually
// 5. Don't clone() in main — pass a reference

fn get_name(names: &[String], index: usize) -> Option<&str> {
    names.get(index).map(|s| s.as_str())
}

fn main() {
    let names = vec!["Alice".to_string(), "Bob".to_string()];
    match get_name(&names, 0) {
        Some(name) => println!("{name}"),
        None => println!("Not found"),
    }
}

Key takeaway: Python habits that hurt in Rust: cloning everything (use borrows), using sentinel values like "" (use Option), taking ownership when borrowing suffices, and using signed integers for indices.


End of Rust for Python Programmers Training Guide

17. Capstone Project: CLI Task Manager

Capstone Project: Build a CLI Task Manager

What you’ll learn: Tie together everything from the course by building a complete Rust CLI application that a Python developer would typically write with argparse + json + pathlib.

Difficulty: 🔴 Advanced

This capstone project exercises concepts from every major chapter:

  • Ch. 3: Types and variables (structs, enums)
  • Ch. 5: Collections (Vec, HashMap)
  • Ch. 6: Enums and pattern matching (task status, commands)
  • Ch. 7: Ownership and borrowing (passing references)
  • Ch. 9: Error handling (Result, ?, custom errors)
  • Ch. 10: Traits (Display, FromStr)
  • Ch. 11: Type conversions (From, TryFrom)
  • Ch. 12: Iterators and closures (filtering, mapping)
  • Ch. 8: Modules (organized project structure)

The Project: rustdo

A command-line task manager (like Python’s todo.txt tools) that stores tasks in a JSON file.

Python Equivalent (what you’d write in Python)

#!/usr/bin/env python3
"""A simple CLI task manager — the Python version."""
import json
import sys
from pathlib import Path
from datetime import datetime
from enum import Enum

TASK_FILE = Path.home() / ".rustdo.json"

class Priority(Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"

class Task:
    def __init__(self, id: int, title: str, priority: Priority, done: bool = False):
        self.id = id
        self.title = title
        self.priority = priority
        self.done = done
        self.created = datetime.now().isoformat()

def load_tasks() -> list[Task]:
    if not TASK_FILE.exists():
        return []
    data = json.loads(TASK_FILE.read_text())
    return [Task(**t) for t in data]

def save_tasks(tasks: list[Task]):
    TASK_FILE.write_text(json.dumps([t.__dict__ for t in tasks], indent=2))

# Commands: add, list, done, remove, stats
# ... (you know how this goes in Python)

Your Rust Implementation

Build this step-by-step. Each step maps to concepts from specific chapters.


Step 1: Define the Data Model (Ch. 3, 6, 10, 11)

#![allow(unused)]
fn main() {
// src/task.rs
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use chrono::Local;

/// Task priority — maps to Python's Priority(Enum)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Priority {
    Low,
    Medium,
    High,
}

// Display trait (Python's __str__)
impl fmt::Display for Priority {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Priority::Low => write!(f, "low"),
            Priority::Medium => write!(f, "medium"),
            Priority::High => write!(f, "high"),
        }
    }
}

// FromStr trait (parsing "high" → Priority::High)
impl FromStr for Priority {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "low" | "l" => Ok(Priority::Low),
            "medium" | "med" | "m" => Ok(Priority::Medium),
            "high" | "h" => Ok(Priority::High),
            other => Err(format!("unknown priority: '{other}' (use low/medium/high)")),
        }
    }
}

/// A single task — maps to Python's Task class
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
    pub id: u32,
    pub title: String,
    pub priority: Priority,
    pub done: bool,
    pub created: String,
}

impl Task {
    pub fn new(id: u32, title: String, priority: Priority) -> Self {
        Self {
            id,
            title,
            priority,
            done: false,
            created: Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
        }
    }
}

impl fmt::Display for Task {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let status = if self.done { "✅" } else { "⬜" };
        let priority_icon = match self.priority {
            Priority::Low => "🟢",
            Priority::Medium => "🟡",
            Priority::High => "🔴",
        };
        write!(f, "{} {} [{}] {} ({})", status, self.id, priority_icon, self.title, self.created)
    }
}
}

Python comparison: In Python you’d use @dataclass + Enum. In Rust, struct + enum + derive macros give you serialization, display, and parsing for free.


Step 2: Storage Layer (Ch. 9, 7)

#![allow(unused)]
fn main() {
// src/storage.rs
use std::fs;
use std::path::PathBuf;
use crate::task::Task;

/// Get the path to the task file (~/.rustdo.json)
fn task_file_path() -> PathBuf {
    let home = dirs::home_dir().expect("Could not determine home directory");
    home.join(".rustdo.json")
}

/// Load tasks from disk — returns empty Vec if file doesn't exist
pub fn load_tasks() -> Result<Vec<Task>, Box<dyn std::error::Error>> {
    let path = task_file_path();
    if !path.exists() {
        return Ok(Vec::new());
    }
    let content = fs::read_to_string(&path)?;  // ? propagates io::Error
    let tasks: Vec<Task> = serde_json::from_str(&content)?;  // ? propagates serde error
    Ok(tasks)
}

/// Save tasks to disk
pub fn save_tasks(tasks: &[Task]) -> Result<(), Box<dyn std::error::Error>> {
    let path = task_file_path();
    let json = serde_json::to_string_pretty(tasks)?;
    fs::write(&path, json)?;
    Ok(())
}
}

Python comparison: Python uses Path.read_text() + json.loads(). Rust uses fs::read_to_string() + serde_json::from_str(). Note the ? — every error is explicit and propagated.


Step 3: Command Enum (Ch. 6)

#![allow(unused)]
fn main() {
// src/command.rs
use crate::task::Priority;

/// All possible commands — one enum variant per action
pub enum Command {
    Add { title: String, priority: Priority },
    List { show_done: bool },
    Done { id: u32 },
    Remove { id: u32 },
    Stats,
    Help,
}

impl Command {
    /// Parse command-line arguments into a Command
    /// (In production, you'd use `clap` — this is educational)
    pub fn parse(args: &[String]) -> Result<Self, String> {
        match args.first().map(|s| s.as_str()) {
            Some("add") => {
                let title = args.get(1)
                    .ok_or("usage: rustdo add <title> [priority]")?
                    .clone();
                let priority = args.get(2)
                    .map(|p| p.parse::<Priority>())
                    .transpose()
                    .map_err(|e| e.to_string())?
                    .unwrap_or(Priority::Medium);
                Ok(Command::Add { title, priority })
            }
            Some("list") => {
                let show_done = args.get(1).map(|s| s == "--all").unwrap_or(false);
                Ok(Command::List { show_done })
            }
            Some("done") => {
                let id: u32 = args.get(1)
                    .ok_or("usage: rustdo done <id>")?
                    .parse()
                    .map_err(|_| "id must be a number")?;
                Ok(Command::Done { id })
            }
            Some("remove") => {
                let id: u32 = args.get(1)
                    .ok_or("usage: rustdo remove <id>")?
                    .parse()
                    .map_err(|_| "id must be a number")?;
                Ok(Command::Remove { id })
            }
            Some("stats") => Ok(Command::Stats),
            _ => Ok(Command::Help),
        }
    }
}
}

Python comparison: Python uses argparse or click. This hand-rolled parser shows how match on enum-like patterns replaces Python’s if/elif chains. For real projects, use the clap crate.


Step 4: Business Logic (Ch. 5, 12, 7)

#![allow(unused)]
fn main() {
// src/actions.rs
use crate::task::{Task, Priority};
use crate::storage;

pub fn add_task(title: String, priority: Priority) -> Result<(), Box<dyn std::error::Error>> {
    let mut tasks = storage::load_tasks()?;
    let next_id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
    let task = Task::new(next_id, title.clone(), priority);
    println!("Added: {task}");
    tasks.push(task);
    storage::save_tasks(&tasks)?;
    Ok(())
}

pub fn list_tasks(show_done: bool) -> Result<(), Box<dyn std::error::Error>> {
    let tasks = storage::load_tasks()?;
    let filtered: Vec<&Task> = tasks.iter()
        .filter(|t| show_done || !t.done)   // Iterator + closure (Ch. 12)
        .collect();

    if filtered.is_empty() {
        println!("No tasks! 🎉");
        return Ok(());
    }

    for task in &filtered {
        println!("  {task}");   // Uses Display trait (Ch. 10)
    }
    println!("\n{} task(s) shown", filtered.len());
    Ok(())
}

pub fn complete_task(id: u32) -> Result<(), Box<dyn std::error::Error>> {
    let mut tasks = storage::load_tasks()?;
    let task = tasks.iter_mut()
        .find(|t| t.id == id)                // Iterator::find (Ch. 12)
        .ok_or(format!("No task with id {id}"))?;
    task.done = true;
    println!("Completed: {task}");
    storage::save_tasks(&tasks)?;
    Ok(())
}

pub fn remove_task(id: u32) -> Result<(), Box<dyn std::error::Error>> {
    let mut tasks = storage::load_tasks()?;
    let len_before = tasks.len();
    tasks.retain(|t| t.id != id);            // Vec::retain (Ch. 5)
    if tasks.len() == len_before {
        return Err(format!("No task with id {id}").into());
    }
    println!("Removed task {id}");
    storage::save_tasks(&tasks)?;
    Ok(())
}

pub fn show_stats() -> Result<(), Box<dyn std::error::Error>> {
    let tasks = storage::load_tasks()?;
    let total = tasks.len();
    let done = tasks.iter().filter(|t| t.done).count();
    let pending = total - done;

    // Group by priority using iterators (Ch. 12)
    let high = tasks.iter().filter(|t| !t.done && t.priority == Priority::High).count();
    let medium = tasks.iter().filter(|t| !t.done && t.priority == Priority::Medium).count();
    let low = tasks.iter().filter(|t| !t.done && t.priority == Priority::Low).count();

    println!("📊 Task Statistics");
    println!("   Total:   {total}");
    println!("   Done:    {done} ✅");
    println!("   Pending: {pending}");
    println!("   🔴 High:   {high}");
    println!("   🟡 Medium: {medium}");
    println!("   🟢 Low:    {low}");
    Ok(())
}
}

Key Rust patterns used: iter().map().max(), iter().filter().collect(), iter_mut().find(), retain(), iter().filter().count(). These replace Python’s list comprehensions, next(x for x in ...), and Counter.


Step 5: Wire It Together (Ch. 8)

// src/main.rs
mod task;
mod storage;
mod command;
mod actions;

use command::Command;

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let command = match Command::parse(&args) {
        Ok(cmd) => cmd,
        Err(e) => {
            eprintln!("Error: {e}");
            std::process::exit(1);
        }
    };

    let result = match command {
        Command::Add { title, priority } => actions::add_task(title, priority),
        Command::List { show_done } => actions::list_tasks(show_done),
        Command::Done { id } => actions::complete_task(id),
        Command::Remove { id } => actions::remove_task(id),
        Command::Stats => actions::show_stats(),
        Command::Help => {
            print_help();
            Ok(())
        }
    };

    if let Err(e) = result {
        eprintln!("Error: {e}");
        std::process::exit(1);
    }
}

fn print_help() {
    println!("rustdo — a task manager for Pythonistas learning Rust\n");
    println!("USAGE:");
    println!("  rustdo add <title> [low|medium|high]   Add a task");
    println!("  rustdo list [--all]                    List pending tasks");
    println!("  rustdo done <id>                       Mark task complete");
    println!("  rustdo remove <id>                     Remove a task");
    println!("  rustdo stats                           Show statistics");
}
graph TD
    CLI["main.rs<br/>(CLI entry)"] --> CMD["command.rs<br/>(parse args)"]
    CMD --> ACT["actions.rs<br/>(business logic)"]
    ACT --> STORE["storage.rs<br/>(JSON persistence)"]
    ACT --> TASK["task.rs<br/>(data model)"]
    STORE --> TASK
    style CLI fill:#d4edda
    style CMD fill:#fff3cd
    style ACT fill:#fff3cd
    style STORE fill:#ffeeba
    style TASK fill:#ffeeba

Step 6: Cargo.toml Dependencies

[package]
name = "rustdo"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = "0.4"
dirs = "5"

Python equivalent: This is your pyproject.toml [project.dependencies]. cargo add serde serde_json chrono dirs is like pip install.


Step 7: Tests (Ch. 14)

#![allow(unused)]
fn main() {
// src/task.rs — add at the bottom
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_priority() {
        assert_eq!("high".parse::<Priority>().unwrap(), Priority::High);
        assert_eq!("H".parse::<Priority>().unwrap(), Priority::High);
        assert_eq!("med".parse::<Priority>().unwrap(), Priority::Medium);
        assert!("invalid".parse::<Priority>().is_err());
    }

    #[test]
    fn task_display() {
        let task = Task::new(1, "Write Rust".to_string(), Priority::High);
        let display = format!("{task}");
        assert!(display.contains("Write Rust"));
        assert!(display.contains("🔴"));
        assert!(display.contains("⬜")); // Not done yet
    }

    #[test]
    fn task_serialization_roundtrip() {
        let task = Task::new(1, "Test".to_string(), Priority::Low);
        let json = serde_json::to_string(&task).unwrap();
        let recovered: Task = serde_json::from_str(&json).unwrap();
        assert_eq!(recovered.title, "Test");
        assert_eq!(recovered.priority, Priority::Low);
    }
}
}

Python equivalent: pytest tests. Run with cargo test instead of pytest. No test discovery magic needed — #[test] marks test functions explicitly.


Stretch Goals

Once you have the basic version working, try these enhancements:

  1. Add clap for argument parsing — Replace the hand-rolled parser with clap’s derive macros:

    #![allow(unused)]
    fn main() {
    #[derive(Parser)]
    enum Command {
        Add { title: String, #[arg(default_value = "medium")] priority: Priority },
        List { #[arg(long)] all: bool },
        Done { id: u32 },
        Remove { id: u32 },
        Stats,
    }
    }
  2. Add colored output — Use the colored crate for terminal colors (like Python’s colorama).

  3. Add due dates — Add an Option<NaiveDate> field and filter overdue tasks.

  4. Add tags/categories — Use Vec<String> for tags and filter with .iter().any().

  5. Make it a library + binary — Split into lib.rs + main.rs so the logic is reusable (Ch. 8 module pattern).


What You Practiced

ChapterConceptWhere It Appeared
Ch. 3Types and variablesTask struct fields, u32, String, bool
Ch. 5CollectionsVec<Task>, retain(), push()
Ch. 6Enums + matchPriority, Command, exhaustive matching
Ch. 7Ownership + borrowing&[Task] vs Vec<Task>, &mut for completion
Ch. 8Modulesmod task; mod storage; mod command; mod actions;
Ch. 9Error handlingResult<T, E>, ? operator, .ok_or()
Ch. 10TraitsDisplay, FromStr, Serialize, Deserialize
Ch. 11From/IntoFromStr for Priority, .into() for error conversion
Ch. 12Iteratorsfilter, map, find, count, collect
Ch. 14Testing#[test], #[cfg(test)], assertion macros

🎓 Congratulations! If you’ve built this project, you’ve used every major Rust concept covered in this book. You’re no longer a Python developer learning Rust — you’re a Rust developer who also knows Python.