Deep Dive into Rust Traits: Inheritance, Composition, and Polymorphism
Daniel Hayes
Full-Stack Engineer · Leapcell

What is a Trait?
In Rust, a trait is a way to define shared behavior. It allows us to specify methods that a type must implement, thereby enabling polymorphism and interface abstraction.
Here is a simple example that defines a trait named Printable
, which includes a method called print
:
trait Printable { fn print(&self); }
Defining and Implementing Traits
To define a trait, we use the trait
keyword, followed by the trait name and a pair of curly brackets. Inside the curly brackets, we define the methods that the trait includes.
To implement a trait, we use the impl
keyword, followed by the trait name, the for
keyword, and the type for which we are implementing the trait. Inside the curly brackets, we must provide implementations for all the methods defined in the trait.
Below is an example showing how to implement the previously defined Printable
trait for the i32
type:
impl Printable for i32 { fn print(&self) { println!("{}", self); } }
In this example, we implemented the Printable
trait for the i32
type and provided a simple implementation of the print
method.
Trait Inheritance and Composition
Rust allows us to extend existing traits through inheritance and composition. Inheritance enables us to reuse methods defined in a parent trait within a new trait, while composition allows us to use multiple different traits in a new trait.
Here is an example demonstrating how to use inheritance to extend the Printable
trait:
trait PrintableWithLabel: Printable { fn print_with_label(&self, label: &str) { print!("{}: ", label); self.print(); } }
In this example, we define a new trait called PrintableWithLabel
, which inherits from the Printable
trait. This means that any type implementing PrintableWithLabel
must also implement Printable
. Additionally, we provide a new method, print_with_label
, which prints a label before printing the value.
Here is another example demonstrating how to use composition to define a new trait:
trait DisplayAndDebug: Display + Debug {}
In this example, we define a new trait DisplayAndDebug
, which consists of two traits from the standard library: Display
and Debug
. This means that any type implementing DisplayAndDebug
must also implement both Display
and Debug
.
Traits as Parameters and Return Values
Rust allows us to use traits as parameters and return values in function signatures, making our code more generic and flexible.
Here is an example showing how to use the PrintableWithLabel
trait as a function parameter:
fn print_twice<T: PrintableWithLabel>(value: T) { value.print_with_label("First"); value.print_with_label("Second"); }
In this example, we define a function named print_twice
that takes a generic parameter T
. The parameter must implement the PrintableWithLabel
trait. Inside the function body, we call the print_with_label
method on the parameter.
Here is an example showing how to use a trait as a function return value:
fn get_printable() -> impl Printable { 42 }
However, fn get_printable() -> impl Printable { 42 }
is incorrect because 42
is an integer and does not implement the Printable
trait.
The correct approach is to return a type that implements the Printable
trait. For example, if we implement Printable
for the i32
type, we can write:
impl Printable for i32 { fn print(&self) { println!("{}", self); } } fn get_printable() -> impl Printable { 42 }
In this example, we implement the Printable
trait for the i32
type and provide a simple implementation of the print
method. Then, in the get_printable
function, we return an i32
value 42
. Since the i32
type implements the Printable
trait, this code is correct.
Trait Objects and Static Dispatch
In Rust, we can achieve polymorphism in two ways: static dispatch and dynamic dispatch.
- Static dispatch is achieved using generics. When we use generic parameters, the compiler generates separate code for each possible type. This allows the function calls to be determined at compile time.
- Dynamic dispatch is achieved using trait objects. When we use trait objects, the compiler generates a general-purpose code that can handle any type implementing the trait. This allows function calls to be determined at runtime.
Here is an example demonstrating how to use both static dispatch and dynamic dispatch:
fn print_static<T: Printable>(value: T) { value.print(); } fn print_dynamic(value: &dyn Printable) { value.print(); }
In this example:
print_static
uses a generic parameterT
, which must implement thePrintable
trait. When this function is called, the compiler generates separate code for each type that is passed to it (static dispatch).print_dynamic
uses a trait object (&dyn Printable
) as a parameter. This enables dynamic dispatch, allowing the function to process any type implementing thePrintable
trait.
Associated Types and Generic Constraints
In Rust, we can use associated types and generic constraints to define more complex traits.
Associated Types
Associated types allow us to define a type that is associated with a particular trait. This is useful for defining methods that depend on an associated type.
Here is an example defining a trait named Add
using an associated type:
trait Add<RHS = Self> { type Output; fn add(self, rhs: RHS) -> Self::Output; }
In this example:
- We define a trait called
Add
. - It includes an associated type
Output
, which represents the return type of theadd
method. - The
RHS
generic parameter specifies the right-hand side of the addition operation, defaulting toSelf
.
Generic Constraints
Generic constraints allow us to specify that a generic parameter must satisfy certain conditions (e.g., implement a specific trait).
Here is an example demonstrating how to use generic constraints in a trait named SummableIterator
:
use std::iter::Sum; trait SummableIterator: Iterator where Self::Item: Sum, { fn sum(self) -> Self::Item { self.fold(Self::Item::zero(), |acc, x| acc + x) } }
In this example:
- We define a trait
SummableIterator
that extends the standardIterator
trait. - We use a generic constraint (
where Self::Item: Sum
) to specify that theItem
type of the iterator must implement theSum
trait. - The
sum
method calculates the total sum of all elements in the iterator.
Example: Implementing Polymorphism Using Traits
Here is an example demonstrating how to use the PrintableWithLabel
trait to achieve polymorphism:
struct Circle { radius: f64, } impl Printable for Circle { fn print(&self) { println!("Circle with radius {}", self.radius); } } impl PrintableWithLabel for Circle {} struct Square { side: f64, } impl Printable for Square { fn print(&self) { println!("Square with side {}", self.side); } } impl PrintableWithLabel for Square {} fn main() { let shapes: Vec<Box<dyn PrintableWithLabel>> = vec![ Box::new(Circle { radius: 1.0 }), Box::new(Square { side: 2.0 }), ]; for shape in shapes { shape.print_with_label("Shape"); } }
In this example:
- We define two structs:
Circle
andSquare
. - Both structs implement the
Printable
andPrintableWithLabel
traits. - In the
main
function, we create a vectorshapes
that stores trait objects (Box<dyn PrintableWithLabel>
). - We iterate over the
shapes
vector and callprint_with_label
on each shape.
Since both Circle
and Square
implement PrintableWithLabel
, they can be stored as trait objects in a vector. When we call print_with_label
, the compiler dynamically determines which method to invoke based on the actual type of the object.
This is how traits enable polymorphism in Rust. I hope this article helps you understand traits better.
We are Leapcell, your top choice for hosting Rust projects.
Leapcell is the Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis:
Multi-Language Support
- Develop with Node.js, Python, Go, or Rust.
Deploy unlimited projects for free
- pay only for usage — no requests, no charges.
Unbeatable Cost Efficiency
- Pay-as-you-go with no idle charges.
- Example: $25 supports 6.94M requests at a 60ms average response time.
Streamlined Developer Experience
- Intuitive UI for effortless setup.
- Fully automated CI/CD pipelines and GitOps integration.
- Real-time metrics and logging for actionable insights.
Effortless Scalability and High Performance
- Auto-scaling to handle high concurrency with ease.
- Zero operational overhead — just focus on building.
Explore more in the Documentation!
Follow us on X: @LeapcellHQ