Design patterns and idioms are essential tools in a programmer’s toolkit, providing battle-tested solutions to common problems and helping to create code that is maintainable, extensible, and idiomatic. In Rust, many traditional design patterns from object-oriented languages have been reimagined to leverage the language’s unique features like ownership, traits, and algebraic data types. Additionally, Rust has developed its own set of idioms that help developers write code that is not only correct and efficient but also feels natural within the Rust ecosystem.
In this comprehensive guide, we’ll explore a wide range of design patterns and idioms specific to Rust, from memory management patterns like RAII to structural patterns like builder and visitor. You’ll learn how these patterns can be implemented in Rust, when to use them, and how they compare to similar patterns in other languages. By the end, you’ll have a solid understanding of how to write idiomatic Rust code that leverages the language’s strengths and follows established best practices.
Memory Management Patterns
Rust’s ownership system influences how we manage resources:
RAII (Resource Acquisition Is Initialization)
RAII is a fundamental pattern in Rust where resources are acquired during initialization and released when the object goes out of scope:
struct File {
handle: std::fs::File,
}
impl File {
fn new(path: &str) -> Result<Self, std::io::Error> {
let handle = std::fs::File::open(path)?;
Ok(File { handle })
}
fn read_to_string(&mut self) -> Result<String, std::io::Error> {
let mut content = String::new();
self.handle.read_to_string(&mut content)?;
Ok(content)
}
}
// The file is automatically closed when `file` goes out of scope
fn process_file(path: &str) -> Result<String, std::io::Error> {
let mut file = File::new(path)?;
file.read_to_string()
}
Drop Guard
A drop guard ensures that cleanup code runs even if a function returns early:
struct CleanupGuard<F: FnMut()> {
cleanup: F,
}
impl<F: FnMut()> Drop for CleanupGuard<F> {
fn drop(&mut self) {
(self.cleanup)();
}
}
fn with_cleanup<F: FnMut()>(cleanup: F) -> CleanupGuard<F> {
CleanupGuard { cleanup }
}
fn process_data() -> Result<(), std::io::Error> {
// Set up some resource
let resource = setup_resource()?;
// Create a guard that will clean up the resource
let _guard = with_cleanup(|| {
cleanup_resource(&resource);
});
// Process the resource (may return early)
if let Err(e) = process_resource(&resource) {
return Err(e);
}
// More processing (may also return early)
if let Err(e) = more_processing(&resource) {
return Err(e);
}
// The guard will clean up the resource when it goes out of scope
Ok(())
}
Scoped Operations
Scoped operations ensure that resources are properly managed within a specific scope:
use std::sync::{Mutex, MutexGuard};
struct Database {
data: Mutex<Vec<String>>,
}
impl Database {
fn new() -> Self {
Database {
data: Mutex::new(Vec::new()),
}
}
// Returns a scoped guard that provides access to the data
fn access(&self) -> Result<MutexGuard<Vec<String>>, std::sync::PoisonError<MutexGuard<Vec<String>>>> {
self.data.lock()
}
}
fn main() {
let db = Database::new();
// The scope ensures the lock is released when `data` goes out of scope
{
let mut data = db.access().unwrap();
data.push("Hello".to_string());
data.push("World".to_string());
// Lock is automatically released here
}
// We can acquire the lock again
{
let data = db.access().unwrap();
println!("Data: {:?}", *data);
}
}
Creational Patterns
Patterns for object creation in Rust:
Builder Pattern
The builder pattern allows for flexible object construction with a fluent interface:
#[derive(Debug, Default)]
struct HttpRequest {
method: String,
url: String,
headers: std::collections::HashMap<String, String>,
body: Option<Vec<u8>>,
}
#[derive(Debug)]
struct HttpRequestBuilder {
request: HttpRequest,
}
impl HttpRequestBuilder {
fn new() -> Self {
HttpRequestBuilder {
request: HttpRequest::default(),
}
}
fn method(mut self, method: impl Into<String>) -> Self {
self.request.method = method.into();
self
}
fn url(mut self, url: impl Into<String>) -> Self {
self.request.url = url.into();
self
}
fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.request.headers.insert(key.into(), value.into());
self
}
fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
self.request.body = Some(body.into());
self
}
fn build(self) -> HttpRequest {
self.request
}
}
fn main() {
let request = HttpRequestBuilder::new()
.method("GET")
.url("https://example.com")
.header("User-Agent", "Rust")
.header("Accept", "text/html")
.build();
println!("{:?}", request);
}
Factory Method
The factory method pattern provides an interface for creating objects:
trait Animal {
fn make_sound(&self) -> &str;
}
struct Dog;
impl Animal for Dog {
fn make_sound(&self) -> &str {
"Woof!"
}
}
struct Cat;
impl Animal for Cat {
fn make_sound(&self) -> &str {
"Meow!"
}
}
enum AnimalType {
Dog,
Cat,
}
// Factory function
fn create_animal(animal_type: AnimalType) -> Box<dyn Animal> {
match animal_type {
AnimalType::Dog => Box::new(Dog),
AnimalType::Cat => Box::new(Cat),
}
}
fn main() {
let dog = create_animal(AnimalType::Dog);
let cat = create_animal(AnimalType::Cat);
println!("Dog says: {}", dog.make_sound());
println!("Cat says: {}", cat.make_sound());
}
Singleton Pattern
While traditional singletons are less common in Rust, we can achieve similar functionality:
use std::sync::{Arc, Mutex, Once};
use std::sync::atomic::{AtomicUsize, Ordering};
struct Logger {
log_count: AtomicUsize,
}
impl Logger {
fn log(&self, message: &str) {
let count = self.log_count.fetch_add(1, Ordering::SeqCst);
println!("[{}] {}", count, message);
}
}
// Thread-safe singleton
fn get_logger() -> Arc<Logger> {
static INIT: Once = Once::new();
static mut LOGGER: Option<Arc<Logger>> = None;
unsafe {
INIT.call_once(|| {
LOGGER = Some(Arc::new(Logger {
log_count: AtomicUsize::new(0),
}));
});
LOGGER.clone().unwrap()
}
}
fn main() {
let logger1 = get_logger();
let logger2 = get_logger();
logger1.log("Hello from logger1");
logger2.log("Hello from logger2");
logger1.log("Hello again from logger1");
}
Structural Patterns
Patterns that focus on object composition:
Adapter Pattern
The adapter pattern allows objects with incompatible interfaces to work together:
// Existing interface
trait OldSystem {
fn legacy_operation(&self, data: &str) -> String;
}
// New interface
trait NewSystem {
fn modern_operation(&self, input: &str) -> String;
}
// Concrete implementation of the old system
struct LegacySystem;
impl OldSystem for LegacySystem {
fn legacy_operation(&self, data: &str) -> String {
format!("Legacy: {}", data.to_uppercase())
}
}
// Adapter that makes the old system compatible with the new interface
struct SystemAdapter {
legacy: LegacySystem,
}
impl NewSystem for SystemAdapter {
fn modern_operation(&self, input: &str) -> String {
// Adapt the call to the legacy system
self.legacy.legacy_operation(input)
}
}
// Client code that works with the new interface
fn process_with_new_system(system: &impl NewSystem, input: &str) {
let result = system.modern_operation(input);
println!("Result: {}", result);
}
fn main() {
// Using the adapter
let legacy = LegacySystem;
let adapter = SystemAdapter { legacy };
process_with_new_system(&adapter, "hello world");
}
Decorator Pattern
The decorator pattern adds behavior to objects dynamically:
// Base component trait
trait DataSource {
fn read(&self) -> String;
fn write(&mut self, data: &str);
}
// Concrete component
struct FileDataSource {
filename: String,
data: String,
}
impl FileDataSource {
fn new(filename: &str) -> Self {
FileDataSource {
filename: filename.to_string(),
data: String::new(),
}
}
}
impl DataSource for FileDataSource {
fn read(&self) -> String {
println!("Reading from file: {}", self.filename);
self.data.clone()
}
fn write(&mut self, data: &str) {
println!("Writing to file: {}", self.filename);
self.data = data.to_string();
}
}
// Base decorator
struct DataSourceDecorator<T: DataSource> {
wrapped: T,
}
impl<T: DataSource> DataSource for DataSourceDecorator<T> {
fn read(&self) -> String {
self.wrapped.read()
}
fn write(&mut self, data: &str) {
self.wrapped.write(data)
}
}
// Concrete decorator: encryption
struct EncryptionDecorator<T: DataSource> {
source: DataSourceDecorator<T>,
}
impl<T: DataSource> EncryptionDecorator<T> {
fn new(source: T) -> Self {
EncryptionDecorator {
source: DataSourceDecorator { wrapped: source },
}
}
// Simple "encryption" for demonstration
fn encrypt(&self, data: &str) -> String {
data.chars().map(|c| ((c as u8) + 1) as char).collect()
}
fn decrypt(&self, data: &str) -> String {
data.chars().map(|c| ((c as u8) - 1) as char).collect()
}
}
impl<T: DataSource> DataSource for EncryptionDecorator<T> {
fn read(&self) -> String {
let data = self.source.read();
println!("Decrypting data");
self.decrypt(&data)
}
fn write(&mut self, data: &str) {
println!("Encrypting data");
let encrypted = self.encrypt(data);
self.source.write(&encrypted);
}
}
Composite Pattern
The composite pattern treats individual objects and compositions of objects uniformly:
use std::collections::HashMap;
// Component trait
trait FileSystemNode {
fn name(&self) -> &str;
fn size(&self) -> usize;
fn print(&self, indent: usize);
}
// Leaf node
struct File {
name: String,
content: Vec<u8>,
}
impl File {
fn new(name: &str, content: Vec<u8>) -> Self {
File {
name: name.to_string(),
content,
}
}
}
impl FileSystemNode for File {
fn name(&self) -> &str {
&self.name
}
fn size(&self) -> usize {
self.content.len()
}
fn print(&self, indent: usize) {
println!("{:indent$}File: {} ({} bytes)", "", self.name, self.size(), indent = indent);
}
}
// Composite node
struct Directory {
name: String,
children: HashMap<String, Box<dyn FileSystemNode>>,
}
impl Directory {
fn new(name: &str) -> Self {
Directory {
name: name.to_string(),
children: HashMap::new(),
}
}
fn add(&mut self, node: impl FileSystemNode + 'static) {
self.children.insert(node.name().to_string(), Box::new(node));
}
}
impl FileSystemNode for Directory {
fn name(&self) -> &str {
&self.name
}
fn size(&self) -> usize {
self.children.values().map(|child| child.size()).sum()
}
fn print(&self, indent: usize) {
println!("{:indent$}Directory: {} ({} bytes)", "", self.name, self.size(), indent = indent);
for child in self.children.values() {
child.print(indent + 4);
}
}
}
Behavioral Patterns
Patterns that focus on communication between objects:
Iterator Pattern
The iterator pattern provides a way to access elements of a collection sequentially:
struct Counter {
count: usize,
limit: usize,
}
impl Counter {
fn new(limit: usize) -> Self {
Counter { count: 0, limit }
}
}
impl Iterator for Counter {
type Item = usize;
fn next(&mut self) -> Option<Self::Item> {
if self.count < self.limit {
let current = self.count;
self.count += 1;
Some(current)
} else {
None
}
}
}
fn main() {
let counter = Counter::new(5);
// Use the iterator
for count in counter {
println!("Count: {}", count);
}
// Create another counter and use iterator methods
let sum: usize = Counter::new(10).sum();
println!("Sum: {}", sum);
let even_counts: Vec<_> = Counter::new(10).filter(|&n| n % 2 == 0).collect();
println!("Even counts: {:?}", even_counts);
}
Observer Pattern
The observer pattern allows objects to be notified of changes:
use std::cell::RefCell;
use std::rc::{Rc, Weak};
// Observer trait
trait Observer {
fn update(&self, message: &str);
}
// Concrete observer
struct ConcreteObserver {
name: String,
}
impl ConcreteObserver {
fn new(name: &str) -> Self {
ConcreteObserver {
name: name.to_string(),
}
}
}
impl Observer for ConcreteObserver {
fn update(&self, message: &str) {
println!("Observer {} received: {}", self.name, message);
}
}
// Subject that notifies observers
struct Subject {
observers: RefCell<Vec<Weak<dyn Observer>>>,
}
impl Subject {
fn new() -> Self {
Subject {
observers: RefCell::new(Vec::new()),
}
}
fn attach(&self, observer: Weak<dyn Observer>) {
self.observers.borrow_mut().push(observer);
}
fn notify(&self, message: &str) {
// Clean up any dropped observers
self.observers.borrow_mut().retain(|o| o.upgrade().is_some());
// Notify all active observers
for observer in self.observers.borrow().iter() {
if let Some(o) = observer.upgrade() {
o.update(message);
}
}
}
}
Strategy Pattern
The strategy pattern defines a family of algorithms and makes them interchangeable:
// Strategy trait
trait SortStrategy {
fn sort(&self, data: &mut [i32]);
}
// Concrete strategies
struct BubbleSort;
impl SortStrategy for BubbleSort {
fn sort(&self, data: &mut [i32]) {
println!("Sorting using bubble sort");
// Bubble sort implementation
let n = data.len();
for i in 0..n {
for j in 0..n - i - 1 {
if data[j] > data[j + 1] {
data.swap(j, j + 1);
}
}
}
}
}
struct QuickSort;
impl SortStrategy for QuickSort {
fn sort(&self, data: &mut [i32]) {
println!("Sorting using quick sort");
// Quick sort implementation (simplified)
if data.len() <= 1 {
return;
}
// Simplified implementation for demonstration
data.sort();
}
}
// Context that uses a strategy
struct Sorter {
strategy: Box<dyn SortStrategy>,
}
impl Sorter {
fn new(strategy: Box<dyn SortStrategy>) -> Self {
Sorter { strategy }
}
fn set_strategy(&mut self, strategy: Box<dyn SortStrategy>) {
self.strategy = strategy;
}
fn sort(&self, data: &mut [i32]) {
self.strategy.sort(data);
}
}
Rust-Specific Idioms
Patterns that are particularly idiomatic in Rust:
Newtype Pattern
The newtype pattern creates a new type that wraps an existing type, providing type safety and encapsulation:
// Newtype for user IDs
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct UserId(u64);
// Newtype for product IDs
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct ProductId(u64);
// Functions that accept specific types
fn get_user(id: UserId) -> Option<User> {
// Look up user by ID
Some(User { id, name: "Alice".to_string() })
}
fn get_product(id: ProductId) -> Option<Product> {
// Look up product by ID
Some(Product { id, name: "Widget".to_string() })
}
// User and Product structs
struct User {
id: UserId,
name: String,
}
struct Product {
id: ProductId,
name: String,
}
Type-State Pattern
The type-state pattern encodes state transitions in the type system:
// States
struct Idle;
struct Running;
struct Paused;
struct Stopped;
// Machine with type-state
struct StateMachine<S> {
state: S,
}
// Constructors and transitions
impl StateMachine<Idle> {
fn new() -> Self {
StateMachine { state: Idle }
}
fn start(self) -> StateMachine<Running> {
println!("Starting the machine");
StateMachine { state: Running }
}
}
impl StateMachine<Running> {
fn pause(self) -> StateMachine<Paused> {
println!("Pausing the machine");
StateMachine { state: Paused }
}
fn stop(self) -> StateMachine<Stopped> {
println!("Stopping the machine");
StateMachine { state: Stopped }
}
}
impl StateMachine<Paused> {
fn resume(self) -> StateMachine<Running> {
println!("Resuming the machine");
StateMachine { state: Running }
}
fn stop(self) -> StateMachine<Stopped> {
println!("Stopping the machine");
StateMachine { state: Stopped }
}
}
impl StateMachine<Stopped> {
fn reset(self) -> StateMachine<Idle> {
println!("Resetting the machine");
StateMachine { state: Idle }
}
}
Visitor Pattern
The visitor pattern allows adding operations to objects without modifying them:
// Element trait
trait Element {
fn accept(&self, visitor: &dyn Visitor);
}
// Concrete elements
struct Circle {
radius: f64,
}
impl Element for Circle {
fn accept(&self, visitor: &dyn Visitor) {
visitor.visit_circle(self);
}
}
struct Rectangle {
width: f64,
height: f64,
}
impl Element for Rectangle {
fn accept(&self, visitor: &dyn Visitor) {
visitor.visit_rectangle(self);
}
}
// Visitor trait
trait Visitor {
fn visit_circle(&self, circle: &Circle);
fn visit_rectangle(&self, rectangle: &Rectangle);
}
// Concrete visitors
struct AreaCalculator;
impl Visitor for AreaCalculator {
fn visit_circle(&self, circle: &Circle) {
let area = std::f64::consts::PI * circle.radius * circle.radius;
println!("Circle area: {:.2}", area);
}
fn visit_rectangle(&self, rectangle: &Rectangle) {
let area = rectangle.width * rectangle.height;
println!("Rectangle area: {:.2}", area);
}
}
Best Practices for Design Patterns in Rust
Based on experience from real-world Rust projects, here are some best practices:
1. Prefer Composition Over Inheritance
Rust doesn’t have inheritance, so use composition and traits instead:
// Instead of inheritance, use composition
struct Logger {
// ...
}
struct DatabaseConnection {
// ...
}
struct Application {
logger: Logger,
db: DatabaseConnection,
}
// Use traits for shared behavior
trait Loggable {
fn log(&self, message: &str);
}
impl Loggable for Logger {
fn log(&self, message: &str) {
println!("[LOG] {}", message);
}
}
impl Loggable for Application {
fn log(&self, message: &str) {
self.logger.log(message);
}
}
2. Leverage Rust’s Type System
Use Rust’s type system to enforce correctness:
// Use enums for state machines
enum ConnectionState {
Disconnected,
Connecting,
Connected,
Failed(String),
}
struct Connection {
state: ConnectionState,
}
impl Connection {
fn new() -> Self {
Connection { state: ConnectionState::Disconnected }
}
fn connect(&mut self) {
self.state = match self.state {
ConnectionState::Disconnected => ConnectionState::Connecting,
ConnectionState::Failed(_) => ConnectionState::Connecting,
_ => return, // Already connecting or connected
};
// Attempt connection...
}
}
3. Use Traits for Polymorphism
Traits provide a clean way to implement polymorphic behavior:
trait Renderer {
fn render(&self, text: &str) -> String;
}
struct HtmlRenderer;
impl Renderer for HtmlRenderer {
fn render(&self, text: &str) -> String {
format!("<p>{}</p>", text)
}
}
struct MarkdownRenderer;
impl Renderer for MarkdownRenderer {
fn render(&self, text: &str) -> String {
format!("*{}*", text)
}
}
// Function that works with any Renderer
fn render_document(renderer: &impl Renderer, content: &str) -> String {
renderer.render(content)
}
4. Embrace Ownership and Borrowing
Design your APIs with ownership in mind:
// Builder that consumes self
struct RequestBuilder {
url: Option<String>,
method: Option<String>,
}
impl RequestBuilder {
fn new() -> Self {
RequestBuilder {
url: None,
method: None,
}
}
// Takes ownership and returns self
fn url(mut self, url: impl Into<String>) -> Self {
self.url = Some(url.into());
self
}
// Takes ownership and returns self
fn method(mut self, method: impl Into<String>) -> Self {
self.method = Some(method.into());
self
}
// Consumes self to build the final object
fn build(self) -> Result<Request, &'static str> {
let url = self.url.ok_or("URL is required")?;
let method = self.method.unwrap_or_else(|| "GET".to_string());
Ok(Request { url, method })
}
}
struct Request {
url: String,
method: String,
}
Conclusion
Design patterns and idioms in Rust often differ from their counterparts in other languages due to Rust’s unique features like ownership, traits, and algebraic data types. By understanding and applying these patterns appropriately, you can write Rust code that is not only correct and efficient but also maintainable and idiomatic.
The key takeaways from this exploration of Rust design patterns are:
- Memory management patterns like RAII and drop guards leverage Rust’s ownership system
- Creational patterns like builder and factory method provide flexible object creation
- Structural patterns like adapter and decorator help with object composition
- Behavioral patterns like iterator and strategy define interactions between objects
- Rust-specific idioms like newtype and type-state patterns leverage Rust’s type system
Remember that patterns are tools, not rules. Always consider the specific requirements and constraints of your project when deciding which patterns to apply. By using these patterns judiciously, you can create Rust code that is both elegant and effective.