Clean Code with Traits in Rust

Introduction

Welcome to the second lesson of the "Clean Code with Multiple Structs and Traits in Rust" course! In the first lesson, we explored how to identify and solve common code smells to write cleaner and more maintainable code. Today, we'll dive deeper into the power of traits in Rust. Traits are a crucial part of Rust's type system, allowing you to define shared behavior in a reusable way. They enable polymorphism, letting you write code that can operate on different types uniformly. Let's get started!

Understanding Traits

Traits in Rust are similar to interfaces in other languages, defining a set of methods that (sub)types must implement. This ensures that different types can provide their own implementations while adhering to a common set of behaviors. Here's a basic example to demonstrate how traits function in Rust:

// Trait defining a contract
trait PaymentProcessor {
    fn process_payment(&self, amount: f64);
}

// Struct implementing the trait
struct CreditCardProcessor;

impl PaymentProcessor for CreditCardProcessor {
    fn process_payment(&self, amount: f64) {
        println!("Processing credit card payment of ${:.2}", amount);
    }
}

In this example, PaymentProcessor is a trait that defines the process_payment method. Any type implementing this trait must provide an implementation for this method. This design choice enables flexibility, allowing different payment methods, such as CreditCardProcessor or PayPalProcessor, to be used interchangeably, as they all conform to a consistent interface. Traits in Rust promote scalability — adding new payment processors requires minimal changes to your existing codebase.

Solving Common Code Challenges with Traits

Code duplication and rigid structures can make it challenging to extend your applications to meet new requirements. Consider the following example that does not employ traits:

struct CashPayment;

impl CashPayment {
    fn pay(&self) {
        println!("Paying with cash");
    }
}

struct CreditCardPayment;

impl CreditCardPayment {
    fn pay(&self) {
        println!("Paying with credit card");
    }
}

This code lacks flexibility; the pay method is duplicated across different structs, and there's no common interface to work with different payment methods uniformly. By using traits, we can refactor the code for improved design:

trait Payment {
    fn pay(&self);
}

struct CashPayment;

impl Payment for CashPayment {
    fn pay(&self) {
        println!("Paying with cash");
    }
}

struct CreditCardPayment;

impl Payment for CreditCardPayment {
    fn pay(&self) {
        println!("Paying with credit card");
    }
}

With the introduction of the Payment trait, different payment types implement a common interface. This allows us to write functions that can accept any type that implements Payment:

// Static dispatch, using `&impl Payment`
// Dynamic dispatch can similarly be employed, using `&dyn Payment`
fn process_payment(payment: &impl Payment) {
    payment.pay();
}

// Usage
let cash = CashPayment;
let credit_card = CreditCardPayment;

process_payment(&cash);
process_payment(&credit_card);

By using traits, adding new payment methods only requires implementing the trait, reducing duplication and improving maintainability.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal