DEV Community

Cover image for Rust Traits: The Complete Guide to Polymorphism and Safe Code Composition
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

Rust Traits: The Complete Guide to Polymorphism and Safe Code Composition

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

When I first started with Rust, coming from other programming languages, I kept hearing about this thing called "traits." It sounded abstract, maybe even a bit intimidating. I wondered how it could be that important. Now, after using Rust for a while, I can tell you that the trait system is the reason so much Rust code just works together, even when it's written by different people at different times. It’s the secret sauce for making code both flexible and rock-solid.

Think of a trait as a promise. It's a way for a type to stand up and say, "I can do these specific things." When I define a trait, I’m not writing the actual code that does the work. Instead, I'm writing a list of method signatures—a contract. Any type that wants to "implement" that trait must provide its own code to fulfill that contract. This is how Rust handles what many languages call polymorphism: the ability for different types to be treated in a common way.

Let me start with something very basic. Suppose I'm writing a simple graphics program. I have different shapes: circles and squares. They are different structs with different data.

struct Circle {
    radius: f64,
}

struct Square {
    side: f64,
}
Enter fullscreen mode Exit fullscreen mode

In an object-oriented language, I might create a base Shape class and have Circle and Square inherit from it. Rust doesn't have inheritance for structs. Instead, I define a trait that captures the common behavior I need.

trait Drawable {
    fn draw(&self);
}
Enter fullscreen mode Exit fullscreen mode

This trait, which I've called Drawable, has one method: draw. It takes a reference to self (the instance of the type) and returns nothing. The semicolon after fn draw(&self); is crucial—it means I’m declaring what the method should look like, not defining its implementation.

Now, I can make a promise on behalf of my Circle struct. I say, "A Circle is Drawable." To prove it, I have to write exactly what draw means for a circle.

impl Drawable for Circle {
    fn draw(&self) {
        println!("Drawing a circle with radius: {}", self.radius);
    }
}
Enter fullscreen mode Exit fullscreen mode

I do the same for Square.

impl Drawable for Square {
    fn draw(&self) {
        println!("Drawing a square with side length: {}", self.side);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, I have two completely different types. They share no common parent class. But they both have made the same promise: they implement the Drawable trait. This lets me write a function that can work with anything that is Drawable.

fn render_item(item: &impl Drawable) {
    item.draw();
}

fn main() {
    let circle = Circle { radius: 5.0 };
    let square = Square { side: 10.0 };

    render_item(&circle); // "Drawing a circle with radius: 5"
    render_item(&square); // "Drawing a square with side length: 10"
}
Enter fullscreen mode Exit fullscreen mode

The magic is in &impl Drawable. This is called a "trait bound." It tells the render_item function, "I don’t care what concrete type you give me, as long as it implements the Drawable trait." The compiler checks this for me at compile time. If I tried to pass a type that didn't implement Drawable, my code wouldn't even compile. This catches mistakes early.

This is the heart of Rust's polymorphism. It's a way to design around shared behavior, not shared data or a shared inheritance line. It’s incredibly flexible. I can implement a trait for a type I didn't even write. For example, if I brought in a library that defined a Triangle struct, I could implement my own Drawable trait for it, provided I follow Rust's coherence rules (which I'll get to later).

The real power starts to show when I write generic code. Let's say I want a function that can compare two things. In many languages, I'd rely on a common interface or a specific method name. In Rust, I use traits to be explicit about the capabilities I need.

The standard library has a PartialOrd trait for types that can be compared (for less-than, greater-than operations). I can write a generic function that finds the larger of two items.

fn get_max<T: PartialOrd>(a: T, b: T) -> T {
    if a > b {
        a
    } else {
        b
    }
}

fn main() {
    let max_int = get_max(5, 10);        // T is i32, which implements PartialOrd
    let max_float = get_max(3.14, 2.71); // T is f64, which also implements PartialOrd
    println!("{} {}", max_int, max_float); // Prints: 10 3.14
}
Enter fullscreen mode Exit fullscreen mode

The <T: PartialOrd> syntax is another way to write a trait bound. It reads as: "For some type T where T implements PartialOrd." The compiler knows that inside the function, using the > operator on a and b is valid, because PartialOrd guarantees that method exists. This function will work for integers, floats, strings, or any custom type I define that implements PartialOrd.

Sometimes, I need to be more expressive. A trait might not just be about methods; it can also define an associated type. The most famous example is the Iterator trait. When I implement Iterator for my collection, I have to specify what type of item it yields.

struct CountUp {
    current: u32,
    limit: u32,
}

impl Iterator for CountUp {
    type Item = u32; // Associated Type Declaration

    fn next(&mut self) -> Option<Self::Item> {
        if self.current < self.limit {
            let val = self.current;
            self.current += 1;
            Some(val)
        } else {
            None
        }
    }
}

fn main() {
    let counter = CountUp { current: 0, limit: 3 };
    for num in counter {
        println!("Count: {}", num); // Prints: 0, 1, 2
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, type Item = u32; inside the impl block is defining the associated type. The next method returns an Option<Self::Item>, which in this case is Option<u32>. This design is powerful because it lets the Iterator trait be generic over the kind of value produced, without making the trait itself generic (like Iterator<T>). Different iterators can produce different item types, but I can still write code that works with any Iterator.

So far, I've shown "static dispatch." The compiler knows the exact concrete type at compile time and generates specific machine code for each. There's no runtime cost. But what if I don't know all the types I'll need at compile time? What if I want a collection of different Drawable shapes?

This is where "trait objects" come in. I use the dyn keyword to indicate dynamic dispatch.

fn render_all(shapes: &Vec<Box<dyn Drawable>>) {
    for shape in shapes {
        shape.draw();
    }
}

fn main() {
    let shapes: Vec<Box<dyn Drawable>> = vec![
        Box::new(Circle { radius: 1.0 }),
        Box::new(Square { side: 2.0 }),
    ];
    render_all(&shapes);
}
Enter fullscreen mode Exit fullscreen mode

A Box<dyn Drawable> is a pointer to some memory on the heap that holds data for a type that implements Drawable, alongside a small table (a "vtable") that points to the correct draw implementation for that specific type. When I call shape.draw(), the program does a quick lookup in this table to find which function to run. This has a tiny runtime overhead, but it gives me enormous flexibility: my vector can hold circles, squares, triangles, or any future shape, all mixed together.

There’s an important rule in Rust called the "orphan rule." It says I can implement a trait for a type only if either the trait or the type is defined in my current crate. This prevents confusion. Imagine if two different libraries I used both tried to implement the same third-party trait for the same third-party type, but in conflicting ways. Which one should the compiler use? The orphan rule makes this impossible, keeping the system coherent and predictable.

Traits can also provide default method implementations. This is great for reducing repetitive code. For instance, I could add a method to my Drawable trait that provides a default behavior.

trait Drawable {
    fn draw(&self);
    fn describe(&self) {
        println!("This is a drawable object.");
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, when I implement Drawable for Circle, I can implement describe if a circle needs a special description. But if I don't, the default one from the trait will be used automatically. My Square implementation from before will now have access to this describe method with its default behavior.

Some traits are "marker traits." They have no methods at all. Their only job is to give the compiler information. The most important ones are Send and Sync. A type is Send if it's safe to transfer its ownership to another thread. It's Sync if it's safe to share references to it between threads (&T is Send). The compiler uses these markers automatically. If my struct is composed entirely of Send types, it will be Send too. This is how Rust's fearless concurrency is enforced at compile time with zero runtime cost.

Writing a lot of trait bounds can make function signatures messy. Rust offers the where clause to clean things up.

// A cluttered signature
fn complicated_func<T: Clone + Debug, U: PartialOrd + Serialize>(a: T, b: U) -> bool { ... }

// A cleaner signature using `where`
fn complicated_func<T, U>(a: T, b: U) -> bool
where
    T: Clone + Debug,
    U: PartialOrd + Serialize,
{
    // Function body
}
Enter fullscreen mode Exit fullscreen mode

The where clause moves the bounds to the end, making the function's interface much easier to read at a glance.

In practice, I use traits constantly. When I write a web server handler, it implements a Handler trait. When I read a configuration file, the format deserializer works because my config struct implements Deserialize. When I add a new database driver to an application, it works because it implements the same Connection trait as the old one. The system is designed around these contracts.

The choice between generics (static dispatch) and trait objects (dynamic dispatch) often comes down to my needs. If I'm writing a performance-critical library and I can know the types involved, generics are fantastic—they get optimized away completely. If I'm building a plugin system or a GUI where I truly need a heterogeneous collection of types, trait objects are the right tool. Rust gives me both, and lets me choose.

For me, learning to think in traits was a shift. It wasn't about "what is this object?" but "what can this object do?" It encourages composition over inheritance. I build small, focused traits and then combine them. My code becomes a set of capabilities, and types opt into the capabilities they need. This leads to designs that are easier to change, easier to test, and, thanks to the compiler's relentless checking, remarkably reliable from the start. The trait system is less about clever language features and more about providing a disciplined, clear path to writing code that is both generic and safe. It turns the complex idea of polymorphism into a series of simple, verifiable promises.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)