diff --git a/Cargo.toml b/Cargo.toml index b8d47cf..0e188a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,10 @@ resolver = "2" members = [ "ch01-ownership", "ch01-ownership/exercises", + "ch02-error-handling", + "ch02-error-handling/exercises", + "ch03-traits-and-generics", + "ch03-traits-and-generics/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()); + } +} 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)"); + } +}