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:
| Chapters | Topic | Suggested Time | Checkpoint |
|---|---|---|---|
| 1–4 | Setup, types, control flow | 1 day | You can write a CLI temperature converter in Rust |
| 5–6 | Data structures, enums, pattern matching | 1–2 days | You can define an enum with data and match exhaustively on it |
| 7 | Ownership and borrowing | 1–2 days | You can explain why let s2 = s1 invalidates s1 |
| 8–9 | Modules, error handling | 1 day | You can create a multi-file project that propagates errors with ? |
| 10–12 | Traits, generics, closures, iterators | 1–2 days | You can translate a list comprehension to an iterator chain |
| 13 | Concurrency | 1 day | You can write a thread-safe counter with Arc<Mutex<T>> |
| 14 | Unsafe, PyO3, testing | 1 day | You can call a Rust function from Python via PyO3 |
| 15–16 | Migration, best practices | At your own pace | Reference material — consult as you write real code |
| 17 | Capstone project | 2–3 days | Build 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 🟢
- The Case for Rust for Python Developers
- Common Python Pain Points That Rust Addresses
- When to Choose Rust Over Python
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 🟡
- Idiomatic Rust for Python Developers
- Common Pitfalls and Solutions
- Python→Rust Rosetta Stone
- Learning Path and Resources
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 compilesfibonaccidirectly to a handful of x86add/movinstructions — 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:
- Complementary skills: Rust and Python solve different problems
- PyO3 bridge: Write Rust extensions callable from Python
- Performance understanding: Learn why Python is slow and how to fix hotspots
- Career growth: Systems programming expertise increasingly valuable
- 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
| Concept | Python | Rust | Key Difference |
|---|---|---|---|
| Typing | Dynamic (duck typing) | Static (compile-time) | Errors caught before runtime |
| Memory | Garbage collected (ref counting + cycle GC) | Ownership system | Zero-cost, deterministic cleanup |
| None/null | None anywhere | Option<T> | Compile-time None safety |
| Error handling | raise/try/except | Result<T, E> | Explicit, no hidden control flow |
| Mutability | Everything mutable | Immutable by default | Opt-in to mutation |
| Speed | Interpreted (~10–100x slower) | Compiled (C/C++ speed) | Orders of magnitude faster |
| Concurrency | GIL limits threads | No GIL, Send/Sync traits | True parallelism by default |
| Dependencies | pip install / poetry add | cargo add | Built-in dependency management |
| Build system | setuptools/poetry/hatch | Cargo | Single unified tool |
| Packaging | pyproject.toml | Cargo.toml | Similar declarative config |
| REPL | python interactive | No REPL (use tests/cargo run) | Compile-first workflow |
| Type hints | Optional, not enforced | Required, compiler-enforced | Types 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.
x = [1, 2, 3]; y = x; x.append(4)— What happens in Rust?data = None; print(data.upper())— How does Rust prevent this?import threading; shared = []; threading.Thread(target=shared.append, args=(1,)).start()— What does Rust demand?
🔑 Solution
- Ownership move:
let y = x;movesx—x.push(4)is a compile error. You’d needlet y = x.clone();or borrow withlet y = &x;. - No null:
datacan’t beNoneunless it’sOption<String>. You mustmatchor use.unwrap()/if let— no surpriseNoneTypeerrors. - Send + Sync: The compiler requires
sharedto be wrapped inArc<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
| Purpose | Python | Rust |
|---|---|---|
| Language runtime | python (interpreter) | rustc (compiler, rarely called directly) |
| Package manager | pip / poetry / uv | cargo (built-in) |
| Project config | pyproject.toml | Cargo.toml |
| Lock file | poetry.lock / requirements.txt | Cargo.lock |
| Virtual env | venv / conda | Not needed (deps are per-project) |
| Formatter | black / ruff format | rustfmt (built-in: cargo fmt) |
| Linter | ruff / flake8 / pylint | clippy (built-in: cargo clippy) |
| Type checker | mypy / pyright | Built into compiler (always on) |
| Test runner | pytest | cargo test (built-in) |
| Docs | sphinx / mkdocs | cargo doc (built-in) |
| REPL | python / ipython | None (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, nosetup.pyvssetup.cfgvspyproject.tomlconfusion. JustCargo.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:
- Declares a variable
namewith your name (type&str) - Declares a mutable variable
countstarting at 0 - Uses a
forloop from 1..=5 to incrementcountand print"Hello, {name}! (count: {count})" - After the loop, print whether count is even or odd using a
matchexpression
🔑 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:
letis immutable by default (you needmutto changecount)1..=5is inclusive range (Python’srange(1, 6))matchis an expression that returns a value- No
self, noif __name__ == "__main__"— justfn 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-precisionint,Stringvs&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 selfin the method signature tells you (and the compiler) thatincrementmodifies 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
| Python | Rust | Notes |
|---|---|---|
int (arbitrary precision) | i8, i16, i32, i64, i128, isize | Rust integers have fixed size |
int (unsigned: no separate type) | u8, u16, u32, u64, u128, usize | Explicit unsigned types |
float (64-bit IEEE 754) | f32, f64 | Python only has 64-bit float |
bool | bool | Same concept |
complex | No 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
| Python | Rust | Notes |
|---|---|---|
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
Uniontypes andisinstance()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/elsewithout parentheses (but with braces),loop/while/forvs 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 implicitreturn.
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>vslist,HashMap<K,V>vsdict, 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
Rectangleobject has a 56-byte header + separate heap-allocated float objects. A RustRectangleis 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
| Python | Rust | Purpose |
|---|---|---|
__str__ | impl Display | Human-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 Iterator | Iteration |
__len__ | .len() method | Length |
__enter__/__exit__ | impl Drop | Cleanup (automatic in Rust) |
__init__ | fn new() (convention) | Constructor |
__getitem__ | impl Index | Indexing with [] |
__contains__ | .contains() method | in 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
| Python | Rust | Notes |
|---|---|---|
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 lst | vec.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
| Python | Rust | Notes |
|---|---|---|
d[key] = val | d.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 d | d.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
| Python | Rust | Notes |
|---|---|---|
set() | HashSet<T> | use std::collections::HashSet; |
collections.deque | VecDeque<T> | use std::collections::VecDeque; |
heapq | BinaryHeap<T> | Max-heap by default |
collections.OrderedDict | IndexMap (crate) | HashMap doesn’t preserve order |
sortedcontainers.SortedList | BTreeSet<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
Uniontypes, exhaustivematchvsmatch/case,Option<T>as a compile-time replacement forNone, 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
matchis exhaustive — the compiler verifies you handle every variant. Add a new variant to an enum and the compiler tells you exactly whichmatchblocks need updating. Python’smatchhas 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>andOption<T>are just enums withmatch.
#![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
| Pattern | Python | Rust |
|---|---|---|
| Check if exists | if x is not None: | if let Some(x) = opt { |
| Default value | x or default | opt.unwrap_or(default) |
| Default factory | x or compute() | opt.unwrap_or_else(|| compute()) |
| Transform if exists | f(x) if x else None | opt.map(f) |
| Chain lookups | x and x.attr and x.attr.method() | opt.and_then(|x| x.method()) |
| Crash if None | Not possible to prevent | opt.unwrap() (panic) or opt.expect("msg") |
| Get or raise | x if x else raise | opt.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 Pointer | Python Analogy | Use Case |
|---|---|---|
Box<T> | Normal allocation | Large data, recursive types, trait objects |
Rc<T> | Python’s default refcount | Shared ownership, single-threaded |
Arc<T> | Thread-safe refcount | Shared ownership, multi-threaded |
RefCell<T> | Python’s “just mutate it” | Interior mutability (escape hatch) |
Rc<RefCell<T>> | Python’s normal object model | Shared + 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, useWeak<T>to break reference loops — unlike Python, Rust’sRchas 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:
- Immutable borrow + mutation:
firstborrowsnames, thenpushmutates it. Fix: usefirstbefore pushing. - Move out of Vec:
names[0]tries to move a String out of Vec (not allowed). Fix: borrow with&names[0]. - Function takes ownership:
make_greeting(String)consumes the value. Fix: take&strinstead.
8. Crates and Modules
Rust Modules vs Python Packages
What you’ll learn:
modandusevsimport, 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.rsas__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
| Concept | Python | Rust |
|---|---|---|
| Module = file | ✅ Automatic | Must declare with mod |
| Package = directory | __init__.py | mod.rs |
| Public by default | ✅ Everything | ❌ Private by default |
| Make public | _prefix convention | pub keyword |
| Import syntax | from x import y | use x::y; |
| Wildcard import | from x import * | use x::*; (discouraged) |
| Relative imports | from . import sibling | use super::sibling; |
| Re-export | __all__ or explicit | pub 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 Library | Rust Crate | Purpose |
|---|---|---|
requests | reqwest | HTTP client |
json (stdlib) | serde_json | JSON parsing |
pydantic | serde | Serialization/validation |
pathlib | std::path (stdlib) | Path handling |
os / shutil | std::fs (stdlib) | File operations |
re | regex | Regular expressions |
logging | tracing / log | Logging |
click / argparse | clap | CLI argument parsing |
asyncio | tokio | Async runtime |
datetime | chrono | Date and time |
pytest | Built-in + rstest | Testing |
dataclasses | #[derive(...)] | Data structures |
typing.Protocol | Traits | Structural typing |
subprocess | std::process (stdlib) | Run external commands |
sqlite3 | rusqlite | SQLite |
sqlalchemy | diesel / sqlx | ORM / SQL toolkit |
fastapi | axum / actix-web | Web 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()ispub - Line B: ❌ Compile error —
secret_recipe()is private tokitchen - Line C: ✅ Compiles —
staff::cook()ispub, andcook()can accesssecret_recipe()viasuper::(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>vstry/except, the?operator for concise error propagation, custom error types withthiserror,anyhowfor 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-generatesimpl 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
| Python | Rust | Notes |
|---|---|---|
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 visibly | Always 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:
- Rejects empty strings with error
"empty input" - Parses the string to
u16, mapping the parse error to"invalid number: {original_error}" - 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 withwhereclauses, 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
| Feature | Python Protocol | Rust Trait |
|---|---|---|
| Structural typing | ✅ (implicit) | ❌ (explicit impl) |
| Checked at | Runtime (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
| Python | Rust | Notes |
|---|---|---|
TypeVar('T') | <T> | Unbounded generic |
TypeVar('T', bound=X) | <T: X> | Bounded generic |
Union[int, str] | enum or trait object | Rust has no union types |
Sequence[T] | &[T] (slice) | Borrowed sequence |
Callable[[A], R] | Fn(A) -> R | Function 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 Trait | Python Equivalent | Purpose |
|---|---|---|
Display | __str__ | Human-readable string |
Debug | __repr__ | Debug string (derivable) |
Clone | copy.deepcopy | Deep 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) |
Default | Default __init__ | Default values |
From / Into | __init__ overloads | Type 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 (
getattrat runtime). Rust defaults to static dispatch (monomorphization — the compiler generates specialized code for each concrete type). Usedyn Traitonly 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:
FromandIntotraits for zero-cost type conversions,TryFromfor fallible conversions, howimpl From<A> for Bauto-generatesInto, 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<A> for B"] -->|"auto-generates"| B["impl Into<B> 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 implementIntodirectly. ImplementingFrom<A> for Bgives youInto<B> for Afor 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 returnsResult— 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
| Python | Rust | Notes |
|---|---|---|
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 equivalent | Use 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 insidetrycould 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 withthiserrorin depth.
Exercises
🏋️ Exercise: Temperature Conversion Library (click to expand)
Challenge: Build a mini temperature conversion library:
- Define
Celsius(f64),Fahrenheit(f64), andKelvin(f64)structs - Implement
From<Celsius> for FahrenheitandFrom<Celsius> for Kelvin - Implement
TryFrom<f64> for Kelvinthat rejects values below absolute zero (-273.15°C = 0K) - Implement
Displayfor 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/FnOncecapture semantics, iterator chains vs list comprehensions,map/filter/fold, andmacro_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<i32>\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
| Python | Rust | Notes |
|---|---|---|
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 Decorator | Rust Derive | Purpose |
|---|---|---|
@dataclass | #[derive(Debug, Clone, PartialEq)] | Data class |
@dataclass(frozen=True) | Immutable by default | Immutability |
@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/Synctraits for compile-time thread safety,Arc<Mutex<T>>vs Pythonthreading.Lock, channels vsqueue.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
| Python | Rust | Purpose |
|---|---|---|
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() | Condvar | Condition variable |
queue.Queue() | mpsc::channel() | Thread-safe channel |
multiprocessing.Pool | rayon::ThreadPool | Thread pool |
concurrent.futures | rayon / tokio::spawn | Task-based parallelism |
threading.local() | thread_local! | Thread-local storage |
| N/A | Atomic* types | Lock-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:
| Ordering | When to use |
|---|---|
Relaxed | Simple counters where ordering doesn’t matter |
Acquire/Release | Producer-consumer: writer uses Release, reader uses Acquire |
SeqCst | When 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
| Aspect | Python asyncio | Rust tokio |
|---|---|---|
| GIL | Still applies | No GIL |
| CPU parallelism | ❌ Single-threaded | ✅ Multi-threaded |
| Runtime | Built-in (asyncio) | External crate (tokio) |
| Ecosystem | aiohttp, asyncpg, etc. | reqwest, sqlx, etc. |
| Performance | Good for I/O | Excellent for I/O AND CPU |
| Error handling | Exceptions | Result<T, E> |
| Cancellation | task.cancel() | Drop the future |
| Color problem | Sync ↔ async boundary | Same 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:
| Metric | Python (multiprocessing) | Rust (rayon) |
|---|---|---|
| Time (50k images) | ~4.5 hours | ~35 minutes |
| Memory overhead | 800MB (16 workers) | ~50MB (shared) |
| Error handling | Opaque pickle errors | Result<T, E> at every step |
| Startup cost | 2–3s (fork + pickle) | None (threads) |
Key lesson: For CPU-bound parallel work, Rust’s threads + rayon replace Python’s
multiprocessingwith 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
unsafepermits 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
unsafeblock. Callers never seeunsafe. Python’sctypeshas no such boundary — every FFI call is implicitly unsafe.📌 See also: Ch. 13 — Concurrency covers
Send/Synctraits which areunsafeauto-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 Concept | PyO3 Attribute | Notes |
|---|---|---|
| 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:
-
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 rawextern "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 } } } -
#[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. -
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
| pytest | Rust | Notes |
|---|---|---|
assert x == y | assert_eq!(x, y) | Equality |
assert x != y | assert_ne!(x, y) | Inequality |
assert condition | assert!(condition) | Boolean |
assert condition, "msg" | assert!(condition, "msg") | With message |
pytest.raises(E) | #[should_panic] | Expect panic |
@pytest.fixture | Setup in test or helper fn | No built-in fixtures |
@pytest.mark.parametrize | rstest crate | Parameterized tests |
conftest.py | tests/common/mod.rs | Shared test helpers |
pytest.skip() | #[ignore] | Skip a test |
tmp_path fixture | tempfile crate | Temporary 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
| Task | Python | Rust Crate | Notes |
|---|---|---|---|
| JSON | json | serde_json | Type-safe serialization |
| CSV | csv, pandas | csv | Streaming, low memory |
| YAML | pyyaml | serde_yaml | Config files |
| TOML | tomllib | toml | Config files |
| Data validation | pydantic | serde + custom | Compile-time validation |
| Date/time | datetime | chrono | Full timezone support |
| Regex | re | regex | Very fast |
| UUID | uuid | uuid | Same concept |
Web & Network
| Task | Python | Rust Crate | Notes |
|---|---|---|---|
| HTTP client | requests | reqwest | Async-first |
| Web framework | FastAPI/Flask | axum / actix-web | Very fast |
| WebSocket | websockets | tokio-tungstenite | Async |
| gRPC | grpcio | tonic | Full support |
| Database (SQL) | sqlalchemy | sqlx / diesel | Compile-time checked SQL |
| Redis | redis-py | redis | Async support |
CLI & System
| Task | Python | Rust Crate | Notes |
|---|---|---|---|
| CLI args | argparse/click | clap | Derive macros |
| Colored output | colorama | colored | Terminal colors |
| Progress bar | tqdm | indicatif | Same UX |
| File watching | watchdog | notify | Cross-platform |
| Logging | logging | tracing | Structured, async-ready |
| Env vars | os.environ | std::env + dotenvy | .env support |
| Subprocess | subprocess | std::process::Command | Built-in |
| Temp files | tempfile | tempfile | Same name! |
Testing
| Task | Python | Rust Crate | Notes |
|---|---|---|---|
| Test framework | pytest | Built-in + rstest | cargo test |
| Mocking | unittest.mock | mockall | Trait-based |
| Property testing | hypothesis | proptest | Similar API |
| Snapshot testing | syrupy | insta | Snapshot approval |
| Benchmarking | pytest-benchmark | criterion | Statistical |
| Code coverage | coverage.py | cargo-tarpaulin | LLVM-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:
| Metric | Python (csv + Decimal) | Rust (PyO3 + csv crate) |
|---|---|---|
| Time (2GB / 15M rows) | 12 minutes | 45 seconds |
| Peak memory | 6GB (pandas) / 2GB (csv) | 200MB |
| Lines changed in Python | — | 1 (import + call) |
| Rust code written | — | ~60 lines |
| Tests passing | 47/47 | 47/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.
- Flask route handlers (request parsing, JSON responses)
- Image thumbnail generation (CPU-bound, processes 10k images/day)
- Database ORM queries (SQLAlchemy)
- CSV parser for 2GB financial files (runs nightly)
- Admin dashboard (Jinja2 templates)
🔑 Solution
| Component | Decision | Rationale |
|---|---|---|
| Flask route handlers | 🐍 Keep Python | I/O-bound, framework-heavy, low benefit from Rust |
| Image thumbnail generation | 🦀 PyO3 bridge | CPU-bound hot path, keep Python API, Rust internals |
| Database ORM queries | 🐍 Keep Python | SQLAlchemy is mature, queries are I/O-bound |
| CSV parser (2GB) | 🦀 PyO3 bridge or full Rust | CPU + memory bound, Rust’s zero-copy parsing shines |
| Admin dashboard | 🐍 Keep Python | UI/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
-
Use
matchon enums instead ofif isinstance()# Python # Rust if isinstance(shape, Circle): ... match shape { Shape::Circle(r) => ... } -
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.
-
Prefer
&stroverStringin function parameters — Accept the most general type.&strworks with bothStringand string literals. -
Use iterators instead of index loops — Iterator chains are more idiomatic and often faster than
for i in 0..vec.len(). -
Embrace
OptionandResult— Don’t.unwrap()everything. Use?,map,and_then,unwrap_or_else. -
Derive traits liberally —
#[derive(Debug, Clone, PartialEq)]should be on most structs. It’s free and makes testing easier. -
Use
cargo clippyreligiously — It catches hundreds of style and correctness issues. Treat it likerufffor Rust. -
Don’t fight the borrow checker — If you’re fighting it, you’re probably structuring data wrong. Refactor to make ownership clear.
-
Use enums for state machines — Instead of string flags or booleans, use enums. The compiler ensures you handle every state.
-
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
| Mistake | Why | Fix |
|---|---|---|
.unwrap() everywhere | Panics at runtime | Use ? or match |
| String instead of &str | Unnecessary allocation | Use &str for params |
for i in 0..vec.len() | Not idiomatic | for item in &vec |
| Ignoring clippy warnings | Miss easy improvements | cargo clippy |
Too many .clone() calls | Performance overhead | Refactor ownership |
| Giant main() function | Hard to test | Extract into lib.rs |
Not using #[derive()] | Re-inventing the wheel | Derive common traits |
| Panicking on errors | Not recoverable | Return 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>andResult<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
clapandserde - 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
axumandtokio - Contribute to an open-source Rust project
- Read “Programming Rust” (O’Reilly) for deeper understanding
Recommended Resources
- 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
| Python | Rust | Chapter |
|---|---|---|
list | Vec<T> | 5 |
dict | HashMap<K,V> | 5 |
set | HashSet<T> | 5 |
tuple | (T1, T2, ...) | 5 |
class | struct + impl | 5 |
@dataclass | #[derive(...)] | 5, 12a |
Enum | enum | 6 |
None | Option<T> | 6 |
raise/try/except | Result<T,E> + ? | 9 |
Protocol (PEP 544) | trait | 10 |
TypeVar | Generics <T> | 10 |
__dunder__ methods | Traits (Display, Add, etc.) | 10 |
lambda | |args| body | 12 |
generator yield | impl Iterator | 12 |
| list comprehension | .map().filter().collect() | 12 |
@decorator | Higher-order fn or macro | 12a, 15 |
asyncio | tokio | 13 |
threading | std::thread | 13 |
multiprocessing | rayon | 13 |
unittest.mock | mockall | 14a |
pytest | cargo test + rstest | 14a |
pip install | cargo add | 8 |
requirements.txt | Cargo.lock | 8 |
pyproject.toml | Cargo.toml | 8 |
with (context mgr) | Scope-based Drop | 15 |
json.dumps/loads | serde_json | 15 |
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+derivemacros 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 usesfs::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
argparseorclick. This hand-rolled parser shows howmatchon enum-like patterns replaces Python’s if/elif chains. For real projects, use theclapcrate.
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 ...), andCounter.
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 dirsis likepip 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:
pytesttests. Run withcargo testinstead ofpytest. No test discovery magic needed —#[test]marks test functions explicitly.
Stretch Goals
Once you have the basic version working, try these enhancements:
-
Add
clapfor argument parsing — Replace the hand-rolled parser withclap’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, } } -
Add colored output — Use the
coloredcrate for terminal colors (like Python’scolorama). -
Add due dates — Add an
Option<NaiveDate>field and filter overdue tasks. -
Add tags/categories — Use
Vec<String>for tags and filter with.iter().any(). -
Make it a library + binary — Split into
lib.rs+main.rsso the logic is reusable (Ch. 8 module pattern).
What You Practiced
| Chapter | Concept | Where It Appeared |
|---|---|---|
| Ch. 3 | Types and variables | Task struct fields, u32, String, bool |
| Ch. 5 | Collections | Vec<Task>, retain(), push() |
| Ch. 6 | Enums + match | Priority, Command, exhaustive matching |
| Ch. 7 | Ownership + borrowing | &[Task] vs Vec<Task>, &mut for completion |
| Ch. 8 | Modules | mod task; mod storage; mod command; mod actions; |
| Ch. 9 | Error handling | Result<T, E>, ? operator, .ok_or() |
| Ch. 10 | Traits | Display, FromStr, Serialize, Deserialize |
| Ch. 11 | From/Into | FromStr for Priority, .into() for error conversion |
| Ch. 12 | Iterators | filter, map, find, count, collect |
| Ch. 14 | Testing | #[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.