Best Practices and Idiomatic Rust
Writing Rust that compiles is one thing. Writing Rust that is clean, efficient, and idiomatic is another. This chapter covers patterns, pitfalls, and guidelines that separate beginner Rust from professional Rust.
Idiomatic Patterns
Use Iterators Instead of Loops
Iterators are not just syntactic sugar - the compiler optimizes them aggressively:
// Avoid: manual loop with index
fn sum_even_squares_loop(numbers: &[i32]) -> i32 {
let mut sum = 0;
for i in 0..numbers.len() {
if numbers[i] % 2 == 0 {
sum += numbers[i] * numbers[i];
}
}
sum
}
// Prefer: iterator chain
fn sum_even_squares(numbers: &[i32]) -> i32 {
numbers
.iter()
.filter(|&&n| n % 2 == 0)
.map(|&n| n * n)
.sum()
}
Use Destructuring Freely
struct Config {
host: String,
port: u16,
debug: bool,
}
// Avoid: accessing fields repeatedly
fn setup_bad(config: &Config) {
println!("Connecting to {}:{}", config.host, config.port);
if config.debug {
println!("Debug mode enabled");
}
}
// Prefer: destructure when accessing multiple fields
fn setup(config: &Config) {
let Config { host, port, debug } = config;
println!("Connecting to {}:{}", host, port);
if *debug {
println!("Debug mode enabled");
}
}
// Works great with tuples and enums too
fn process_result(result: Result<(String, u32), String>) {
match result {
Ok((name, age)) => println!("{} is {}", name, age),
Err(e) => eprintln!("Error: {}", e),
}
}
Prefer &str Over &String
// Avoid: forces callers to have a String
fn greet_bad(name: &String) {
println!("Hello, {}!", name);
}
// Prefer: accepts both &String and &str
fn greet(name: &str) {
println!("Hello, {}!", name);
}
fn main() {
let owned = String::from("Alice");
let borrowed = "Bob";
greet(&owned); // &String coerces to &str
greet(borrowed); // Already &str
}
This applies generally - prefer borrowing slices over borrowing containers:
| Instead of | Use |
|---|---|
&String | &str |
&Vec<T> | &[T] |
&Box<T> | &T |
Use Into and AsRef for Flexible APIs
// Accepts anything that can become a String
fn set_name(name: impl Into<String>) {
let name: String = name.into();
println!("Name set to: {}", name);
}
// Accepts anything that can be referenced as a path
fn read_config(path: impl AsRef<std::path::Path>) {
let path = path.as_ref();
println!("Reading: {}", path.display());
}
fn main() {
set_name("Alice"); // &str
set_name(String::from("Bob")); // String
read_config("config.toml"); // &str
read_config(String::from("app.toml")); // String
read_config(std::path::Path::new("x")); // &Path
}
Builder Pattern
For structs with many optional fields:
#[derive(Debug)]
struct Server {
host: String,
port: u16,
max_connections: u32,
timeout_secs: u64,
tls: bool,
}
struct ServerBuilder {
host: String,
port: u16,
max_connections: u32,
timeout_secs: u64,
tls: bool,
}
impl ServerBuilder {
fn new(host: impl Into<String>, port: u16) -> Self {
ServerBuilder {
host: host.into(),
port,
max_connections: 100,
timeout_secs: 30,
tls: false,
}
}
fn max_connections(mut self, n: u32) -> Self {
self.max_connections = n;
self
}
fn timeout(mut self, secs: u64) -> Self {
self.timeout_secs = secs;
self
}
fn tls(mut self, enabled: bool) -> Self {
self.tls = enabled;
self
}
fn build(self) -> Server {
Server {
host: self.host,
port: self.port,
max_connections: self.max_connections,
timeout_secs: self.timeout_secs,
tls: self.tls,
}
}
}
fn main() {
let server = ServerBuilder::new("localhost", 8080)
.max_connections(500)
.tls(true)
.timeout(60)
.build();
println!("{:?}", server);
}
Newtype Pattern
Wrap primitive types for type safety:
struct UserId(u64);
struct OrderId(u64);
struct Email(String);
// These are different types - can't mix them up
fn get_user_orders(user_id: UserId) -> Vec<OrderId> {
vec![OrderId(1001), OrderId(1002)]
}
fn send_notification(email: &Email, message: &str) {
println!("Sending '{}' to {}", message, email.0);
}
fn main() {
let user = UserId(42);
let orders = get_user_orders(user);
// get_user_orders(OrderId(1)); // ERROR: expected UserId, got OrderId
let email = Email("user@example.com".to_string());
send_notification(&email, "Your order shipped");
}
Use collect Wisely
collect() can build many different collection types:
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
// Collect into Vec
let doubled: Vec<i32> = numbers.iter().map(|&n| n * 2).collect();
// Collect into String
let csv: String = numbers
.iter()
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join(", ");
println!("{}", csv); // "1, 2, 3, 4, 5"
// Collect into HashMap
use std::collections::HashMap;
let map: HashMap<&str, i32> = vec![("a", 1), ("b", 2), ("c", 3)]
.into_iter()
.collect();
// Collect Results - stops at first error
let results: Result<Vec<i32>, _> = vec!["1", "2", "three", "4"]
.iter()
.map(|s| s.parse::<i32>())
.collect();
println!("{:?}", results); // Err(ParseIntError)
// Partition into two collections
let (evens, odds): (Vec<i32>, Vec<i32>) = numbers
.iter()
.partition(|&&n| n % 2 == 0);
println!("Evens: {:?}, Odds: {:?}", evens, odds);
}
Error Handling Patterns
Use thiserror for Libraries
// In Cargo.toml: thiserror = "1"
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("file not found: {path}")]
NotFound { path: String },
#[error("permission denied: {0}")]
PermissionDenied(String),
#[error("invalid configuration: {field} = {value}")]
InvalidConfig { field: String, value: String },
#[error("database error")]
Database(#[from] DatabaseError),
#[error("I/O error")]
Io(#[from] std::io::Error),
}
#[derive(Error, Debug)]
#[error("database connection failed: {message}")]
pub struct DatabaseError {
message: String,
}
fn load_config(path: &str) -> Result<String, AppError> {
std::fs::read_to_string(path).map_err(|_| AppError::NotFound {
path: path.to_string(),
})
}
Use anyhow for Applications
// In Cargo.toml: anyhow = "1"
use anyhow::{Context, Result, bail, ensure};
fn read_config(path: &str) -> Result<Config> {
let content = std::fs::read_to_string(path)
.context(format!("Failed to read config from '{}'", path))?;
let config: Config = serde_json::from_str(&content)
.context("Failed to parse config as JSON")?;
ensure!(config.port > 0, "Port must be positive, got {}", config.port);
if config.host.is_empty() {
bail!("Host cannot be empty");
}
Ok(config)
}
fn main() -> Result<()> {
let config = read_config("app.json")?;
println!("Starting on {}:{}", config.host, config.port);
Ok(())
}
The ? Operator Chain
use std::fs;
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
enum ConfigError {
Io(io::Error),
Parse(ParseIntError),
}
impl From<io::Error> for ConfigError {
fn from(e: io::Error) -> Self {
ConfigError::Io(e)
}
}
impl From<ParseIntError> for ConfigError {
fn from(e: ParseIntError) -> Self {
ConfigError::Parse(e)
}
}
fn read_port(path: &str) -> Result<u16, ConfigError> {
let content = fs::read_to_string(path)?; // io::Error -> ConfigError
let port = content.trim().parse::<u16>()?; // ParseIntError -> ConfigError
Ok(port)
}
When to Use Which
| Situation | Use |
|---|---|
| Library code | thiserror with custom error enum |
| Application code | anyhow for quick error handling |
| Prototype / scripts | unwrap() / expect() sparingly |
| Never in production | unwrap() on user input or I/O |
Common Pitfalls
1. Clone Abuse
// Avoid: cloning to satisfy the borrow checker
fn process(data: &Vec<String>) {
let copy = data.clone(); // Expensive and unnecessary
for item in copy {
println!("{}", item);
}
}
// Prefer: borrow properly
fn process_better(data: &[String]) {
for item in data {
println!("{}", item);
}
}
If you find yourself adding .clone() everywhere, step back and reconsider your ownership model.
2. Unnecessary Allocation
// Avoid: allocating a String just to compare
fn is_hello(s: &str) -> bool {
s.to_string() == String::from("hello")
}
// Prefer: compare slices directly
fn is_hello_better(s: &str) -> bool {
s == "hello"
}
// Avoid: collecting into a Vec just to iterate
fn sum_doubled(numbers: &[i32]) -> i32 {
let doubled: Vec<i32> = numbers.iter().map(|&n| n * 2).collect();
doubled.iter().sum()
}
// Prefer: chain iterators lazily
fn sum_doubled_better(numbers: &[i32]) -> i32 {
numbers.iter().map(|&n| n * 2).sum()
}
3. String Formatting in Hot Paths
// Avoid: format! allocates a new String every call
fn log_value(key: &str, value: i32) {
let msg = format!("{}: {}", key, value);
println!("{}", msg);
}
// Prefer: write directly when possible
fn log_value_better(key: &str, value: i32) {
println!("{}: {}", key, value);
}
4. Not Using Entry API for Maps
use std::collections::HashMap;
fn count_words(text: &str) -> HashMap<&str, u32> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
// Avoid: double lookup
// if counts.contains_key(word) {
// *counts.get_mut(word).unwrap() += 1;
// } else {
// counts.insert(word, 1);
// }
// Prefer: entry API (single lookup)
*counts.entry(word).or_insert(0) += 1;
}
counts
}
fn main() {
let text = "the cat sat on the mat the cat";
let counts = count_words(text);
println!("{:?}", counts);
}
5. Ignoring Clippy Warnings
# Run clippy and treat warnings as errors
cargo clippy -- -D warnings
Clippy catches hundreds of common issues. Some important lints:
// Clippy warns: use `is_empty()` instead of `len() == 0`
if vec.len() == 0 { } // Bad
if vec.is_empty() { } // Good
// Clippy warns: redundant closure
list.iter().map(|x| foo(x)) // Bad
list.iter().map(foo) // Good
// Clippy warns: manual implementation of `map`
match option { // Bad
Some(x) => Some(x + 1),
None => None,
}
option.map(|x| x + 1) // Good
Performance Tips
Pre-allocate Collections
fn main() {
// Avoid: grows and reallocates multiple times
let mut v = Vec::new();
for i in 0..10000 {
v.push(i);
}
// Prefer: allocate once
let mut v = Vec::with_capacity(10000);
for i in 0..10000 {
v.push(i);
}
// Same for String
let mut s = String::with_capacity(1024);
for _ in 0..100 {
s.push_str("hello ");
}
// Same for HashMap
use std::collections::HashMap;
let mut map = HashMap::with_capacity(100);
for i in 0..100 {
map.insert(i, i * 2);
}
}
Use Cow for Conditional Ownership
use std::borrow::Cow;
// Returns borrowed data when possible, owned when needed
fn normalize_name(name: &str) -> Cow<str> {
if name.chars().all(|c| c.is_lowercase()) {
// Already normalized - return borrowed, no allocation
Cow::Borrowed(name)
} else {
// Need to transform - must allocate
Cow::Owned(name.to_lowercase())
}
}
fn main() {
let already_lower = normalize_name("alice"); // No allocation
let needs_work = normalize_name("ALICE"); // Allocates
// Both can be used as &str
println!("{}, {}", already_lower, needs_work);
}
Avoid Unnecessary Boxing
// Avoid: boxing when the size is known at compile time
fn process(data: Box<Vec<i32>>) {
println!("{:?}", data);
}
// Prefer: pass by reference
fn process_better(data: &[i32]) {
println!("{:?}", data);
}
// Box is appropriate for:
// - Recursive types
// - Trait objects (Box<dyn Trait>)
// - Large structs you want on the heap
// Recursive type needs Box
enum List {
Cons(i32, Box<List>),
Nil,
}
Use Rayon for Easy Parallelism
// In Cargo.toml: rayon = "1"
use rayon::prelude::*;
fn main() {
let numbers: Vec<i64> = (0..1_000_000).collect();
// Sequential
let sum: i64 = numbers.iter().map(|&n| n * n).sum();
// Parallel - just change iter() to par_iter()
let sum_parallel: i64 = numbers.par_iter().map(|&n| n * n).sum();
// Parallel sort
let mut data = vec![5, 2, 8, 1, 9, 3];
data.par_sort();
// Parallel map
let results: Vec<String> = numbers
.par_iter()
.filter(|&&n| n % 2 == 0)
.map(|n| format!("item_{}", n))
.collect();
}
API Design Guidelines
Make Invalid States Unrepresentable
// Avoid: invalid states are possible
struct Connection {
url: String,
connected: bool,
session_id: Option<String>, // Must be Some when connected
}
// Prefer: use the type system to enforce invariants
enum Connection {
Disconnected { url: String },
Connected { url: String, session_id: String },
}
impl Connection {
fn connect(url: String) -> Result<Connection, String> {
let session_id = establish_session(&url)?;
Ok(Connection::Connected { url, session_id })
}
fn session_id(&self) -> Option<&str> {
match self {
Connection::Connected { session_id, .. } => Some(session_id),
Connection::Disconnected { .. } => None,
}
}
}
fn establish_session(url: &str) -> Result<String, String> {
Ok(format!("session_{}", url.len()))
}
Use Type State Pattern
Encode state transitions in the type system:
struct Draft;
struct Review;
struct Published;
struct Post<State> {
title: String,
content: String,
_state: std::marker::PhantomData<State>,
}
impl Post<Draft> {
fn new(title: &str) -> Self {
Post {
title: title.to_string(),
content: String::new(),
_state: std::marker::PhantomData,
}
}
fn add_content(&mut self, text: &str) {
self.content.push_str(text);
}
fn submit_for_review(self) -> Post<Review> {
Post {
title: self.title,
content: self.content,
_state: std::marker::PhantomData,
}
}
}
impl Post<Review> {
fn approve(self) -> Post<Published> {
Post {
title: self.title,
content: self.content,
_state: std::marker::PhantomData,
}
}
fn reject(self) -> Post<Draft> {
Post {
title: self.title,
content: self.content,
_state: std::marker::PhantomData,
}
}
}
impl Post<Published> {
fn content(&self) -> &str {
&self.content
}
}
fn main() {
let mut post = Post::<Draft>::new("My Post");
post.add_content("Great content here.");
let post = post.submit_for_review();
// post.add_content("more"); // ERROR: no method on Post<Review>
let post = post.approve();
println!("{}", post.content());
// post.approve(); // ERROR: no method on Post<Published>
}
Accept Generics, Return Concrete Types
use std::collections::HashMap;
// Accept generic inputs
fn insert_items<K, V>(map: &mut HashMap<K, V>, items: impl IntoIterator<Item = (K, V)>)
where
K: Eq + std::hash::Hash,
{
for (key, value) in items {
map.insert(key, value);
}
}
// Return concrete types (not impl Trait, when possible)
fn create_lookup(pairs: &[(&str, i32)]) -> HashMap<String, i32> {
pairs
.iter()
.map(|&(k, v)| (k.to_string(), v))
.collect()
}
fn main() {
let mut map = HashMap::new();
// Works with Vec of tuples
insert_items(&mut map, vec![("a", 1), ("b", 2)]);
// Works with arrays
insert_items(&mut map, [("c", 3), ("d", 4)]);
println!("{:?}", map);
}
Implement Standard Traits
Make your types work naturally in the Rust ecosystem:
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Money {
cents: i64,
currency: String,
}
impl Money {
fn new(amount: f64, currency: &str) -> Self {
Money {
cents: (amount * 100.0) as i64,
currency: currency.to_string(),
}
}
fn amount(&self) -> f64 {
self.cents as f64 / 100.0
}
}
impl fmt::Display for Money {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:.2} {}", self.amount(), self.currency)
}
}
impl Default for Money {
fn default() -> Self {
Money {
cents: 0,
currency: "USD".to_string(),
}
}
}
impl std::ops::Add for Money {
type Output = Self;
fn add(self, other: Self) -> Self {
assert_eq!(self.currency, other.currency, "Currency mismatch");
Money {
cents: self.cents + other.cents,
currency: self.currency,
}
}
}
fn main() {
let a = Money::new(10.50, "USD");
let b = Money::new(3.75, "USD");
let total = a + b;
println!("Total: {}", total); // Display
println!("Debug: {:?}", total); // Debug
println!("Default: {}", Money::default()); // Default
}
Clippy Configuration
Configure Clippy in clippy.toml or via attributes:
// Allow specific lints in code
#[allow(clippy::too_many_arguments)]
fn complex_function(a: i32, b: i32, c: i32, d: i32, e: i32, f: i32) {}
// Deny specific lints project-wide (put in lib.rs or main.rs)
#![deny(clippy::unwrap_used)]
#![deny(clippy::expect_used)]
#![warn(clippy::pedantic)]
# Run with extra strict lints
cargo clippy -- -W clippy::pedantic
# Run with specific lint groups
cargo clippy -- -W clippy::nursery
# Fix automatically where possible
cargo clippy --fix
Useful Clippy Lint Groups
| Group | Purpose |
|---|---|
clippy::correctness | Likely bugs (enabled by default) |
clippy::suspicious | Suspicious code patterns |
clippy::style | Style issues (enabled by default) |
clippy::complexity | Overly complex code |
clippy::perf | Performance issues |
clippy::pedantic | Strict but opinionated lints |
clippy::nursery | Experimental lints |
Practice Exercises
Exercise 1: Refactor to Idiomatic
Take this code and make it idiomatic:
fn process(items: &Vec<String>) -> Vec<String> {
let mut result = Vec::new();
for i in 0..items.len() {
if items[i].len() > 3 {
result.push(items[i].clone().to_uppercase());
}
}
return result;
}
Exercise 2: Error Hierarchy
Design an error type hierarchy for a file-processing application that handles I/O errors, parse errors, and validation errors with proper context.
Exercise 3: Type-Safe API
Design a type-safe API for an HTTP request builder that ensures a URL is set before the request can be sent (using the type state or builder pattern).
Common Mistakes
1. Using to_string() When to_owned() Suffices
// Both work, but mean different things
let s: String = "hello".to_string(); // Uses Display trait
let s: String = "hello".to_owned(); // Converts borrowed to owned
// For &str -> String, to_owned() is slightly more idiomatic
// For types implementing Display, to_string() is fine
2. Not Using if let for Single-Pattern Matches
// Avoid: full match for one variant
match value {
Some(x) => println!("{}", x),
None => {}
}
// Prefer: if let
if let Some(x) = value {
println!("{}", x);
}
3. Forgetting that Iterators are Lazy
fn main() {
let v = vec![1, 2, 3];
// This does NOTHING - iterator is never consumed
v.iter().map(|x| {
println!("{}", x);
x * 2
});
// Must consume the iterator
let _: Vec<_> = v.iter().map(|x| x * 2).collect();
// Or use for_each for side effects
v.iter().for_each(|x| println!("{}", x));
}
Key Takeaways
- Prefer iterators over manual loops for clarity and performance
- Accept borrows (
&str,&[T]) over owned types in function parameters - Use the type system to make invalid states impossible
- Handle errors explicitly -
thiserrorfor libraries,anyhowfor apps - Run
cargo clippyon every project - it catches real bugs - Pre-allocate collections when you know the size
- Avoid
.clone()as a fix for borrow checker issues - redesign ownership instead - Implement standard traits (
Display,Debug,Default,From) to integrate with the ecosystem - The builder pattern handles complex construction elegantly
- The newtype pattern adds type safety to primitive values
Quick Reference
// Idiomatic patterns
let x: &str = &my_string; // Borrow as slice
let v: &[T] = &my_vec; // Borrow as slice
items.iter().filter(..).map(..).collect(); // Iterator chain
*map.entry(key).or_insert(0) += 1; // Entry API
Cow::Borrowed(s) / Cow::Owned(s) // Conditional ownership
// Error handling
use thiserror::Error; // Library errors
use anyhow::{Result, Context}; // App errors
foo().context("what failed")?; // Add context
// Performance
Vec::with_capacity(n) // Pre-allocate
String::with_capacity(n) // Pre-allocate
.par_iter() // Rayon parallelism
// Clippy
#![deny(clippy::unwrap_used)] // Ban unwrap
cargo clippy -- -W clippy::pedantic // Strict mode
cargo clippy --fix // Auto-fix