Lesson 84 of 94
API Design Idioms
A good API makes the right thing easy and the wrong thing impossible, or at least a compile error. Rust gives you many tools for that: ownership in signatures, enums, traits you can implement for your own types, and conversions that plug into the standard library. This lesson collects the idioms that experienced Rust developers use, many of them from the official Rust API Guidelines.
The recurring theme: encode rules in types so the compiler checks them, instead of in doc comments that callers may not read.
Part 1: Conventions
1. Naming conventions
Rust naming follows predictable patterns that tell callers about cost and ownership before they read the docs.
| Pattern | Meaning | Examples |
|---|---|---|
as_x() |
cheap view, borrowed to borrowed | as_str(), as_bytes(), as_ref() |
to_x() |
expensive conversion, usually allocates | to_string(), to_vec(), to_lowercase() |
into_x() |
consumes self, reuses its storage |
into_bytes(), into_inner(), into_iter() |
x() (no get_) |
getter | len(), name(), capacity() |
x_mut() |
mutable getter | get_mut(), last_mut(), name_mut() |
is_x(), has_x() |
boolean query | is_empty(), is_some(), has_permission() |
new, with_x, from_x |
constructors | Vec::new(), Vec::with_capacity(n), String::from_utf8(v) |
try_x() |
fallible version returning Result/Option |
try_from(), try_lock() |
iter(), iter_mut(), into_iter() |
iterate by &, &mut, value |
on every collection |
pub struct Username(String);
impl Username {
pub fn new(raw: &str) -> Username {
Username(raw.trim().to_lowercase())
}
pub fn as_str(&self) -> &str {
&self.0 // cheap: just a view
}
pub fn len(&self) -> usize {
self.0.len() // a getter: no `get_` prefix
}
pub fn is_admin(&self) -> bool {
self.0.starts_with("admin")
}
pub fn to_display_name(&self) -> String {
let mut c = self.0.chars(); // allocates a new String
c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default()
}
pub fn into_string(self) -> String {
self.0 // consumes self, no copy
}
}
fn main() {
let u = Username::new(" AdminAna ");
println!("{} ({} chars), admin: {}", u.as_str(), u.len(), u.is_admin());
println!("{}", u.to_display_name());
let raw: String = u.into_string();
println!("{raw}");
}adminana (8 chars), admin: true
Adminana
adminanaA getter for a name field is called name(), and a setter usually becomes either a name_mut() or a method that expresses intent (rename(x)).
2. Accept the most general input you can use
A parameter of &str accepts &String, &str, and string literals. &[T] accepts &Vec<T>, arrays, and slices. Asking for &String or &Vec<T> needlessly restricts callers.
fn count_vowels(s: &str) -> usize {
s.chars().filter(|c| "aeiouAEIOU".contains(*c)).count()
}
fn average(values: &[f64]) -> Option<f64> {
(!values.is_empty()).then(|| values.iter().sum::<f64>() / values.len() as f64)
}
fn main() {
let owned = String::from("Programming in Rust");
println!("{}", count_vowels(&owned)); // &String -> &str
println!("{}", count_vowels("literal")); // &'static str
println!("{}", count_vowels(&owned[..11])); // a slice of a String
let v = vec![1.0, 2.0, 4.5];
println!("{:?}", average(&v)); // &Vec<f64> -> &[f64]
println!("{:?}", average(&[10.0, 20.0])); // an array
println!("{:?}", average(&v[1..])); // a sub-slice
}5
3
3
Some(2.5)
Some(15.0)
Some(3.25)3. impl Into<String> when you need to own the value
If a function stores the string, taking String forces callers with a &str to write .to_string(). impl Into<String> accepts both, and moves an existing String without copying.
struct Email {
to: String,
subject: String,
}
impl Email {
fn new(to: impl Into<String>, subject: impl Into<String>) -> Email {
Email { to: to.into(), subject: subject.into() }
}
}
fn main() {
let a = Email::new("ana@example.com", "Hi"); // &str: converted
let subject = format!("Invoice #{}", 42);
let b = Email::new(String::from("ben@example.com"), subject); // String: moved, no copy
println!("{} / {}", a.to, a.subject);
println!("{} / {}", b.to, b.subject);
}ana@example.com / Hi
ben@example.com / Invoice #424. AsRef<Path> for anything path-like
Standard library file functions take impl AsRef<Path>, so they accept &str, String, Path, and PathBuf. Do the same in your own APIs.
use std::path::Path;
fn extension_of(path: impl AsRef<Path>) -> Option<String> {
path.as_ref().extension().map(|e| e.to_string_lossy().to_lowercase())
}
fn main() {
let owned = String::from("report.PDF");
let buf = std::path::PathBuf::from("/var/log/app.log");
println!("{:?}", extension_of("photo.jpeg"));
println!("{:?}", extension_of(&owned));
println!("{:?}", extension_of(&buf));
println!("{:?}", extension_of(Path::new("Makefile")));
}Some("jpeg")
Some("pdf")
Some("log")
None5. Accept impl IntoIterator
Taking impl IntoIterator<Item = T> accepts a Vec, an array, a HashSet, a range, or a lazy iterator chain, without making the caller build a Vec first.
use std::collections::BTreeSet;
fn total_cents(prices: impl IntoIterator<Item = u32>) -> u64 {
prices.into_iter().map(u64::from).sum()
}
fn main() {
println!("{}", total_cents(vec![199, 250]));
println!("{}", total_cents([100, 200, 300]));
println!("{}", total_cents(BTreeSet::from([5, 10])));
println!("{}", total_cents((1..=4).map(|n| n * 100))); // a lazy chain, no Vec
}449
600
15
10006. Return iterators instead of Vecs
Returning impl Iterator<Item = T> lets callers decide: collect into any collection, stop early, or chain more adapters, with no intermediate allocation.
struct Inventory {
items: Vec<(String, u32)>,
}
impl Inventory {
fn low_stock(&self, below: u32) -> impl Iterator<Item = &str> {
self.items.iter().filter(move |(_, qty)| *qty < below).map(|(name, _)| name.as_str())
}
}
fn main() {
let inv = Inventory { items: vec![("pens".into(), 3), ("paper".into(), 120), ("ink".into(), 1), ("tape".into(), 9)] };
let first = inv.low_stock(10).next(); // stops after the first match
let count = inv.low_stock(10).count();
let joined = inv.low_stock(5).collect::<Vec<_>>().join(", ");
println!("{first:?}, {count} low, critically low: {joined}");
}Some("pens"), 3 low, critically low: pens, inkPart 2: Types that prevent mistakes
7. Newtypes stop unit mix-ups
Two f64s can be swapped by accident; a Meters and a Feet can't. A newtype (a one-field tuple struct) costs nothing at runtime and turns mix-ups into compile errors.
struct Meters(f64);
struct Feet(f64);
fn climb_rate(height: Meters, seconds: f64) -> f64 {
height.0 / seconds
}
fn main() {
let altitude = Feet(3000.0);
println!("{}", climb_rate(altitude, 60.0));
}error[E0308]: mismatched types
--> src/main.rs:10:31
|
10 | println!("{}", climb_rate(altitude, 60.0));
| ---------- ^^^^^^^^ expected `Meters`, found `Feet`
| |
| arguments to this function are incorrect
|
note: function defined here
--> src/main.rs:4:4
|
4 | fn climb_rate(height: Meters, seconds: f64) -> f64 {
| ^^^^^^^^^^ --------------The famous example of what this prevents is NASA's Mars Climate Orbiter, lost in 1999 when one team's software produced pound-force seconds and another expected newton-seconds.
8. Newtypes with conversions and operators
Give a newtype the conversions and operators that make sense, and nothing else. Here Meters can be added and compared, and converted from Feet explicitly.
use std::ops::Add;
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
struct Meters(f64);
#[derive(Debug, Clone, Copy)]
struct Feet(f64);
impl From<Feet> for Meters {
fn from(f: Feet) -> Meters {
Meters(f.0 * 0.3048)
}
}
impl Add for Meters {
type Output = Meters;
fn add(self, other: Meters) -> Meters {
Meters(self.0 + other.0)
}
}
fn main() {
let runway = Meters(2500.0);
let extension: Meters = Feet(1000.0).into(); // explicit conversion
let total = runway + extension;
println!("{total:?}");
println!("long enough for a 747: {}", total >= Meters(2800.0));
}Meters(2804.8)
long enough for a 747: true9. Parse, don't validate
Instead of a validate(email: &str) -> bool that callers might forget to call, give the validated value its own type, created only by a parsing function. Every Email in the program is then valid by construction, and functions that take an Email never re-check.
#[derive(Debug, Clone)]
pub struct Email(String);
impl Email {
pub fn parse(raw: &str) -> Result<Email, String> {
let raw = raw.trim();
let (user, domain) = raw.split_once('@').ok_or(format!("{raw:?} has no @"))?;
if user.is_empty() || !domain.contains('.') || domain.starts_with('.') {
return Err(format!("{raw:?} is not a valid address"));
}
Ok(Email(raw.to_lowercase()))
}
pub fn domain(&self) -> &str {
self.0.split_once('@').map(|(_, d)| d).unwrap() // can't fail: checked in parse
}
}
// No validation needed here: the type guarantees it.
fn send_welcome(to: &Email) -> String {
format!("welcome mail queued for {} (domain {})", to.0, to.domain())
}
fn main() {
for raw in ["Ana@Example.com", "no-at-sign", "x@.com", "ben@mail.org"] {
match Email::parse(raw) {
Ok(email) => println!("{}", send_welcome(&email)),
Err(e) => println!("rejected: {e}"),
}
}
}welcome mail queued for ana@example.com (domain example.com)
rejected: "no-at-sign" has no @
rejected: "x@.com" is not a valid address
welcome mail queued for ben@mail.org (domain mail.org)10. Make illegal states unrepresentable
A struct with connected: bool, socket: Option<Socket> and error: Option<String> fields can be in contradictory states (connected with an error, disconnected with a socket). An enum where each state carries exactly its own data can't.
#[derive(Debug)]
enum Connection {
Disconnected,
Connecting { attempt: u32 },
Connected { session_id: u64 },
Failed { reason: String },
}
impl Connection {
fn step(self, ok: bool) -> Connection {
match self {
Connection::Disconnected => Connection::Connecting { attempt: 1 },
Connection::Connecting { attempt } if ok => Connection::Connected { session_id: 1000 + attempt as u64 },
Connection::Connecting { attempt } if attempt < 3 => Connection::Connecting { attempt: attempt + 1 },
Connection::Connecting { .. } => Connection::Failed { reason: String::from("3 attempts failed") },
done => done, // Connected and Failed stay as they are
}
}
}
fn main() {
let mut c = Connection::Disconnected;
for ok in [false, false, true, false] {
c = c.step(ok);
println!("{c:?}");
}
// There's no way to build "Connected with a failure reason".
}Connecting { attempt: 1 }
Connecting { attempt: 2 }
Connected { session_id: 1002 }
Connected { session_id: 1002 }11. Typestate: states checked at compile time
The enum above checks transitions at runtime. When the order of operations is fixed, you can go further and make each state a type, so calling a method in the wrong state doesn't compile. A generic marker parameter keeps it to one struct.
use std::marker::PhantomData;
struct Draft;
struct Reviewed;
struct Published;
struct Article<State> {
title: String,
body: String,
_state: PhantomData<State>,
}
impl Article<Draft> {
fn new(title: &str) -> Self {
Article { title: title.to_string(), body: String::new(), _state: PhantomData }
}
fn write(mut self, text: &str) -> Self {
self.body.push_str(text);
self
}
fn submit(self) -> Article<Reviewed> {
Article { title: self.title, body: self.body, _state: PhantomData }
}
}
impl Article<Reviewed> {
fn publish(self) -> Article<Published> {
Article { title: self.title, body: self.body, _state: PhantomData }
}
}
impl Article<Published> {
fn url(&self) -> String {
format!("/posts/{}", self.title.to_lowercase().replace(' ', "-"))
}
}
fn main() {
let draft = Article::new("Typestate in Rust").write("States as types...");
// draft.publish(); // error: no method `publish` on Article<Draft>
let live = draft.submit().publish();
println!("{} ({} chars) at {}", live.title, live.body.len(), live.url());
}Typestate in Rust (18 chars) at /posts/typestate-in-rustThe Methods lesson built this with separate structs; the generic version shares fields and common methods across states.
12. Using a method in the wrong state is a compile error
This is the payoff: the invalid sequence is rejected before the program runs.
use std::marker::PhantomData;
struct Draft;
struct Published;
struct Article<State> {
title: String,
_state: PhantomData<State>,
}
impl Article<Draft> {
fn new(title: &str) -> Self {
Article { title: title.to_string(), _state: PhantomData }
}
}
impl Article<Published> {
fn url(&self) -> String {
format!("/posts/{}", self.title)
}
}
fn main() {
let draft = Article::new("oops");
println!("{}", draft.url());
}error[E0599]: no method named `url` found for struct `Article<Draft>` in the current scope
--> src/main.rs:25:26
|
6 | struct Article<State> {
| --------------------- method `url` not found for this struct
...
25 | println!("{}", draft.url());
| ^^^ method not found in `Article<Draft>`
|
= note: the method was found for
- `Article<Published>`13. Default and struct update syntax
For types with many optional settings, deriving Default and letting callers override fields with ..Default::default() is often all you need, with no builder at all.
#[derive(Debug, Clone)]
struct ServerConfig {
host: String,
port: u16,
workers: usize,
tls: bool,
max_body_kb: u32,
}
impl Default for ServerConfig {
fn default() -> Self {
ServerConfig { host: String::from("127.0.0.1"), port: 8080, workers: 4, tls: false, max_body_kb: 1024 }
}
}
fn main() {
let dev = ServerConfig::default();
let prod = ServerConfig { host: String::from("0.0.0.0"), port: 443, tls: true, ..Default::default() };
println!("{dev:?}");
println!("{prod:?}");
}ServerConfig { host: "127.0.0.1", port: 8080, workers: 4, tls: false, max_body_kb: 1024 }
ServerConfig { host: "0.0.0.0", port: 443, workers: 4, tls: true, max_body_kb: 1024 }#[derive(Default)] works when every field's own default is right (0, false, empty string); enums pick their default with a #[default] attribute on one variant.
14. The builder pattern
When construction needs validation, or has required and optional parts, a builder makes call sites readable. This one takes self by value in each setter (a consuming builder) and validates in build().
#[derive(Debug)]
pub struct HttpRequest {
method: String,
url: String,
headers: Vec<(String, String)>,
timeout_ms: u64,
}
#[derive(Default)]
pub struct HttpRequestBuilder {
method: Option<String>,
url: Option<String>,
headers: Vec<(String, String)>,
timeout_ms: Option<u64>,
}
impl HttpRequest {
pub fn builder() -> HttpRequestBuilder {
HttpRequestBuilder::default()
}
}
impl HttpRequestBuilder {
pub fn get(mut self, url: impl Into<String>) -> Self {
self.method = Some(String::from("GET"));
self.url = Some(url.into());
self
}
pub fn header(mut self, name: &str, value: &str) -> Self {
self.headers.push((name.to_string(), value.to_string()));
self
}
pub fn timeout_ms(mut self, ms: u64) -> Self {
self.timeout_ms = Some(ms);
self
}
pub fn build(self) -> Result<HttpRequest, String> {
let url = self.url.ok_or("url is required")?;
if !url.starts_with("http") {
return Err(format!("invalid url {url:?}"));
}
Ok(HttpRequest { method: self.method.unwrap_or_else(|| String::from("GET")), url, headers: self.headers, timeout_ms: self.timeout_ms.unwrap_or(30_000) })
}
}
fn main() {
let req = HttpRequest::builder().get("https://api.example.com/users").header("Accept", "application/json").timeout_ms(5_000).build();
println!("{req:#?}");
println!("{:?}", HttpRequest::builder().header("X", "1").build());
println!("{:?}", HttpRequest::builder().get("ftp://old").build());
}Ok(
HttpRequest {
method: "GET",
url: "https://api.example.com/users",
headers: [
(
"Accept",
"application/json",
),
],
timeout_ms: 5000,
},
)
Err("url is required")
Err("invalid url \"ftp://old\"")15. A builder with compile-time required fields
Combine the builder with typestate and forgetting a required field becomes a compile error instead of a runtime Err. The builder's type records which required fields have been set.
use std::marker::PhantomData;
struct Missing;
struct Set;
struct EmailBuilder<To, Subject> {
to: Option<String>,
subject: Option<String>,
body: String,
_state: PhantomData<(To, Subject)>,
}
struct Email {
to: String,
subject: String,
body: String,
}
impl EmailBuilder<Missing, Missing> {
fn new() -> Self {
EmailBuilder { to: None, subject: None, body: String::new(), _state: PhantomData }
}
}
impl<S> EmailBuilder<Missing, S> {
fn to(self, addr: &str) -> EmailBuilder<Set, S> {
EmailBuilder { to: Some(addr.into()), subject: self.subject, body: self.body, _state: PhantomData }
}
}
impl<T> EmailBuilder<T, Missing> {
fn subject(self, s: &str) -> EmailBuilder<T, Set> {
EmailBuilder { to: self.to, subject: Some(s.into()), body: self.body, _state: PhantomData }
}
}
impl<T, S> EmailBuilder<T, S> {
fn body(mut self, text: &str) -> Self {
self.body.push_str(text); // optional, allowed in any state
self
}
}
impl EmailBuilder<Set, Set> {
// `build` only exists once both required fields are set; it can't fail.
fn build(self) -> Email {
Email { to: self.to.unwrap(), subject: self.subject.unwrap(), body: self.body }
}
}
fn main() {
let mail = EmailBuilder::new().subject("Your order shipped").body("Tracking: 1Z999").to("ana@example.com").build();
println!("to {} | {} | {}", mail.to, mail.subject, mail.body);
// EmailBuilder::new().to("x@y.z").build(); // error: `build` not found for EmailBuilder<Set, Missing>
}to ana@example.com | Your order shipped | Tracking: 1Z999Crates like bon and typed-builder generate this pattern from a derive, with the checks at compile time.
16. Enums instead of boolean parameters
copy_file("a", "b", true, false) is unreadable at the call site. Two-variant enums name the options and can't be passed in the wrong order.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Overwrite {
Yes,
No,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Permissions {
Preserve,
Reset,
}
fn copy_file(from: &str, to: &str, overwrite: Overwrite, perms: Permissions) -> String {
let exists = to.ends_with(".bak");
if exists && overwrite == Overwrite::No {
return format!("skipped {to}: already exists");
}
format!("copied {from} -> {to} ({})", if perms == Permissions::Preserve { "permissions kept" } else { "default permissions" })
}
fn main() {
println!("{}", copy_file("db.sqlite", "db.sqlite.bak", Overwrite::No, Permissions::Preserve));
println!("{}", copy_file("db.sqlite", "db.sqlite.bak", Overwrite::Yes, Permissions::Reset));
}skipped db.sqlite.bak: already exists
copied db.sqlite -> db.sqlite.bak (default permissions)Part 3: Playing well with the standard library
17. Implement the common traits eagerly
Users expect to print, compare, hash, clone, and sort your types. Derive the standard traits whenever they make sense; a public type without Debug is especially frustrating to work with.
use std::collections::{BTreeSet, HashMap};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
enum Priority {
Low,
Medium,
High,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
struct Ticket {
priority: Priority, // derived Ord compares fields top to bottom
id: u32,
}
fn main() {
let tickets = vec![
Ticket { priority: Priority::Low, id: 3 },
Ticket { priority: Priority::High, id: 7 },
Ticket { priority: Priority::High, id: 2 },
];
let sorted: BTreeSet<Ticket> = tickets.iter().cloned().collect(); // needs Ord
println!("{:?}", sorted.iter().map(|t| t.id).collect::<Vec<_>>());
let mut per_priority: HashMap<Priority, usize> = HashMap::new(); // needs Hash + Eq
for t in &tickets {
*per_priority.entry(t.priority).or_default() += 1;
}
println!("high: {}", per_priority[&Priority::High]);
println!("max: {:?}", tickets.iter().max()); // needs Ord
}[3, 2, 7]
high: 2
max: Some(Ticket { priority: High, id: 7 })The recommended set for most public types: Debug, Clone, PartialEq, and where they apply, Eq, Hash, PartialOrd, Ord, Default, and Copy (for small plain-data types).
18. Display for users, Debug for developers
Debug shows the structure, for logs and debugging. Display is the human-facing format, and it gives you to_string() for free.
use std::fmt;
#[derive(Debug)]
struct Money {
cents: i64,
currency: &'static str,
}
impl fmt::Display for Money {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let sign = if self.cents < 0 { "-" } else { "" };
let abs = self.cents.abs();
write!(f, "{sign}{}{}.{:02}", self.currency, abs / 100, abs % 100)
}
}
fn main() {
let refund = Money { cents: -1250, currency: "$" };
println!("{refund}");
println!("{refund:?}");
let label: String = refund.to_string(); // from Display
println!("{label:>12}|"); // padding applies to the whole formatted value
}-$12.50
Money { cents: -1250, currency: "$" }
-$12.50|19. FromStr: parsing with .parse()
Implementing FromStr lets callers write "...".parse::<YourType>(), the same way they parse numbers. It pairs naturally with Display for round-tripping text.
use std::fmt;
use std::str::FromStr;
#[derive(Debug, PartialEq, Clone, Copy)]
struct Version {
major: u32,
minor: u32,
patch: u32,
}
impl FromStr for Version {
type Err = String;
fn from_str(s: &str) -> Result<Version, String> {
let parts: Vec<&str> = s.trim_start_matches('v').split('.').collect();
let [major, minor, patch] = parts.as_slice() else {
return Err(format!("{s:?}: expected MAJOR.MINOR.PATCH"));
};
let num = |p: &str| p.parse::<u32>().map_err(|_| format!("{s:?}: {p:?} is not a number"));
Ok(Version { major: num(major)?, minor: num(minor)?, patch: num(patch)? })
}
}
impl fmt::Display for Version {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
fn main() {
let v: Version = "v1.42.3".parse().unwrap();
println!("{v} ({v:?})");
println!("round trip: {}", v.to_string().parse::<Version>() == Ok(v));
println!("{:?}", "1.2".parse::<Version>());
println!("{:?}", "1.x.0".parse::<Version>());
}1.42.3 (Version { major: 1, minor: 42, patch: 3 })
round trip: true
Err("\"1.2\": expected MAJOR.MINOR.PATCH")
Err("\"1.x.0\": \"x\" is not a number")20. From and TryFrom for conversions
From<A> for B is an infallible conversion and gives you .into() for free; TryFrom is the fallible version with .try_into(). Implementing them is how your types join the standard conversion vocabulary, including ?'s error conversion.
use std::convert::TryFrom;
#[derive(Debug, Clone, Copy, PartialEq)]
struct Percent(u8);
impl TryFrom<i32> for Percent {
type Error = String;
fn try_from(v: i32) -> Result<Percent, String> {
u8::try_from(v).ok().filter(|p| *p <= 100).map(Percent).ok_or(format!("{v} is not a percentage"))
}
}
impl From<Percent> for f64 {
fn from(p: Percent) -> f64 {
p.0 as f64 / 100.0
}
}
fn discounted(price: f64, off: Percent) -> f64 {
price * (1.0 - f64::from(off))
}
fn main() {
let off: Percent = 25.try_into().unwrap();
println!("{:.2}", discounted(80.0, off));
println!("{:?} {:?}", Percent::try_from(150), Percent::try_from(-3));
let as_fraction: f64 = off.into();
println!("{as_fraction}");
}60.00
Err("150 is not a percentage") Err("-3 is not a percentage")
0.2521. Make your collection iterable
For for x in &collection to work, implement IntoIterator for &YourType. Returning the inner collection's iterator is usually enough. Add iter() too, since users expect it.
struct Playlist {
name: String,
songs: Vec<String>,
}
impl Playlist {
fn iter(&self) -> std::slice::Iter<'_, String> {
self.songs.iter()
}
}
impl<'a> IntoIterator for &'a Playlist {
type Item = &'a String;
type IntoIter = std::slice::Iter<'a, String>;
fn into_iter(self) -> Self::IntoIter {
self.songs.iter()
}
}
impl IntoIterator for Playlist {
type Item = String;
type IntoIter = std::vec::IntoIter<String>;
fn into_iter(self) -> Self::IntoIter {
self.songs.into_iter()
}
}
fn main() {
let p = Playlist { name: String::from("focus"), songs: vec!["Intro".into(), "Deep Work".into(), "Outro".into()] };
for song in &p {
print!("{song}; ");
}
println!("({} has {} songs)", p.name, p.iter().count());
let owned: Vec<String> = p.into_iter().filter(|s| s.len() > 5).collect(); // consumes the playlist
println!("{owned:?}");
}Intro; Deep Work; Outro; (focus has 3 songs)
["Deep Work"]22. FromIterator and Extend: collect into your type
Implementing FromIterator lets users collect() straight into your type; Extend lets them add items from any iterator. Your collection then behaves like the built-in ones.
use std::collections::BTreeMap;
#[derive(Debug, Default)]
struct Histogram {
buckets: BTreeMap<u32, usize>, // bucket start -> count
width: u32,
}
impl Histogram {
fn add(&mut self, value: u32) {
let w = if self.width == 0 { 10 } else { self.width };
*self.buckets.entry(value / w * w).or_default() += 1;
}
}
impl FromIterator<u32> for Histogram {
fn from_iter<I: IntoIterator<Item = u32>>(iter: I) -> Self {
let mut h = Histogram { width: 10, ..Default::default() };
h.extend(iter);
h
}
}
impl Extend<u32> for Histogram {
fn extend<I: IntoIterator<Item = u32>>(&mut self, iter: I) {
for v in iter {
self.add(v);
}
}
}
fn main() {
let latencies = [12, 15, 27, 31, 8, 22, 45, 19];
let mut h: Histogram = latencies.into_iter().collect();
h.extend([41, 44, 49]);
for (start, n) in &h.buckets {
println!("{start:>2}-{:<2} {}", start + h.width - 1, "#".repeat(*n));
}
} 0-9 #
10-19 ###
20-29 ##
30-39 #
40-49 ####23. Extension traits: adding methods to types you don't own
You can't add methods to str or Vec directly, but you can define a trait with the methods you want and implement it for them. Users opt in by importing the trait.
pub trait StrExt {
fn truncate_words(&self, max_words: usize) -> String;
fn is_blank(&self) -> bool;
}
impl StrExt for str {
fn truncate_words(&self, max_words: usize) -> String {
let words: Vec<&str> = self.split_whitespace().collect();
if words.len() <= max_words {
words.join(" ")
} else {
format!("{}...", words[..max_words].join(" "))
}
}
fn is_blank(&self) -> bool {
self.trim().is_empty()
}
}
fn main() {
let summary = "Rust gives you control over memory without a garbage collector";
println!("{}", summary.truncate_words(5));
println!("{}", "short text".truncate_words(5));
println!("{} {}", " ".is_blank(), String::from(" x ").is_blank()); // String derefs to str
}Rust gives you control over...
short text
true falseThe standard library and popular crates use this everywhere: Read/Write, Iterator adapters from itertools, StreamExt, FutureExt.
24. Callbacks: impl Fn, generics, or Box<dyn Fn>
How you accept a closure depends on what you do with it. Call it right away: impl Fn. Store it in a struct: a generic parameter (fast) or Box<dyn Fn> (flexible, allows a list of different closures).
// Called immediately: impl Fn.
fn retry_count(attempts: u32, op: impl Fn(u32) -> bool) -> Option<u32> {
(1..=attempts).find(|&n| op(n))
}
// Stored, one closure type per struct: a generic.
struct Validator<F: Fn(&str) -> bool> {
name: &'static str,
check: F,
}
// Stored, many different closures: Box<dyn Fn>.
struct Hooks {
on_save: Vec<Box<dyn Fn(&str) -> String>>,
}
fn main() {
println!("{:?}", retry_count(5, |n| n * n > 10));
let v = Validator { name: "no-spaces", check: |s: &str| !s.contains(' ') };
println!("{}: {}", v.name, (v.check)("hello world"));
let prefix = String::from("[audit]");
let hooks = Hooks {
on_save: vec![Box::new(|doc| format!("indexed {doc}")), Box::new(move |doc| format!("{prefix} saved {doc}"))],
};
for h in &hooks.on_save {
println!("{}", h("report.md"));
}
}Some(4)
no-spaces: false
indexed report.md
[audit] saved report.md25. Getters that return Option<&T>, not &Option<T>
Return Option<&T> from getters, via as_ref() or as_deref(). It's easier to use (if let Some(x)), hides how you store the value, and works with ?.
struct Profile {
nickname: Option<String>,
avatar_urls: Option<Vec<String>>,
}
impl Profile {
fn nickname(&self) -> Option<&str> {
self.nickname.as_deref() // Option<String> -> Option<&str>
}
fn first_avatar(&self) -> Option<&str> {
self.avatar_urls.as_ref()?.first().map(String::as_str)
}
}
fn main() {
let a = Profile { nickname: Some(String::from("ana_dev")), avatar_urls: Some(vec![String::from("/a.png")]) };
let b = Profile { nickname: None, avatar_urls: None };
for p in [&a, &b] {
println!("{} / {}", p.nickname().unwrap_or("anonymous"), p.first_avatar().unwrap_or("/default.png"));
}
}ana_dev / /a.png
anonymous / /default.png26. Evolving an API without breaking users
Public APIs change. A few habits keep changes non-breaking:
| Habit | Why |
|---|---|
| keep struct fields private; offer constructors and getters | you can add or change fields freely |
#[non_exhaustive] on public enums and structs |
you can add variants or fields in a minor version |
| sealed traits for traits users shouldn't implement | you can add methods to them later |
accept impl Into<X> / impl AsRef<X> |
you can widen accepted types later |
return impl Iterator instead of a concrete iterator type |
you can change the implementation |
#[deprecated(since, note)] before removing things |
users get a warning and a migration hint |
The Crates & Workspaces and Modules lessons show #[non_exhaustive], #[deprecated], and sealed traits in detail. Tools like cargo-semver-checks catch accidental breaking changes before you publish.
27. Realistic: a Money type that's hard to misuse
Bringing several idioms together: a newtype with private fields, parsing via FromStr, a Display for users, checked arithmetic that refuses to mix currencies, and Sum so a list of amounts can be totaled with .sum().
use std::fmt;
use std::iter::Sum;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Currency {
Eur,
Usd,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Money {
cents: i64,
currency: Currency,
}
impl Money {
pub fn zero(currency: Currency) -> Money {
Money { cents: 0, currency }
}
pub fn checked_add(self, other: Money) -> Result<Money, String> {
if self.currency != other.currency {
return Err(format!("cannot add {} to {}", other, self));
}
Ok(Money { cents: self.cents + other.cents, currency: self.currency })
}
}
impl FromStr for Money {
type Err = String;
fn from_str(s: &str) -> Result<Money, String> {
let (amount, code) = s.trim().rsplit_once(' ').ok_or(format!("{s:?}: expected e.g. \"12.50 EUR\""))?;
let currency = match code {
"EUR" => Currency::Eur,
"USD" => Currency::Usd,
other => return Err(format!("unknown currency {other}")),
};
let (whole, frac) = amount.split_once('.').unwrap_or((amount, "0"));
let frac = format!("{frac:0<2}");
let bad_amount = |_| format!("{s:?}: invalid amount {amount:?}");
let cents = whole.parse::<i64>().map_err(bad_amount)? * 100 + frac[..2].parse::<i64>().map_err(bad_amount)?;
Ok(Money { cents, currency })
}
}
impl fmt::Display for Money {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let code = match self.currency {
Currency::Eur => "EUR",
Currency::Usd => "USD",
};
write!(f, "{}.{:02} {code}", self.cents / 100, self.cents % 100)
}
}
/// Sums amounts of one currency. Panics on mixed currencies; use `checked_add` to handle that case.
impl Sum for Money {
fn sum<I: Iterator<Item = Money>>(mut iter: I) -> Money {
let first = iter.next().unwrap_or(Money::zero(Currency::Eur));
iter.fold(first, |acc, m| acc.checked_add(m).expect("mixed currencies"))
}
}
fn main() {
let lines: Vec<Money> = ["12.50 EUR", "3.5 EUR", "100 EUR"].iter().map(|s| s.parse().unwrap()).collect();
let total: Money = lines.iter().copied().sum();
println!("total: {total}");
let usd: Money = "9.99 USD".parse().unwrap();
println!("{:?}", total.checked_add(usd));
println!("{:?}", "12,50 EUR".parse::<Money>());
println!("{:?}", "5 GBP".parse::<Money>());
}total: 116.00 EUR
Err("cannot add 9.99 USD to 116.00 EUR")
Err("\"12,50 EUR\": invalid amount \"12,50\"")
Err("unknown currency GBP")28. Realistic: a small client API
A client for a hypothetical REST service, using most of the lesson: a builder with sensible defaults, impl Into<String> parameters, a newtype id, an error enum with Display, Option<&T> getters, and a method returning an iterator.
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ProjectId(u32);
#[derive(Debug)]
pub struct Project {
id: ProjectId,
name: String,
owner: Option<String>,
}
impl Project {
pub fn id(&self) -> ProjectId {
self.id
}
pub fn name(&self) -> &str {
&self.name
}
pub fn owner(&self) -> Option<&str> {
self.owner.as_deref()
}
}
#[derive(Debug)]
pub enum ClientError {
NotFound(ProjectId),
Unauthorized,
}
impl fmt::Display for ClientError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ClientError::NotFound(id) => write!(f, "project {} not found", id.0),
ClientError::Unauthorized => write!(f, "missing or invalid API token"),
}
}
}
impl std::error::Error for ClientError {}
pub struct Client {
token: Option<String>,
base_url: String,
projects: HashMap<ProjectId, Project>, // stands in for the remote service
}
pub struct ClientBuilder {
token: Option<String>,
base_url: String,
}
impl Client {
pub fn builder() -> ClientBuilder {
ClientBuilder { token: None, base_url: String::from("https://api.example.com") }
}
pub fn project(&self, id: ProjectId) -> Result<&Project, ClientError> {
self.token.as_ref().ok_or(ClientError::Unauthorized)?;
self.projects.get(&id).ok_or(ClientError::NotFound(id))
}
pub fn projects_owned_by<'a>(&'a self, owner: &'a str) -> impl Iterator<Item = &'a Project> + 'a {
self.projects.values().filter(move |p| p.owner() == Some(owner))
}
}
impl ClientBuilder {
pub fn token(mut self, t: impl Into<String>) -> Self {
self.token = Some(t.into());
self
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
pub fn build(self) -> Client {
let projects = [(1, "website", Some("ana")), (2, "mobile-app", Some("ben")), (3, "docs", Some("ana")), (4, "sandbox", None)]
.into_iter()
.map(|(id, name, owner)| (ProjectId(id), Project { id: ProjectId(id), name: name.into(), owner: owner.map(Into::into) }))
.collect();
Client { token: self.token, base_url: self.base_url, projects }
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::builder().token("secret-token").build();
let p = client.project(ProjectId(2))?;
println!("{} (id {:?}) owned by {:?} via {}", p.name(), p.id(), p.owner(), client.base_url);
let mut anas: Vec<&str> = client.projects_owned_by("ana").map(Project::name).collect();
anas.sort();
println!("ana owns {anas:?}");
println!("{}", client.project(ProjectId(9)).unwrap_err());
let anonymous = Client::builder().base_url("http://localhost:8080").build();
println!("{}", anonymous.project(ProjectId(1)).unwrap_err());
Ok(())
}mobile-app (id ProjectId(2)) owned by Some("ben") via https://api.example.com
ana owns ["docs", "website"]
project 9 not found
missing or invalid API tokenSummary
- Follow the naming conventions (
as_/to_/into_, getters withoutget_,is_,iter/iter_mut/into_iter,new/with_/from_) so callers can predict cost and ownership. - Accept general inputs (
&str,&[T],impl AsRef<Path>,impl Into<String>,impl IntoIterator) and returnimpl Iteratorrather than buildingVecs. - Put rules in types: newtypes for units and ids, parse-don't-validate constructors, enums whose variants carry exactly their data, and typestate for fixed sequences (including builders whose
buildonly exists when required fields are set). - Use
Defaultplus struct update syntax for simple configuration; builders when construction needs validation. - Implement the standard traits users expect:
Debug,Clone,PartialEq/Eq/Hash/Ord,Default,Display,FromStr,From/TryFrom,IntoIterator,FromIterator,Extend, andSumwhere they fit. - Extension traits add methods to foreign types; enums beat boolean flags; getters return
Option<&T>. - Plan for evolution: private fields,
#[non_exhaustive], sealed traits, and#[deprecated].
This completes the Advanced Rust section. Next: How the Rust Compiler Works, the start of the Under the Hood section.
