codelessgenie guide

Implementing Design Patterns in Rust

Design patterns are proven solutions to recurring software design challenges. They provide a common vocabulary for developers, enabling clearer communication and more maintainable code. While originally popularized in object-oriented languages (via the "Gang of Four" book), design patterns are language-agnostic—and Rust, with its unique blend of memory safety, concurrency, and expressive type system, offers fresh perspectives on implementing them. Rust’s features—such as ownership, traits, enums, and zero-cost abstractions—influence how patterns are structured. For example, Rust’s `trait` system replaces traditional "interfaces," and its emphasis on immutability and explicit ownership can make some patterns (like Singleton) safer but more constrained. This blog explores how to implement key design patterns in Rust, highlighting Rust-specific idioms and best practices.

Table of Contents

  1. Creational Patterns
  2. Structural Patterns
  3. Behavioral Patterns
  4. Conclusion
  5. References

Creational Patterns

Creational patterns focus on object instantiation, abstracting the creation process to make code more flexible and reusable.

1. Singleton Pattern

Problem: Ensure a type has only one instance, providing a global point of access to it (e.g., a database connection pool, logger, or configuration manager).

Solution: Use Rust’s concurrency primitives to enforce lazy, thread-safe initialization of a single instance.

Implementation:
Rust avoids global mutable state by default, but libraries like lazy_static or once_cell simplify safe Singleton creation. Here’s an example with once_cell (a lightweight, zero-cost alternative to lazy_static):

use once_cell::sync::OnceCell;

// Define a struct for the Singleton (e.g., a configuration manager)
#[derive(Debug)]
struct AppConfig {
    api_url: String,
    max_retries: u8,
}

impl AppConfig {
    // Lazy-initialized, thread-safe Singleton instance
    static INSTANCE: OnceCell<AppConfig> = OnceCell::new();

    // Initialize the Singleton with configuration data
    fn init(config: AppConfig) -> Result<(), AppConfig> {
        Self::INSTANCE.set(config)
    }

    // Get the global instance (panics if not initialized)
    fn get() -> &'static AppConfig {
        Self::INSTANCE.get().expect("AppConfig not initialized")
    }
}

fn main() {
    // Initialize the Singleton (e.g., from a config file)
    AppConfig::init(AppConfig {
        api_url: "https://api.example.com".to_string(),
        max_retries: 3,
    }).unwrap();

    // Access the Singleton globally
    let config = AppConfig::get();
    println!("API URL: {}", config.api_url); // Output: API URL: https://api.example.com
}

Rust-Specific Notes:

  • OnceCell ensures the instance is initialized exactly once, even across threads.
  • Avoid Singletons for mutable state unless necessary—prefer dependency injection for testability.
  • For mutable Singletons, use RwLock or Mutex inside OnceCell (e.g., OnceCell<Mutex<AppConfig>>).

2. Factory Method Pattern

Problem: Define an interface for creating objects but delegate instantiation to subclasses (or, in Rust, trait implementations).

Solution: Use a trait to define a “factory method,” with concrete types implementing the trait to return specific products.

Example: Document Editor
Suppose we need an editor that creates different document types (text, spreadsheet) via a factory.

// Product trait: Defines the interface for documents
trait Document {
    fn open(&self);
    fn save(&self);
}

// Concrete Products
struct TextDocument;
impl Document for TextDocument {
    fn open(&self) { println!("Opening text document..."); }
    fn save(&self) { println!("Saving text document..."); }
}

struct SpreadsheetDocument;
impl Document for SpreadsheetDocument {
    fn open(&self) { println!("Opening spreadsheet..."); }
    fn save(&self) { println!("Saving spreadsheet..."); }
}

// Factory trait: Defines the factory method
trait DocumentFactory {
    fn create_document(&self) -> Box<dyn Document>;
}

// Concrete Factories
struct TextDocumentFactory;
impl DocumentFactory for TextDocumentFactory {
    fn create_document(&self) -> Box<dyn Document> {
        Box::new(TextDocument)
    }
}

struct SpreadsheetFactory;
impl DocumentFactory for SpreadsheetFactory {
    fn create_document(&self) -> Box<dyn Document> {
        Box::new(SpreadsheetDocument)
    }
}

fn main() {
    let text_factory: Box<dyn DocumentFactory> = Box::new(TextDocumentFactory);
    let text_doc = text_factory.create_document();
    text_doc.open(); // Output: Opening text document...
}

Rust-Specific Notes:

  • Traits (Document, DocumentFactory) replace OOP interfaces.
  • Box<dyn Document> enables dynamic dispatch for polymorphic behavior.

3. Builder Pattern

Problem: Construct complex objects with many optional fields (e.g., a User with required id/name and optional email/age).

Solution: Use a “builder” struct to incrementally set fields, then validate and build the final object.

Implementation:

#[derive(Debug)]
struct User {
    id: u64,          // Required
    name: String,     // Required
    email: Option<String>, // Optional
    age: Option<u8>,  // Optional
}

// Builder struct to construct User
#[derive(Default)]
struct UserBuilder {
    id: Option<u64>,
    name: Option<String>,
    email: Option<String>,
    age: Option<u8>,
}

impl UserBuilder {
    // Set required fields
    fn id(mut self, id: u64) -> Self {
        self.id = Some(id);
        self
    }

    fn name(mut self, name: String) -> Self {
        self.name = Some(name);
        self
    }

    // Set optional fields
    fn email(mut self, email: String) -> Self {
        self.email = Some(email);
        self
    }

    fn age(mut self, age: u8) -> Self {
        self.age = Some(age);
        self
    }

    // Build the User (returns Result to handle missing required fields)
    fn build(self) -> Result<User, String> {
        Ok(User {
            id: self.id.ok_or("ID is required")?,
            name: self.name.ok_or("Name is required")?,
            email: self.email,
            age: self.age,
        })
    }
}

fn main() {
    // Create a User with required fields and optional email
    let user = UserBuilder::default()
        .id(123)
        .name("Alice".to_string())
        .email("[email protected]".to_string())
        .build()
        .expect("Failed to create user");

    println!("User: {:?}", user); 
    // Output: User: User { id: 123, name: "Alice", email: Some("[email protected]"), age: None }
}

Rust-Specific Notes:

  • Use Default for the builder to simplify initialization.
  • Return Result from build() to enforce validation of required fields.

Structural Patterns

Structural patterns compose objects to form larger structures, optimizing relationships between entities.

4. Adapter Pattern

Problem: Make two incompatible interfaces work together (e.g., integrating a legacy library with a new system).

Solution: Create an “adapter” struct that wraps the legacy type and implements the target interface.

Example: Legacy Printer Adapter

// Legacy library with an incompatible interface
struct LegacyPrinter {
    model: String,
}

impl LegacyPrinter {
    fn print_legacy(&self, text: &str) {
        println!("[Legacy {}] Printing: {}", self.model, text);
    }
}

// New system's target interface
trait ModernPrinter {
    fn print(&self, text: &str);
}

// Adapter: Wraps LegacyPrinter and implements ModernPrinter
struct LegacyPrinterAdapter(LegacyPrinter);

impl ModernPrinter for LegacyPrinterAdapter {
    fn print(&self, text: &str) {
        // Adapt the new interface to the legacy method
        self.0.print_legacy(text);
    }
}

// Usage: New system works with ModernPrinter
fn main() {
    let legacy_printer = LegacyPrinter { model: "LX-100".to_string() };
    let adapter = LegacyPrinterAdapter(legacy_printer);

    // Use the adapter via the ModernPrinter interface
    adapter.print("Hello, Adapter Pattern!"); 
    // Output: [Legacy LX-100] Printing: Hello, Adapter Pattern!
}

Rust-Specific Notes:

  • The adapter uses tuple struct syntax (LegacyPrinterAdapter(LegacyPrinter)) for concise wrapping.
  • Traits (ModernPrinter) define the target interface, ensuring compatibility.

Behavioral Patterns

Behavioral patterns focus on communication between objects, defining how they interact and distribute responsibility.

5. Observer Pattern

Problem: Notify multiple “observer” objects of state changes in a “subject” object (e.g., a weather station updating displays).

Solution: Define Subject and Observer traits, with the subject maintaining a list of observers to notify on state changes.

Implementation with Shared Ownership:

use std::cell::RefCell;
use std::rc::Rc;

// Observer trait: Defines the update interface
trait Observer {
    fn update(&mut self, temperature: f64);
}

// Subject trait: Defines methods to manage observers
trait Subject {
    fn attach(&mut self, observer: Rc<RefCell<dyn Observer>>);
    fn detach(&mut self, observer: Rc<RefCell<dyn Observer>>);
    fn notify(&self);
}

// Concrete Subject: Weather Station
struct WeatherStation {
    temperature: f64,
    observers: Vec<Rc<RefCell<dyn Observer>>>,
}

impl WeatherStation {
    fn new() -> Self {
        WeatherStation { temperature: 0.0, observers: Vec::new() }
    }

    // Update temperature and notify observers
    fn set_temperature(&mut self, temp: f64) {
        self.temperature = temp;
        self.notify();
    }
}

impl Subject for WeatherStation {
    fn attach(&mut self, observer: Rc<RefCell<dyn Observer>>) {
        self.observers.push(observer);
    }

    fn detach(&mut self, observer: Rc<RefCell<dyn Observer>>) {
        self.observers.retain(|o| !Rc::ptr_eq(o, &observer));
    }

    fn notify(&self) {
        for observer in &self.observers {
            observer.borrow_mut().update(self.temperature);
        }
    }
}

// Concrete Observers
struct PhoneDisplay {
    name: String,
}

impl Observer for PhoneDisplay {
    fn update(&mut self, temperature: f64) {
        println!("[{}] Temperature updated: {}°C", self.name, temperature);
    }
}

struct LaptopDisplay {
    name: String,
}

impl Observer for LaptopDisplay {
    fn update(&mut self, temperature: f64) {
        println!("[{}] Current temp: {}°C", self.name, temperature);
    }
}

fn main() {
    let mut station = WeatherStation::new();

    // Create observers (Rc<RefCell<...>> for shared, mutable ownership)
    let phone = Rc::new(RefCell::new(PhoneDisplay { name: "Phone".to_string() }));
    let laptop = Rc::new(RefCell::new(LaptopDisplay { name: "Laptop".to_string() }));

    // Attach observers
    station.attach(Rc::clone(&phone));
    station.attach(Rc::clone(&laptop));

    // Update temperature (triggers notifications)
    station.set_temperature(22.5); 
    // Output: [Phone] Temperature updated: 22.5°C
    //         [Laptop] Current temp: 22.5°C

    // Detach laptop
    station.detach(laptop);
    station.set_temperature(25.0); 
    // Output: [Phone] Temperature updated: 25°C
}

Rust-Specific Notes:

  • Use Rc<RefCell<...>> to enable shared, mutable ownership of observers (required for multiple observers).
  • Rc::ptr_eq checks for pointer equality to detach observers safely.

6. Strategy Pattern

Problem: Define a family of algorithms, encapsulate each, and make them interchangeable (e.g., different payment methods in a checkout system).

Solution: Use a Strategy trait to abstract algorithms, with concrete strategies implementing the trait.

Example: Payment Strategies

// Strategy trait: Defines the algorithm interface
trait PaymentStrategy {
    fn pay(&self, amount: f64) -> Result<(), String>;
}

// Concrete Strategies
struct CreditCardPayment {
    card_number: String,
}

impl PaymentStrategy for CreditCardPayment {
    fn pay(&self, amount: f64) -> Result<(), String> {
        Ok(println!("Paid ${} with credit card {}", amount, self.card_number))
    }
}

struct PayPalPayment {
    email: String,
}

impl PaymentStrategy for PayPalPayment {
    fn pay(&self, amount: f64) -> Result<(), String> {
        Ok(println!("Paid ${} via PayPal ({})", amount, self.email))
    }
}

// Context: Uses a strategy to perform an action
struct ShoppingCart {
    items: Vec<f64>,
    payment_strategy: Box<dyn PaymentStrategy>,
}

impl ShoppingCart {
    fn new(strategy: Box<dyn PaymentStrategy>) -> Self {
        ShoppingCart { items: Vec::new(), payment_strategy: strategy }
    }

    fn add_item(&mut self, price: f64) {
        self.items.push(price);
    }

    fn checkout(&self) -> Result<(), String> {
        let total: f64 = self.items.iter().sum();
        self.payment_strategy.pay(total)
    }
}

fn main() {
    // Use Credit Card strategy
    let mut cart = ShoppingCart::new(Box::new(CreditCardPayment {
        card_number: "****-****-****-1234".to_string(),
    }));
    cart.add_item(29.99);
    cart.add_item(15.50);
    cart.checkout().unwrap(); 
    // Output: Paid $45.49 with credit card ****-****-****-1234

    // Switch to PayPal strategy
    let mut cart = ShoppingCart::new(Box::new(PayPalPayment {
        email: "[email protected]".to_string(),
    }));
    cart.add_item(99.99);
    cart.checkout().unwrap(); 
    // Output: Paid $99.99 via PayPal ([email protected])
}

Rust-Specific Notes:

  • Strategies are boxed (Box<dyn PaymentStrategy>) for dynamic dispatch, allowing runtime swapping.

7. Iterator Pattern (Rust’s Built-in Implementation)

Problem: Provide a way to traverse elements of a collection without exposing its underlying structure.

Solution: Rust’s standard library includes the Iterator trait, which is a built-in implementation of the Iterator pattern.

Example: Custom Fibonacci Iterator

// Custom iterator for Fibonacci sequence
struct Fibonacci {
    a: u64,
    b: u64,
}

impl Fibonacci {
    fn new() -> Self {
        Fibonacci { a: 0, b: 1 }
    }
}

// Implement Rust's Iterator trait
impl Iterator for Fibonacci {
    type Item = u64;

    fn next(&mut self) -> Option<Self::Item> {
        let next = self.a;
        self.a = self.b;
        self.b = next + self.b;
        Some(next) // Infinite iterator (returns None only on overflow)
    }
}

fn main() {
    // Generate first 10 Fibonacci numbers
    let fib_sequence: Vec<u64> = Fibonacci::new().take(10).collect();
    println!("Fibonacci: {:?}", fib_sequence); 
    // Output: Fibonacci: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
}

Rust-Specific Notes:

  • The Iterator trait defines a uniform interface for iteration (next() -> Option<Item>).
  • Combinators like take(), map(), and filter() enable powerful, declarative iteration pipelines.

Conclusion

Design patterns are timeless tools, but their implementation in Rust is shaped by its unique features: traits for interfaces, ownership for safety, and built-in abstractions like Iterator. By adapting patterns to Rust’s paradigm, you can write code that is both idiomatic and robust. Remember to choose patterns judiciously—Rust’s type system often eliminates the need for patterns like Singleton, while enabling others (like Builder) to be more expressive.

References