From 437d6410cde1eea03047d0b2b72c36b05fb946b9 Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sun, 12 Apr 2026 21:45:25 -0400 Subject: [PATCH 1/2] Add Chapter 2: Error Handling (Result, Option, ? operator) Maps Python's try/except/None to Rust's Result, Option, and the ? operator. Covers custom error types, Option chaining, iterator collect into Result, and the LBYL/EAFP/types comparison. 16 passing example tests, 5 exercises with todo!() stubs. Also fixes ch01 Logger exercise: replaced todo!() in Drop impl with a comment-only stub to avoid double-panic abort in the test runner. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 2 + ch01-ownership/exercises/src/lib.rs | 4 +- ch02-error-handling/Cargo.toml | 6 + ch02-error-handling/README.md | 203 ++++++++++++++ ch02-error-handling/exercises/Cargo.toml | 6 + ch02-error-handling/exercises/src/lib.rs | 304 ++++++++++++++++++++ ch02-error-handling/src/lib.rs | 341 +++++++++++++++++++++++ 7 files changed, 865 insertions(+), 1 deletion(-) create mode 100644 ch02-error-handling/Cargo.toml create mode 100644 ch02-error-handling/README.md create mode 100644 ch02-error-handling/exercises/Cargo.toml create mode 100644 ch02-error-handling/exercises/src/lib.rs create mode 100644 ch02-error-handling/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index b8d47cf..cd80752 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,8 @@ resolver = "2" members = [ "ch01-ownership", "ch01-ownership/exercises", + "ch02-error-handling", + "ch02-error-handling/exercises", ] [workspace.package] diff --git a/ch01-ownership/exercises/src/lib.rs b/ch01-ownership/exercises/src/lib.rs index 1682c86..36274c2 100644 --- a/ch01-ownership/exercises/src/lib.rs +++ b/ch01-ownership/exercises/src/lib.rs @@ -142,7 +142,9 @@ impl Logger { impl Drop for Logger { fn drop(&mut self) { - todo!("Push \"logger:closed\" onto self.entries") + // TODO: Push "logger:closed" onto self.entries + // (We can't use todo!() here because panic in Drop aborts the process. + // Replace this comment block with your implementation.) } } diff --git a/ch02-error-handling/Cargo.toml b/ch02-error-handling/Cargo.toml new file mode 100644 index 0000000..e753c22 --- /dev/null +++ b/ch02-error-handling/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "ch02-error-handling" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Chapter 2: Error Handling — Result, Option, and the ? operator" diff --git a/ch02-error-handling/README.md b/ch02-error-handling/README.md new file mode 100644 index 0000000..3f800ef --- /dev/null +++ b/ch02-error-handling/README.md @@ -0,0 +1,203 @@ +# Chapter 2: Error Handling + +## The Big Idea + +Python uses exceptions for errors and `None` for missing values. Both are +invisible in function signatures — you only discover them by reading docs +(or hitting them at runtime). Rust makes errors and absence *part of the +type system*. A function that can fail returns `Result`. A function +that might have nothing returns `Option`. The compiler won't let you +ignore either one. + +This isn't just syntax sugar — it changes how you think about error paths. +In Python, error handling is something you bolt on after the fact. In Rust, +it's part of the design from the start. + +## Python Analogies + +### `Option` = The problem `None` was trying to solve + +```python +# Python: None is a valid value for any variable +def find_user(user_id): + if user_id in database: + return database[user_id] + return None + +user = find_user(42) +print(user.name) # AttributeError if user is None — runtime crash! +``` + +```rust +// Rust: Option forces you to handle the None case +fn find_user(user_id: u64) -> Option { + database.get(&user_id).cloned() +} + +let user = find_user(42); +// user.name // compile error! user is Option, not User + +// You must unwrap it explicitly: +match user { + Some(u) => println!("{}", u.name), + None => println!("User not found"), +} +``` + +**Key insight:** Python's `None` is a billion-dollar mistake (Tony Hoare's +words). Any variable can be `None`, and nothing forces you to check. Rust's +`Option` is a type — if a function returns `Option`, you *must* +handle the `None` case before you can use the `User`. The compiler enforces +what Python hopes you'll remember. + +### `Result` = `try/except` but visible in the signature + +```python +# Python: you can't tell from the signature that this function raises +def parse_config(path): + with open(path) as f: # might raise FileNotFoundError + data = json.load(f) # might raise JSONDecodeError + return Config(**data) # might raise TypeError +# Caller has to guess what to catch (or read the source) +``` + +```rust +// Rust: the signature tells you this function can fail, and how +fn parse_config(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; // propagates io::Error + let data: Value = serde_json::from_str(&content)?; // propagates json Error + Config::from_value(data) // returns Result +} +// Caller knows exactly what can go wrong — it's in the type +``` + +**Key insight:** Python's exception system is powerful but invisible. Any +function can raise anything. Rust's `Result` makes failure a +first-class part of the return type. You can't accidentally ignore an error +because the compiler won't let you use the success value without handling +the error case first. + +### The `?` operator = Python's implicit exception propagation, but explicit + +```python +# Python: exceptions propagate automatically up the call stack +def load_settings(): + config = parse_config("settings.json") # if this raises, it bubbles up + return config.settings # caller never sees this line +``` + +```rust +// Rust: the ? operator propagates errors explicitly +fn load_settings() -> Result { + let config = parse_config("settings.json")?; // ? = "if Err, return it" + Ok(config.settings) +} +``` + +**Key insight:** In Python, every function call is an implicit `?` — errors +always propagate unless you catch them. In Rust, propagation is opt-in with +`?`. This means you can see *exactly* which calls in a function might cause +it to return early. No hidden control flow. + +### LBYL vs EAFP — Rust chooses neither (it chooses types) + +Python has two schools of error handling: + +```python +# LBYL: Look Before You Leap +if os.path.exists(path): + with open(path) as f: + data = f.read() +# Problem: file could be deleted between the check and the open (TOCTOU race) + +# EAFP: Easier to Ask Forgiveness than Permission +try: + with open(path) as f: + data = f.read() +except FileNotFoundError: + data = default_data +# Better, but you have to know which exception to catch +``` + +```rust +// Rust: the type system handles it — no LBYL/EAFP debate needed +match std::fs::read_to_string(path) { + Ok(data) => process(data), + Err(e) if e.kind() == ErrorKind::NotFound => use_default(), + Err(e) => return Err(e.into()), // propagate unexpected errors +} +``` + +**Key insight:** LBYL has race conditions. EAFP has invisible error types. +Rust's `match` on `Result` gives you exhaustive handling without either +problem — and the compiler tells you if you missed a case. + +### Custom error types = Custom exception classes + +```python +# Python custom exceptions +class ValidationError(Exception): + def __init__(self, field, message): + self.field = field + self.message = message + super().__init__(f"{field}: {message}") +``` + +```rust +// Rust custom errors — they're just enums +#[derive(Debug)] +enum ValidationError { + MissingField(String), + InvalidValue { field: String, message: String }, + TooLong { field: String, max: usize, actual: usize }, +} + +impl std::fmt::Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingField(name) => write!(f, "missing field: {name}"), + Self::InvalidValue { field, message } => write!(f, "{field}: {message}"), + Self::TooLong { field, max, actual } => + write!(f, "{field}: too long ({actual} > {max})"), + } + } +} +``` + +**Key insight:** Python exceptions are classes in a hierarchy. Rust errors +are enums with variants. The `match` statement on a Rust error enum is +exhaustive — the compiler ensures you handle every variant. Python's +`except` blocks are best-effort — you can always miss one. + +## Summary + +| Python | Rust | What Changes | +|--------|------|-------------| +| `None` (any variable) | `Option` | Absence is a type, not a surprise | +| `try/except` | `Result` | Errors visible in function signatures | +| Implicit propagation | `?` operator | You see where errors can escape | +| LBYL / EAFP debate | `match` on Result | Exhaustive handling, no race conditions | +| Exception class hierarchy | Error enums | Compiler checks exhaustiveness | +| `assert` / `raise` for bugs | `panic!` / `unreachable!` | Unrecoverable = crash, recoverable = Result | + +## The Panic Distinction + +One more thing Python developers need to know: Rust separates +*recoverable* errors from *unrecoverable* ones. + +- **Recoverable**: file not found, invalid input, network timeout → `Result` +- **Unrecoverable**: index out of bounds, violated invariant → `panic!` + +In Python, both are exceptions. In Rust, a `panic!` is a program bug — it +means something happened that *should never happen*. A `Result::Err` is an +expected failure — the system is working correctly by reporting it. + +Don't use `panic!` for things users might do wrong. Don't use `Result` for +things that indicate bugs. This distinction makes Rust programs much more +predictable than Python programs, where `KeyError` might mean "bad user +input" or "bug in your code" depending on context. + +## Next Steps + +Open `src/lib.rs` to see these concepts in working code, then try the +exercises in `exercises/`. diff --git a/ch02-error-handling/exercises/Cargo.toml b/ch02-error-handling/exercises/Cargo.toml new file mode 100644 index 0000000..92b2540 --- /dev/null +++ b/ch02-error-handling/exercises/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "ch02-exercises" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Exercises for Chapter 2: Error Handling" diff --git a/ch02-error-handling/exercises/src/lib.rs b/ch02-error-handling/exercises/src/lib.rs new file mode 100644 index 0000000..4408183 --- /dev/null +++ b/ch02-error-handling/exercises/src/lib.rs @@ -0,0 +1,304 @@ +//! # Chapter 2 Exercises: Error Handling +//! +//! Each exercise shows a Python snippet and asks you to write the Rust +//! equivalent. Replace the `todo!()` markers with working code. +//! +//! Run tests: `cargo test -p ch02-exercises` + +// These allows are intentional: exercise stubs have unused parameters +// and fields until the student fills in the todo!() markers. +#![allow(unused_variables, dead_code, clippy::ptr_arg)] + +use std::fmt; + +// ============================================================ +// Exercise 1: Option Basics +// ============================================================ +// +// Python version: +// ```python +// EXTENSIONS = { +// "rs": "Rust", +// "py": "Python", +// "js": "JavaScript", +// "ts": "TypeScript", +// } +// +// def language_for_extension(ext): +// return EXTENSIONS.get(ext) +// +// assert language_for_extension("rs") == "Rust" +// assert language_for_extension("go") is None +// ``` +// +// Implement a function that returns the language name for a file extension, +// or None if the extension is unknown. Use a match expression. + +pub fn language_for_extension(ext: &str) -> Option<&'static str> { + todo!("Match on ext: rs->Rust, py->Python, js->JavaScript, ts->TypeScript, _->None") +} + +// ============================================================ +// Exercise 2: Option Chaining +// ============================================================ +// +// Python version: +// ```python +// def describe_extension(ext): +// lang = language_for_extension(ext) +// if lang is not None: +// return f"{ext} is a {lang} file" +// return None +// +// assert describe_extension("py") == "py is a Python file" +// assert describe_extension("go") is None +// ``` +// +// Use Option::map to transform the value without unwrapping. + +pub fn describe_extension(ext: &str) -> Option { + todo!("Use language_for_extension and .map() to build the description string") +} + +// ============================================================ +// Exercise 3: Custom Error Type +// ============================================================ +// +// Python version: +// ```python +// class TemperatureError(Exception): pass +// +// def celsius_to_fahrenheit(celsius): +// if celsius < -273.15: +// raise TemperatureError(f"below absolute zero: {celsius}") +// return celsius * 9/5 + 32 +// +// assert celsius_to_fahrenheit(100) == 212.0 +// assert celsius_to_fahrenheit(0) == 32.0 +// # celsius_to_fahrenheit(-300) raises TemperatureError +// ``` +// +// 1. Define a TemperatureError enum with a BelowAbsoluteZero variant +// that carries the invalid value (f64). +// 2. Implement Display for it. +// 3. Implement celsius_to_fahrenheit returning Result. + +#[derive(Debug, PartialEq)] +pub enum TemperatureError { + // todo!(): Add a BelowAbsoluteZero variant that holds an f64 + BelowAbsoluteZero(f64), +} + +impl fmt::Display for TemperatureError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + todo!("Display: 'below absolute zero: '") + } +} + +pub fn celsius_to_fahrenheit(celsius: f64) -> Result { + todo!("Return Err if below -273.15, otherwise Ok(fahrenheit)") +} + +// ============================================================ +// Exercise 4: The ? Operator +// ============================================================ +// +// Python version: +// ```python +// def parse_pair(s): +// """Parse 'x,y' into a tuple of floats.""" +// parts = s.split(',') +// if len(parts) != 2: +// raise ValueError(f"expected 'x,y', got: {s}") +// x = float(parts[0]) # might raise ValueError +// y = float(parts[1]) # might raise ValueError +// return (x, y) +// +// assert parse_pair("3.5,7.2") == (3.5, 7.2) +// # parse_pair("oops") raises ValueError +// ``` +// +// Implement parse_pair. Use the provided PairError type and the ? operator +// to propagate errors from split and parse. + +#[derive(Debug, PartialEq)] +pub enum PairError { + BadFormat(String), + BadNumber(String), +} + +impl fmt::Display for PairError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BadFormat(s) => write!(f, "expected 'x,y', got: {s}"), + Self::BadNumber(s) => write!(f, "not a valid number: {s}"), + } + } +} + +pub fn parse_pair(s: &str) -> Result<(f64, f64), PairError> { + todo!("Split on ',', check for exactly 2 parts, parse each as f64") +} + +// ============================================================ +// Exercise 5: Collecting Results from an Iterator +// ============================================================ +// +// Python version: +// ```python +// def parse_scores(lines): +// """Parse 'name:score' lines into a dict. +// +// Raises ValueError on malformed lines or non-integer scores. +// """ +// result = {} +// for line in lines: +// if ':' not in line: +// raise ValueError(f"missing ':' in: {line}") +// name, score_str = line.split(':', 1) +// score = int(score_str) # raises ValueError if not a number +// result[name] = score +// return result +// +// assert parse_scores(["alice:95", "bob:87"]) == {"alice": 95, "bob": 87} +// # parse_scores(["alice:95", "bad"]) raises ValueError +// ``` +// +// Implement parse_scores using iterators and .collect() to gather +// Result<(String, i32), ScoreError> into Result, ScoreError>. + +#[derive(Debug, PartialEq)] +pub enum ScoreError { + MissingColon(String), + InvalidScore(String), +} + +impl fmt::Display for ScoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingColon(s) => write!(f, "missing ':' in: {s}"), + Self::InvalidScore(s) => write!(f, "invalid score: {s}"), + } + } +} + +pub fn parse_scores(lines: &[&str]) -> Result, ScoreError> { + todo!("Iterate over lines, split each on ':', parse score, collect into Result") +} + +// ============================================================ +// Tests — do not modify below this line +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // Exercise 1 + #[test] + fn ex1_known_extension() { + assert_eq!(language_for_extension("rs"), Some("Rust")); + assert_eq!(language_for_extension("py"), Some("Python")); + assert_eq!(language_for_extension("js"), Some("JavaScript")); + assert_eq!(language_for_extension("ts"), Some("TypeScript")); + } + + #[test] + fn ex1_unknown_extension() { + assert_eq!(language_for_extension("go"), None); + assert_eq!(language_for_extension(""), None); + } + + // Exercise 2 + #[test] + fn ex2_describe_known() { + assert_eq!( + describe_extension("py"), + Some("py is a Python file".to_string()) + ); + } + + #[test] + fn ex2_describe_unknown() { + assert_eq!(describe_extension("go"), None); + } + + // Exercise 3 + #[test] + fn ex3_valid_conversion() { + assert_eq!(celsius_to_fahrenheit(100.0), Ok(212.0)); + assert_eq!(celsius_to_fahrenheit(0.0), Ok(32.0)); + assert_eq!(celsius_to_fahrenheit(-40.0), Ok(-40.0)); // the crossover point! + } + + #[test] + fn ex3_below_absolute_zero() { + assert_eq!( + celsius_to_fahrenheit(-300.0), + Err(TemperatureError::BelowAbsoluteZero(-300.0)) + ); + } + + #[test] + fn ex3_exactly_absolute_zero_is_ok() { + assert!(celsius_to_fahrenheit(-273.15).is_ok()); + } + + #[test] + fn ex3_display() { + let err = TemperatureError::BelowAbsoluteZero(-300.0); + assert_eq!(err.to_string(), "below absolute zero: -300"); + } + + // Exercise 4 + #[test] + fn ex4_valid_pair() { + assert_eq!(parse_pair("3.5,7.2"), Ok((3.5, 7.2))); + } + + #[test] + fn ex4_negative_numbers() { + assert_eq!(parse_pair("-1.5,2.5"), Ok((-1.5, 2.5))); + } + + #[test] + fn ex4_bad_format() { + assert_eq!( + parse_pair("oops"), + Err(PairError::BadFormat("oops".to_string())) + ); + } + + #[test] + fn ex4_bad_number() { + assert!(matches!( + parse_pair("1.0,abc"), + Err(PairError::BadNumber(_)) + )); + } + + // Exercise 5 + #[test] + fn ex5_valid_scores() { + assert_eq!( + parse_scores(&["alice:95", "bob:87"]), + Ok(vec![("alice".to_string(), 95), ("bob".to_string(), 87),]) + ); + } + + #[test] + fn ex5_missing_colon() { + assert_eq!( + parse_scores(&["alice:95", "bad"]), + Err(ScoreError::MissingColon("bad".to_string())) + ); + } + + #[test] + fn ex5_invalid_score() { + assert_eq!( + parse_scores(&["alice:xyz"]), + Err(ScoreError::InvalidScore("xyz".to_string())) + ); + } +} diff --git a/ch02-error-handling/src/lib.rs b/ch02-error-handling/src/lib.rs new file mode 100644 index 0000000..75695c0 --- /dev/null +++ b/ch02-error-handling/src/lib.rs @@ -0,0 +1,341 @@ +//! # Chapter 2: Error Handling +//! +//! This module demonstrates Rust's error handling through examples that +//! map to familiar Python patterns. +//! +//! Run the tests: `cargo test -p ch02-error-handling` + +use std::fmt; +use std::num::ParseIntError; + +// --------------------------------------------------------------------------- +// 1. Option — Rust's answer to None +// --------------------------------------------------------------------------- + +/// Look up a value in a simple in-memory "database." +/// +/// Python equivalent: +/// ```python +/// def find_port(service_name): +/// ports = {"http": 80, "https": 443, "ssh": 22} +/// return ports.get(service_name) # returns None if missing +/// ``` +/// +/// The difference: Python returns None (any variable can be None). +/// Rust returns Option — the type *tells* you it might be absent. +pub fn find_port(service: &str) -> Option { + match service { + "http" => Some(80), + "https" => Some(443), + "ssh" => Some(22), + _ => None, + } +} + +/// Chaining Option operations with `map` and `and_then`. +/// +/// Python equivalent: +/// ```python +/// def port_as_string(service): +/// port = find_port(service) +/// if port is not None: +/// return f":{port}" +/// return None +/// ``` +/// +/// Rust's combinators let you avoid nested if-let / match blocks. +pub fn port_as_string(service: &str) -> Option { + find_port(service).map(|p| format!(":{p}")) +} + +/// Using `unwrap_or` — like Python's `value if value is not None else default`. +/// +/// Python equivalent: +/// ```python +/// def port_or_default(service): +/// return find_port(service) or 8080 +/// ``` +pub fn port_or_default(service: &str) -> u16 { + find_port(service).unwrap_or(8080) +} + +// --------------------------------------------------------------------------- +// 2. Result — Errors as values, not exceptions +// --------------------------------------------------------------------------- + +/// A custom error type — like a Python exception class, but an enum. +/// +/// Python equivalent: +/// ```python +/// class ParseError(Exception): pass +/// class OutOfRange(Exception): +/// def __init__(self, value, min_val, max_val): ... +/// ``` +#[derive(Debug, PartialEq)] +pub enum PortError { + /// The input string couldn't be parsed as a number. + NotANumber(String), + /// The number is outside the valid port range. + OutOfRange { value: i64, min: u16, max: u16 }, +} + +impl fmt::Display for PortError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotANumber(s) => write!(f, "not a valid number: {s}"), + Self::OutOfRange { value, min, max } => { + write!(f, "port {value} out of range ({min}..{max})") + } + } + } +} + +/// Convert a ParseIntError into our custom PortError. +/// +/// This is like Python's `raise PortError(...) from original_exception`. +/// The `From` trait lets the `?` operator do this conversion automatically. +impl From for PortError { + fn from(e: ParseIntError) -> Self { + Self::NotANumber(e.to_string()) + } +} + +/// Parse a string as a valid port number (1-65535). +/// +/// Python equivalent: +/// ```python +/// def parse_port(s): +/// try: +/// value = int(s) +/// except ValueError: +/// raise ParseError(f"not a valid number: {s}") +/// if not (1 <= value <= 65535): +/// raise OutOfRange(value, 1, 65535) +/// return value +/// ``` +/// +/// The Rust version returns Result instead of raising — the caller can see +/// from the type signature that this function can fail. +pub fn parse_port(s: &str) -> Result { + let value: i64 = s + .parse() + .map_err(|_| PortError::NotANumber(s.to_string()))?; + + if !(1..=65535).contains(&value) { + return Err(PortError::OutOfRange { + value, + min: 1, + max: 65535, + }); + } + + Ok(value as u16) +} + +// --------------------------------------------------------------------------- +// 3. The ? operator — explicit propagation +// --------------------------------------------------------------------------- + +/// A network address: host + port. +#[derive(Debug, PartialEq)] +pub struct Address { + pub host: String, + pub port: u16, +} + +/// Parse "host:port" into an Address. +/// +/// Python equivalent: +/// ```python +/// def parse_address(s): +/// if ':' not in s: +/// raise ParseError("missing ':'") +/// host, port_str = s.rsplit(':', 1) +/// port = parse_port(port_str) # raises on bad port — propagates! +/// return Address(host=host, port=port) +/// ``` +/// +/// Notice each `?` in the Rust version. They mark *exactly* where the +/// function might return early with an error. No hidden control flow. +pub fn parse_address(s: &str) -> Result { + let (host, port_str) = s + .rsplit_once(':') + .ok_or_else(|| PortError::NotANumber("missing ':' separator".to_string()))?; + + let port = parse_port(port_str)?; // ? propagates PortError + + Ok(Address { + host: host.to_string(), + port, + }) +} + +// --------------------------------------------------------------------------- +// 4. Combining Option and Result +// --------------------------------------------------------------------------- + +/// Look up a service port, falling back to parsing a custom port string. +/// +/// Python equivalent: +/// ```python +/// def resolve_port(service_or_number): +/// port = find_port(service_or_number) +/// if port is not None: +/// return port +/// return parse_port(service_or_number) # might raise +/// ``` +/// +/// This shows how Option and Result interact: Option for "might not exist" +/// and Result for "might fail with a specific error." +pub fn resolve_port(service_or_number: &str) -> Result { + // If it's a known service, use that + if let Some(port) = find_port(service_or_number) { + return Ok(port); + } + // Otherwise try to parse as a number + parse_port(service_or_number) +} + +// --------------------------------------------------------------------------- +// 5. Iterating with Results — collect into Result +// --------------------------------------------------------------------------- + +/// Parse multiple port strings, failing on the first bad one. +/// +/// Python equivalent: +/// ```python +/// def parse_all_ports(strings): +/// return [parse_port(s) for s in strings] # raises on first bad one +/// ``` +/// +/// Rust's iterator + collect can gather Results into a single Result. +/// This is one of those "Rust lets you express something cleanly that +/// Python can't" moments. +pub fn parse_all_ports(strings: &[&str]) -> Result, PortError> { + strings.iter().map(|s| parse_port(s)).collect() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // Option tests + + #[test] + fn option_some() { + assert_eq!(find_port("http"), Some(80)); + } + + #[test] + fn option_none() { + assert_eq!(find_port("gopher"), None); + } + + #[test] + fn option_map() { + assert_eq!(port_as_string("https"), Some(":443".to_string())); + assert_eq!(port_as_string("gopher"), None); + } + + #[test] + fn option_unwrap_or() { + assert_eq!(port_or_default("http"), 80); + assert_eq!(port_or_default("gopher"), 8080); + } + + // Result tests + + #[test] + fn result_ok() { + assert_eq!(parse_port("443"), Ok(443)); + } + + #[test] + fn result_not_a_number() { + assert_eq!( + parse_port("abc"), + Err(PortError::NotANumber("abc".to_string())) + ); + } + + #[test] + fn result_out_of_range() { + assert_eq!( + parse_port("99999"), + Err(PortError::OutOfRange { + value: 99999, + min: 1, + max: 65535, + }) + ); + } + + #[test] + fn result_zero_is_invalid() { + assert_eq!( + parse_port("0"), + Err(PortError::OutOfRange { + value: 0, + min: 1, + max: 65535, + }) + ); + } + + // ? operator tests + + #[test] + fn address_parse_ok() { + assert_eq!( + parse_address("localhost:8080"), + Ok(Address { + host: "localhost".to_string(), + port: 8080, + }) + ); + } + + #[test] + fn address_bad_port_propagates() { + assert!(parse_address("localhost:abc").is_err()); + } + + #[test] + fn address_missing_colon() { + assert!(parse_address("localhost").is_err()); + } + + // Option + Result interop + + #[test] + fn resolve_known_service() { + assert_eq!(resolve_port("ssh"), Ok(22)); + } + + #[test] + fn resolve_numeric_port() { + assert_eq!(resolve_port("3000"), Ok(3000)); + } + + #[test] + fn resolve_bad_string() { + assert!(resolve_port("not_a_service").is_err()); + } + + // Collecting Results + + #[test] + fn collect_all_ok() { + assert_eq!(parse_all_ports(&["80", "443", "22"]), Ok(vec![80, 443, 22])); + } + + #[test] + fn collect_fails_on_first_bad() { + let result = parse_all_ports(&["80", "bad", "443"]); + assert!(result.is_err()); + } +} From 006de00e8512cb6f951eb6c877ad7c61caee92f3 Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sun, 12 Apr 2026 22:04:22 -0400 Subject: [PATCH 2/2] Add Chapter 3: Traits & Generics Maps Python's ABCs/Protocols/duck typing to Rust's trait system. Covers trait definition and implementation, generic functions with trait bounds, default methods, derived traits, operator overloading (Add, Sub, Display), static vs dynamic dispatch, and multiple trait bounds with where clauses. 16 passing example tests, 5 exercises with todo!() stubs covering HasArea trait, generic largest_area, Display for Temperature, Add for Money, and dynamic dispatch with Renderable. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 2 + ch03-traits-and-generics/Cargo.toml | 6 + ch03-traits-and-generics/README.md | 274 +++++++++++ ch03-traits-and-generics/exercises/Cargo.toml | 6 + ch03-traits-and-generics/exercises/src/lib.rs | 373 ++++++++++++++ ch03-traits-and-generics/src/lib.rs | 456 ++++++++++++++++++ 6 files changed, 1117 insertions(+) create mode 100644 ch03-traits-and-generics/Cargo.toml create mode 100644 ch03-traits-and-generics/README.md create mode 100644 ch03-traits-and-generics/exercises/Cargo.toml create mode 100644 ch03-traits-and-generics/exercises/src/lib.rs create mode 100644 ch03-traits-and-generics/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index cd80752..0e188a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,8 @@ members = [ "ch01-ownership/exercises", "ch02-error-handling", "ch02-error-handling/exercises", + "ch03-traits-and-generics", + "ch03-traits-and-generics/exercises", ] [workspace.package] diff --git a/ch03-traits-and-generics/Cargo.toml b/ch03-traits-and-generics/Cargo.toml new file mode 100644 index 0000000..e9003e7 --- /dev/null +++ b/ch03-traits-and-generics/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "ch03-traits-and-generics" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Chapter 3: Traits & Generics — interfaces that compose" diff --git a/ch03-traits-and-generics/README.md b/ch03-traits-and-generics/README.md new file mode 100644 index 0000000..f3393ff --- /dev/null +++ b/ch03-traits-and-generics/README.md @@ -0,0 +1,274 @@ +# Chapter 3: Traits & Generics + +## The Big Idea + +Python has several ways to define "interfaces": abstract base classes (ABCs), +Protocols (structural typing), and plain duck typing. They all work at +runtime — you find out something doesn't implement the right method when +your program crashes. + +Rust has **traits**: named sets of behaviors that types can implement. Like +Python Protocols, they describe *what a type can do*. Unlike Python +Protocols, the compiler checks them *before your code runs*. Combined with +generics, traits let you write code that works with any type meeting certain +requirements — and the compiler generates specialized, zero-cost versions +for each concrete type. + +## Python Analogies + +### Traits = Protocols (but checked at compile time) + +```python +# Python Protocol (PEP 544) — structural typing +from typing import Protocol + +class Drawable(Protocol): + def draw(self) -> str: ... + +class Circle: + def draw(self) -> str: + return "○" + +class Square: + def draw(self) -> str: + return "□" + +def render(shape: Drawable) -> str: + return shape.draw() # type checker warns if .draw() is missing + # but the code still RUNS — crash at runtime +``` + +```rust +// Rust trait — the compiler enforces it +trait Drawable { + fn draw(&self) -> String; +} + +struct Circle; +struct Square; + +impl Drawable for Circle { + fn draw(&self) -> String { "○".to_string() } +} + +impl Drawable for Square { + fn draw(&self) -> String { "□".to_string() } +} + +fn render(shape: &dyn Drawable) -> String { + shape.draw() // compile error if .draw() is missing — guaranteed safe +} +``` + +**Key insight:** Python Protocols are advisory — mypy can warn you, but the +code runs regardless. Rust traits are mandatory — if a type claims to +implement a trait, the compiler verifies every required method exists and +has the right signature. + +### Generics = Type parameters (but monomorphized) + +```python +# Python generic with TypeVar +from typing import TypeVar, List + +T = TypeVar('T') + +def first(items: List[T]) -> T | None: + return items[0] if items else None + +# At runtime, T is erased — it's just a hint for type checkers +``` + +```rust +// Rust generic — the compiler generates specialized code for each type +fn first(items: &[T]) -> Option<&T> { + items.first() +} + +// When you call first::(&numbers), the compiler generates a +// version of `first` specifically for i32. No runtime cost. +// When you call first::(&names), it generates another version. +``` + +**Key insight:** Python generics are erased at runtime — `List[int]` and +`List[str]` are the same object. Rust generics are *monomorphized* — the +compiler generates specialized machine code for each concrete type. This +means generic Rust code runs as fast as hand-written specialized code. + +### Trait bounds = "T must support these operations" + +```python +# Python: you just hope T has the right methods +def largest(items: List[T]) -> T: + return max(items) # assumes T supports comparison — crashes if not +``` + +```rust +// Rust: the bound says exactly what T must support +fn largest(items: &[T]) -> Option<&T> { + items.iter().reduce(|a, b| if a >= b { a } else { b }) +} +// Won't compile if you call it with a type that can't be compared +``` + +**Key insight:** Python's duck typing means "try it and see." Rust's trait +bounds mean "prove it before we start." The compiler error message tells +you *exactly* which trait is missing. + +### Default methods = Mixin behavior + +```python +# Python: mixins or default method implementations +class Describable: + def name(self) -> str: + raise NotImplementedError + + def describe(self) -> str: + return f"I am {self.name()}" # default implementation using name() +``` + +```rust +// Rust: traits can have default methods +trait Describable { + fn name(&self) -> &str; // required — implementors must provide this + + fn describe(&self) -> String { // default — implementors get this for free + format!("I am {}", self.name()) + } +} +``` + +**Key insight:** This is exactly like Python mixins, but with a guarantee: +the required methods are enforced at compile time. No `NotImplementedError` +at runtime. + +### `impl Trait` = "something that implements this" + +```python +# Python: you write the Protocol in the type hint +def make_shape() -> Drawable: + return Circle() # caller knows it's Drawable, not necessarily Circle +``` + +```rust +// Rust: impl Trait in return position +fn make_shape() -> impl Drawable { + Circle // caller knows it's Drawable, compiler knows it's Circle +} +``` + +### Deriving = Auto-generating trait implementations + +```python +# Python: @dataclass auto-generates __eq__, __repr__, __hash__, etc. +from dataclasses import dataclass + +@dataclass +class Point: + x: float + y: float +# Automatically gets __eq__, __repr__, __init__, etc. +``` + +```rust +// Rust: #[derive] auto-generates trait implementations +#[derive(Debug, Clone, PartialEq)] +struct Point { + x: f64, + y: f64, +} +// Automatically gets Debug (like __repr__), Clone (like copy.deepcopy), +// and PartialEq (like __eq__) +``` + +**Key insight:** Python's `@dataclass` is a single decorator that generates +multiple methods. Rust's `#[derive]` lets you pick exactly which traits to +auto-implement. You get fine-grained control over what your type can do. + +### Operator overloading = `__add__`, `__mul__`, etc. + +```python +class Vector: + def __init__(self, x, y): + self.x = x + self.y = y + + def __add__(self, other): + return Vector(self.x + other.x, self.y + other.y) + + def __repr__(self): + return f"Vector({self.x}, {self.y})" +``` + +```rust +use std::ops::Add; + +#[derive(Debug, Clone, Copy)] +struct Vector { + x: f64, + y: f64, +} + +impl Add for Vector { + type Output = Self; + + fn add(self, other: Self) -> Self { + Self { + x: self.x + other.x, + y: self.y + other.y, + } + } +} +``` + +**Key insight:** Same concept, different mechanism. Python uses magic +methods (`__add__`). Rust uses trait implementations (`impl Add`). The +Rust version is more explicit about what the output type is (it could be +different from the input types). + +## Static vs Dynamic Dispatch + +One concept that doesn't exist in Python: Rust lets you choose between +*static dispatch* (generics, resolved at compile time) and *dynamic +dispatch* (trait objects, resolved at runtime). + +```rust +// Static dispatch — compiler generates specialized code for each type +// Fast (no indirection), but can't mix types in a collection +fn draw_static(shape: &impl Drawable) -> String { + shape.draw() +} + +// Dynamic dispatch — uses a vtable pointer at runtime +// Slight overhead, but can mix different types in a collection +fn draw_dynamic(shape: &dyn Drawable) -> String { + shape.draw() +} + +// Why this matters: you can have a Vec of mixed shapes +let shapes: Vec> = vec![ + Box::new(Circle), + Box::new(Square), +]; +``` + +Python always does dynamic dispatch (everything goes through `__dict__` +lookup). Rust makes you choose — and the default (generics/static) is +zero-cost. + +## Summary + +| Python | Rust | What Changes | +|--------|------|-------------| +| ABCs / Protocols | Traits | Compile-time enforcement | +| `TypeVar` / generics | `` generics | Monomorphized — zero runtime cost | +| Duck typing | Trait bounds | Explicit requirements, clear error messages | +| Mixins / default methods | Default trait methods | Same idea, enforced by compiler | +| `@dataclass` | `#[derive(...)]` | Pick exactly which behaviors to generate | +| `__add__`, `__repr__` | `impl Add`, `impl Display` | Operators are traits | +| Always dynamic dispatch | Static or dynamic dispatch | You choose the trade-off | + +## Next Steps + +Open `src/lib.rs` to see these concepts in working code, then try the +exercises in `exercises/`. diff --git a/ch03-traits-and-generics/exercises/Cargo.toml b/ch03-traits-and-generics/exercises/Cargo.toml new file mode 100644 index 0000000..04e8788 --- /dev/null +++ b/ch03-traits-and-generics/exercises/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "ch03-exercises" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Exercises for Chapter 3: Traits & Generics" diff --git a/ch03-traits-and-generics/exercises/src/lib.rs b/ch03-traits-and-generics/exercises/src/lib.rs new file mode 100644 index 0000000..35e4583 --- /dev/null +++ b/ch03-traits-and-generics/exercises/src/lib.rs @@ -0,0 +1,373 @@ +//! # Chapter 3 Exercises: Traits & Generics +//! +//! Each exercise shows a Python snippet and asks you to write the Rust +//! equivalent. Replace the `todo!()` markers with working code. +//! +//! Run tests: `cargo test -p ch03-exercises` + +// These allows are intentional: exercise stubs have unused parameters +// and fields until the student fills in the todo!() markers. +#![allow(unused_variables, dead_code)] + +use std::fmt; + +// ============================================================ +// Exercise 1: Define and Implement a Trait +// ============================================================ +// +// Python version: +// ```python +// class HasArea(Protocol): +// def area(self) -> float: ... +// +// class Rectangle: +// def __init__(self, width, height): +// self.width = width +// self.height = height +// def area(self): +// return self.width * self.height +// +// class Circle: +// def __init__(self, radius): +// self.radius = radius +// def area(self): +// return 3.14159265 * self.radius ** 2 +// ``` +// +// 1. Define a trait `HasArea` with a method `fn area(&self) -> f64` +// 2. Implement it for Rectangle and Circle (structs provided below) + +pub trait HasArea { + fn area(&self) -> f64; +} + +pub struct Rectangle { + pub width: f64, + pub height: f64, +} + +pub struct Circle { + pub radius: f64, +} + +impl HasArea for Rectangle { + fn area(&self) -> f64 { + todo!("width * height") + } +} + +impl HasArea for Circle { + fn area(&self) -> f64 { + todo!("PI * radius^2 — use std::f64::consts::PI") + } +} + +// ============================================================ +// Exercise 2: Generic Function with Trait Bound +// ============================================================ +// +// Python version: +// ```python +// def largest_area(shapes: list[HasArea]) -> float: +// return max(shape.area() for shape in shapes) +// +// assert largest_area([Rectangle(3, 4), Circle(1)]) == 12.0 +// ``` +// +// Write a generic function that finds the largest area in a slice. +// The bound: T must implement HasArea. + +pub fn largest_area(shapes: &[T]) -> Option { + todo!("Return the largest area, or None if the slice is empty") +} + +// ============================================================ +// Exercise 3: Display Trait (Python's __str__) +// ============================================================ +// +// Python version: +// ```python +// class Temperature: +// def __init__(self, celsius): +// self.celsius = celsius +// def __str__(self): +// return f"{self.celsius}°C" +// def __repr__(self): +// return f"Temperature(celsius={self.celsius})" +// ``` +// +// Implement Display for Temperature so that: +// format!("{}", temp) returns "23.5°C" +// +// Debug is already derived for you (__repr__ equivalent). + +#[derive(Debug, Clone, Copy)] +pub struct Temperature { + pub celsius: f64, +} + +impl Temperature { + pub fn new(celsius: f64) -> Self { + Self { celsius } + } + + pub fn to_fahrenheit(&self) -> f64 { + self.celsius * 9.0 / 5.0 + 32.0 + } +} + +impl fmt::Display for Temperature { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + todo!("Write celsius followed by °C") + } +} + +// ============================================================ +// Exercise 4: Operator Overloading +// ============================================================ +// +// Python version: +// ```python +// class Money: +// def __init__(self, cents): +// self.cents = cents +// def __add__(self, other): +// return Money(self.cents + other.cents) +// def __eq__(self, other): +// return self.cents == other.cents +// def __str__(self): +// return f"${self.cents / 100:.2f}" +// ``` +// +// Implement Add and Display for Money. PartialEq is already derived. + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Money { + pub cents: i64, +} + +impl Money { + pub fn new(cents: i64) -> Self { + Self { cents } + } + + pub fn from_dollars(dollars: f64) -> Self { + Self { + cents: (dollars * 100.0).round() as i64, + } + } +} + +impl std::ops::Add for Money { + type Output = Self; + + fn add(self, other: Self) -> Self { + todo!("Add the cents together") + } +} + +impl fmt::Display for Money { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + todo!("Format as $X.XX — dollars and cents with 2 decimal places") + } +} + +// ============================================================ +// Exercise 5: Trait with Default Method + Dynamic Dispatch +// ============================================================ +// +// Python version: +// ```python +// class Renderable: +// def render(self) -> str: +// raise NotImplementedError +// def render_with_border(self) -> str: +// content = self.render() +// width = max(len(line) for line in content.split('\n')) +// border = '+' + '-' * (width + 2) + '+' +// lines = [f"| {line:<{width}} |" for line in content.split('\n')] +// return '\n'.join([border] + lines + [border]) +// +// class TextBlock(Renderable): +// def __init__(self, text): self.text = text +// def render(self): return self.text +// +// class NumberBlock(Renderable): +// def __init__(self, value): self.value = value +// def render(self): return str(self.value) +// ``` +// +// 1. Implement `render()` for TextBlock and NumberBlock +// 2. The `render_with_border()` default method is provided +// 3. Implement `render_all` to work with mixed types (dynamic dispatch) + +pub trait Renderable { + /// Required: return the content to render. + fn render(&self) -> String; + + /// Default: wrap render() output in a border. + fn render_with_border(&self) -> String { + let content = self.render(); + let width = content.lines().map(|l| l.len()).max().unwrap_or(0); + let border = format!("+{}+", "-".repeat(width + 2)); + let body: Vec = content + .lines() + .map(|line| format!("| {: String { + todo!("Return self.text") + } +} + +impl Renderable for NumberBlock { + fn render(&self) -> String { + todo!("Return self.value as a string") + } +} + +/// Render all items in a mixed collection, one per line. +/// +/// Python equivalent: +/// ```python +/// def render_all(items: list[Renderable]) -> str: +/// return '\n'.join(item.render() for item in items) +/// ``` +/// +/// Hint: use `&[Box]` for the parameter type. +pub fn render_all(items: &[Box]) -> String { + todo!("Join each item's render() output with newlines") +} + +// ============================================================ +// Tests — do not modify below this line +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // Exercise 1 + #[test] + fn ex1_rectangle_area() { + let r = Rectangle { + width: 3.0, + height: 4.0, + }; + assert!((r.area() - 12.0).abs() < f64::EPSILON); + } + + #[test] + fn ex1_circle_area() { + let c = Circle { radius: 1.0 }; + assert!((c.area() - std::f64::consts::PI).abs() < 1e-10); + } + + // Exercise 2 + #[test] + fn ex2_largest_area() { + let rects = vec![ + Rectangle { + width: 2.0, + height: 3.0, + }, + Rectangle { + width: 10.0, + height: 1.0, + }, + Rectangle { + width: 4.0, + height: 4.0, + }, + ]; + let largest = largest_area(&rects).unwrap(); + assert!((largest - 16.0).abs() < f64::EPSILON); + } + + #[test] + fn ex2_empty_returns_none() { + let empty: Vec = vec![]; + assert!(largest_area(&empty).is_none()); + } + + // Exercise 3 + #[test] + fn ex3_temperature_display() { + let t = Temperature::new(23.5); + assert_eq!(format!("{t}"), "23.5\u{00B0}C"); + } + + #[test] + fn ex3_temperature_debug() { + let t = Temperature::new(100.0); + assert_eq!(format!("{t:?}"), "Temperature { celsius: 100.0 }"); + } + + // Exercise 4 + #[test] + fn ex4_money_add() { + let a = Money::new(150); + let b = Money::new(250); + assert_eq!(a + b, Money::new(400)); + } + + #[test] + fn ex4_money_display() { + assert_eq!(format!("{}", Money::new(150)), "$1.50"); + assert_eq!(format!("{}", Money::new(7)), "$0.07"); + assert_eq!(format!("{}", Money::new(1000)), "$10.00"); + } + + #[test] + fn ex4_money_from_dollars() { + assert_eq!(Money::from_dollars(9.99), Money::new(999)); + } + + // Exercise 5 + #[test] + fn ex5_text_render() { + let t = TextBlock { + text: "hello".to_string(), + }; + assert_eq!(t.render(), "hello"); + } + + #[test] + fn ex5_number_render() { + let n = NumberBlock { value: 42.0 }; + assert_eq!(n.render(), "42"); + } + + #[test] + fn ex5_border() { + let t = TextBlock { + text: "hi".to_string(), + }; + let bordered = t.render_with_border(); + assert!(bordered.contains("+----+")); + assert!(bordered.contains("| hi |")); + } + + #[test] + fn ex5_render_all() { + let items: Vec> = vec![ + Box::new(TextBlock { + text: "hello".to_string(), + }), + Box::new(NumberBlock { value: 42.0 }), + ]; + assert_eq!(render_all(&items), "hello\n42"); + } +} diff --git a/ch03-traits-and-generics/src/lib.rs b/ch03-traits-and-generics/src/lib.rs new file mode 100644 index 0000000..cc1d8f0 --- /dev/null +++ b/ch03-traits-and-generics/src/lib.rs @@ -0,0 +1,456 @@ +//! # Chapter 3: Traits & Generics +//! +//! This module demonstrates Rust's trait system through examples that +//! map to familiar Python patterns. +//! +//! Run the tests: `cargo test -p ch03-traits-and-generics` + +use std::fmt; + +// --------------------------------------------------------------------------- +// 1. Defining and implementing traits +// --------------------------------------------------------------------------- + +/// A trait for things that can summarize themselves in one line. +/// +/// Python equivalent: +/// ```python +/// class Summarizable(Protocol): +/// def summary(self) -> str: ... +/// ``` +pub trait Summarizable { + fn summary(&self) -> String; +} + +/// A blog post. +#[derive(Debug, Clone)] +pub struct BlogPost { + pub title: String, + pub author: String, + pub word_count: usize, +} + +/// A code snippet. +#[derive(Debug, Clone)] +pub struct CodeSnippet { + pub language: String, + pub lines: usize, +} + +impl Summarizable for BlogPost { + fn summary(&self) -> String { + format!( + "\"{}\" by {} ({} words)", + self.title, self.author, self.word_count + ) + } +} + +impl Summarizable for CodeSnippet { + fn summary(&self) -> String { + format!("{} snippet ({} lines)", self.language, self.lines) + } +} + +// --------------------------------------------------------------------------- +// 2. Trait bounds and generics +// --------------------------------------------------------------------------- + +/// Print the summary of anything Summarizable. +/// +/// Python equivalent: +/// ```python +/// def print_summary(item: Summarizable) -> str: +/// return f">> {item.summary()}" +/// ``` +/// +/// The `impl Summarizable` syntax is sugar for ``. +/// The compiler generates a specialized version for each type you call +/// this with — no vtable, no runtime cost. +pub fn format_summary(item: &impl Summarizable) -> String { + format!(">> {}", item.summary()) +} + +/// Find the item with the longest summary. +/// +/// This shows a more complex trait bound: T must be both Summarizable +/// and Clone (because we need to return an owned copy). +/// +/// Python equivalent: +/// ```python +/// def longest_summary(items: list[Summarizable]) -> Summarizable: +/// return max(items, key=lambda x: len(x.summary())) +/// ``` +pub fn longest_summary(items: &[T]) -> Option { + items + .iter() + .max_by_key(|item| item.summary().len()) + .cloned() +} + +// --------------------------------------------------------------------------- +// 3. Default methods +// --------------------------------------------------------------------------- + +/// A trait with a required method and a default method. +/// +/// Python equivalent: +/// ```python +/// class Labeled: +/// def label(self) -> str: +/// raise NotImplementedError +/// +/// def display_label(self) -> str: +/// return f"[{self.label()}]" # default uses label() +/// ``` +pub trait Labeled { + /// Required — implementors must provide this. + fn label(&self) -> &str; + + /// Default — implementors get this for free, but can override it. + fn display_label(&self) -> String { + format!("[{}]", self.label()) + } +} + +#[derive(Debug)] +pub struct Tag { + name: String, +} + +impl Tag { + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + } + } +} + +impl Labeled for Tag { + fn label(&self) -> &str { + &self.name + } + // display_label() uses the default implementation +} + +#[derive(Debug)] +pub struct Priority { + level: u8, + name: String, +} + +impl Priority { + pub fn new(level: u8, name: &str) -> Self { + Self { + level, + name: name.to_string(), + } + } +} + +impl Labeled for Priority { + fn label(&self) -> &str { + &self.name + } + + // Override the default to include the level + fn display_label(&self) -> String { + format!("[P{}:{}]", self.level, self.name) + } +} + +// --------------------------------------------------------------------------- +// 4. Deriving common traits +// --------------------------------------------------------------------------- + +/// A 2D point with derived traits. +/// +/// Python equivalent: +/// ```python +/// @dataclass(frozen=True) +/// class Point: +/// x: float +/// y: float +/// # Gets __eq__, __repr__, __hash__ automatically +/// ``` +/// +/// Rust's #[derive] is more granular — you pick exactly which traits. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Point { + pub x: f64, + pub y: f64, +} + +impl Point { + pub fn new(x: f64, y: f64) -> Self { + Self { x, y } + } + + pub fn distance_to(&self, other: &Self) -> f64 { + ((self.x - other.x).powi(2) + (self.y - other.y).powi(2)).sqrt() + } +} + +/// Display is the trait behind `format!("{}", point)`. +/// It's like Python's `__str__`. +impl fmt::Display for Point { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "({}, {})", self.x, self.y) + } +} + +// --------------------------------------------------------------------------- +// 5. Operator overloading via traits +// --------------------------------------------------------------------------- + +/// Implement Add for Point — like Python's `__add__`. +impl std::ops::Add for Point { + type Output = Self; + + fn add(self, other: Self) -> Self { + Self { + x: self.x + other.x, + y: self.y + other.y, + } + } +} + +/// Implement Sub for Point — like Python's `__sub__`. +impl std::ops::Sub for Point { + type Output = Self; + + fn sub(self, other: Self) -> Self { + Self { + x: self.x - other.x, + y: self.y - other.y, + } + } +} + +// --------------------------------------------------------------------------- +// 6. Dynamic dispatch with trait objects +// --------------------------------------------------------------------------- + +/// Render a mixed collection of Summarizable items. +/// +/// Python equivalent: +/// ```python +/// def render_feed(items: list[Summarizable]) -> list[str]: +/// return [f"- {item.summary()}" for item in items] +/// ``` +/// +/// In Rust, mixing different concrete types in one Vec requires dynamic +/// dispatch: `Box` or `&dyn Trait`. +pub fn render_feed(items: &[Box]) -> Vec { + items + .iter() + .map(|item| format!("- {}", item.summary())) + .collect() +} + +// --------------------------------------------------------------------------- +// 7. Multiple trait bounds — the "where" clause +// --------------------------------------------------------------------------- + +/// Format a labeled, summarizable item. +/// +/// Python equivalent: +/// ```python +/// def card(item): +/// # Assumes item has both .label() and .summary() +/// return f"{item.display_label()} {item.summary()}" +/// ``` +/// +/// The `where` clause is the same as inline bounds but more readable +/// when you have multiple constraints. +pub fn card(item: &T) -> String +where + T: Labeled + Summarizable, +{ + format!("{} {}", item.display_label(), item.summary()) +} + +/// A type that implements both Labeled and Summarizable. +#[derive(Debug, Clone)] +pub struct Article { + pub section: String, + pub title: String, + pub word_count: usize, +} + +impl Labeled for Article { + fn label(&self) -> &str { + &self.section + } +} + +impl Summarizable for Article { + fn summary(&self) -> String { + format!("{} ({} words)", self.title, self.word_count) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // Trait basics + + #[test] + fn summarize_blog_post() { + let post = BlogPost { + title: "Ownership in Rust".to_string(), + author: "Alice".to_string(), + word_count: 1500, + }; + assert_eq!( + post.summary(), + "\"Ownership in Rust\" by Alice (1500 words)" + ); + } + + #[test] + fn summarize_code_snippet() { + let snippet = CodeSnippet { + language: "Rust".to_string(), + lines: 42, + }; + assert_eq!(snippet.summary(), "Rust snippet (42 lines)"); + } + + // Generic functions with trait bounds + + #[test] + fn format_summary_works() { + let post = BlogPost { + title: "Hello".to_string(), + author: "Bob".to_string(), + word_count: 100, + }; + assert_eq!(format_summary(&post), ">> \"Hello\" by Bob (100 words)"); + } + + #[test] + fn longest_summary_finds_longest() { + let snippets = vec![ + CodeSnippet { + language: "Python".to_string(), + lines: 10, + }, + CodeSnippet { + language: "Rust".to_string(), + lines: 1000, + }, + ]; + let longest = longest_summary(&snippets).unwrap(); + assert_eq!(longest.language, "Rust"); + } + + #[test] + fn longest_summary_empty_returns_none() { + let empty: Vec = vec![]; + assert!(longest_summary(&empty).is_none()); + } + + // Default methods + + #[test] + fn tag_uses_default_display_label() { + let tag = Tag::new("urgent"); + assert_eq!(tag.display_label(), "[urgent]"); + } + + #[test] + fn priority_overrides_display_label() { + let p = Priority::new(1, "critical"); + assert_eq!(p.display_label(), "[P1:critical]"); + } + + // Derived traits + + #[test] + fn point_equality() { + let a = Point::new(1.0, 2.0); + let b = Point::new(1.0, 2.0); + assert_eq!(a, b); + } + + #[test] + fn point_debug() { + let p = Point::new(3.0, 4.0); + assert_eq!(format!("{:?}", p), "Point { x: 3.0, y: 4.0 }"); + } + + #[test] + fn point_display() { + let p = Point::new(3.0, 4.0); + assert_eq!(format!("{p}"), "(3, 4)"); + } + + #[test] + fn point_copy() { + let a = Point::new(1.0, 2.0); + let b = a; // Copy, not move + assert_eq!(a, b); // both still valid + } + + // Operator overloading + + #[test] + fn point_add() { + let a = Point::new(1.0, 2.0); + let b = Point::new(3.0, 4.0); + assert_eq!(a + b, Point::new(4.0, 6.0)); + } + + #[test] + fn point_sub() { + let a = Point::new(5.0, 7.0); + let b = Point::new(2.0, 3.0); + assert_eq!(a - b, Point::new(3.0, 4.0)); + } + + #[test] + fn point_distance() { + let a = Point::new(0.0, 0.0); + let b = Point::new(3.0, 4.0); + assert!((a.distance_to(&b) - 5.0).abs() < f64::EPSILON); + } + + // Dynamic dispatch + + #[test] + fn render_mixed_feed() { + let items: Vec> = vec![ + Box::new(BlogPost { + title: "Hello".to_string(), + author: "Alice".to_string(), + word_count: 100, + }), + Box::new(CodeSnippet { + language: "Rust".to_string(), + lines: 50, + }), + ]; + let feed = render_feed(&items); + assert_eq!(feed.len(), 2); + assert!(feed[0].starts_with("- \"Hello\"")); + assert!(feed[1].starts_with("- Rust snippet")); + } + + // Multiple trait bounds + + #[test] + fn article_card() { + let article = Article { + section: "Tech".to_string(), + title: "Why Traits Matter".to_string(), + word_count: 2000, + }; + assert_eq!(card(&article), "[Tech] Why Traits Matter (2000 words)"); + } +}