Rust Basics - Module 5: Generics and Traits
Part 5 of 5 in Rust BasicsIn the previous blog post, we discussed about the Structs, Enums, how structs help in grouping the related data under one custom data type and how enums lets us enumerate variants of same or different data types. And we end with discussing Pattern matching, a primary way of working with Enums. In this blog post, we will cover the topics, Traits and Generics in Rust.
Traits are how Rust achieves polymorphism - shared behaviour across different types without inheritance. Generics are how you write code that works over many types without duplicating the logic.
Let's start with What problem we are trying to solve with Traits & Generics, then about Traits, Generics and bringing all together with an example.
The Problem
Consider we want to find the largest elements in a slice of different data types say i32 , f64 etc. Without traits and generics, we need to write a separate function for each data type:
fn largest_i32(list: &[i32]) -> i32 {
let mut largest = list[0];
for &item in list.iter() {
if item > largest {
largest = item;
}
}
largest
}
fn largest_f64(list: &[f64]) -> f64 {
let mut largest = list[0];
for &item in list.iter() {
if item > largest {
largest = item;
}
}
largest
}Here, the logic is a pure duplication of code as we wrote for two different data types and which is redundant and unnecessary. Traits and Generics helps in eliminating these problems. Before looking at the solution, here's the mental model:
Generics eliminate the duplication. Traits are constraints that implementing types must satisfy.
Together, the solution would be:
fn largest<T: PartialOrd>(list: &[T]) -> &T { ... }
// works for any T that can be comparedT - a generic type placeholder; can be
i32,f32or even aStringPartialOrd - a trait constraint meaning the type must support comparison operators, or in other words, only types that implement
PartitalOrdcan be passed as a slice&[T]to this function.
Traits: defining shared behaviour
A trait defines a set of method signatures that a type (data type, structs, enums etc..) must implement. Think of it as an interface. Like Java interfaces, traits define a contract - but unlike Java, Rust traits can have default implementations and can be added to existing types.
trait Summary {
fn summarise(&self) -> String;
}This says: any type that implements Summary must provide a summarise method that takes &self and returns a String . The trait does not say how, implementing type decides that.
Example
Implementing Summary trait on a type:
struct Article {
title: String,
author: String,
content: String,
}
struct Tweet {
username: String,
content: String,
}
impl Summary for Article {
fn summarise(&self) -> String {
format!("{} by {}", self.title, self.author)
}
}
impl Summary for Tweet {
fn summarise(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}Now both Article and Tweet satisfy the Summary contract. The method call is the same regardless of which type you have:
let article = Article{
title: "Rust Module-5",
author: "Siva",
content: "Generics & Traits",
};
let tweet = Tweet{
username: "sambasiva",
content: "Releasing a series on Rust",
};
println!("{}", article.summarise()); // Prints "Rust Module-5 by Siva"
println!("{}", tweet.summarise()); // Prints "sambasiva: Releasing a series on Rust"Default implementation
A trait can provide a default body that types can override if they wanted to or else inherit the default behaviour.
trait Summary {
fn summarise(&self) -> String {
String::from("(Read more...)") // Default behaviour
}
}
impl Summary for Article {} // Uses default summarise
// Or Override it
impl Summary for Tweet {
fn summarise(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
fn main() {
let article = Article{
title: "Rust Module-5",
author: "Siva",
content: "Generics & Traits",
};
let tweet = Tweet{
username: "sambasiva",
content: "Releasing a series on Rust",
};
println!("{}", article.summarise()); // Prints "(Read more...)"
println!("{}", tweet.summarise()); // Prints "sambasiva: Releasing a series on Rust"
}Generics
Generics let you write a function or struct that is parameterised over a type, resolved at compile time. First we will start with functions.
Generic Functions
fn first<T>(list: &[T]) -> &T {
&list[0]
}T is a type parameter - a placeholder. When you call first(&[1, 2, 3]) , the compiler substitutes T = i32 . When you call first(&["a", "b"]) , it substitues T = &str . Two separate compiled functions - zero runtime cost. This is called monomorphisation.
Generic Structs
struct Pair<T> {
first: T,
second: T,
}
impl<T> Pair<T> {
fn new(first: T, second: T) -> Self {
Pair { first, second }
}
}
fn main() {
let pair1 = Pair { first: 3, second: 4 } // Pass
let pair2 = Pair { first: 3, second: 4.0 } // Throws error
}Why error for pair2? Because we defined only one generic parameter to the struct Pair i.e T . In case of pair2 , first is i32 and second is f64 , two different data types, so the error. The fix is simple, we can pass the multiple type parameters each with different identifier. For example:
struct Pair<T, K> {
first: T,
second: K,
}
impl<T, K> Pair<T, K> {
fn new(first: T, second: K) -> Self {
Pair { first, second }
}
}
fn main() {
let pair1 = Pair { first: 3, second: 4 } // Pass
let pair2 = Pair { first: 3, second: 4.0 } // Now Pass
}Trait Bounds - constraining Generics
A bare T can be anything - you can't call any methods on it because the compiler doesn't know what T supports. Traits bounds express the requirements for those types:
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest { // requires PartialOrd
largest = item;
}
}
largest
}T: PartialOrd means "T must implement the PartialOrd trait" - i.e., T must support > , < , >= , <= . Without this bound, item > largest is a compile error because the compiler can't guarantee T supports comparison. So this function works for any type that implements PartialOrd - integers, floats, even String . A custom struct with no ordering would fail this bound.
Multiple Bounds with "+"
We can define as many trait bounds on a Generic type as we want with the help of + .
fn print_summary<T: Summary + std::fmt::Display>(item: &T) {
println!("{}", item.summarise());
}In the above example, we can call this function on the types which implements both Summary and std::fmt::Display traits.
where clause: a clear syntax for complex bounds:
fn compare<T, U>(t: &T, u:&U) -> String
where
T: Summary + Clone,
U: Summary + std::fmt::Debug,
{
//...
}Both forms are equivalent - where is just more readable when bounds get long.
Traits as Function Parameters - impl Trait
Instead of a generic type parameter, we can also use impl Trait syntax directly in the parameter.
fn notify(item: &impl Summary) {
println!("{}", item.summarise());
}This says "accept any reference to any type that implements Summary ". It is syntactic sugar for a generic with a bound:
fn notify<T: Summary>(item: &T) { ... } // equivalent to above impl SummaryUse impl Trait for simple cases. Use explicit generics when you need to refer to T multiple times or return it.
Returning traits - dyn Trait
Sometimes you want to return "some type that implements a trait" without specifying which. This requires a different mechanism - traits objects with dyn :
fn make_summary(is_article: bool) -> Box<dyn Summary> {
if is_article {
Box::new(Article { ... })
} else {
Box::new(Tweet { ... })
}
}Box<dyn Summary>is a fat pointer - a pointer to the data plus a pointer to a vtable of method implementations. The concrete type is decided at runtime, not compile time - this is slower than generics but allows more flexibility.
Key Standard Library Traits
These are the traits you will encounter constantly. Knowing what they mean is essential:
Display — defines how a type is formatted with {}. Implement this to make your type printable.
Debug — defines formatting with {:?}. Usually derived automatically.
Clone — provides the .clone() method for explicit deep copy.
Copy — marker trait, makes assignment copy instead of move. Requires Clone.
PartialOrd / Ord — comparison operators <, > etc.
PartialEq / Eq — equality operators ==, !=.
Iterator — the trait behind every for loop and iterator chain. One required method: next().
Most of these can be derived automatically by the compiler for your types:
#[derive(Debug, Clone, PartialEq)]
struct Point {
x: f64,
y: f64,
}
let p1 = Point { x: 1.0, y: 2.0 };
let p2 = p1.clone();
println!("{:?}", p1); // requires Debug
println!("{}", p1 == p2); // requires PartialEq#[derive(...)] is a macro that auto-generates the trait implementation for you based on the struct's fields.
Complete Example
use std::fmt;
trait Area {
fn area(&self) -> f64;
fn describe(&self) -> String {
format!("This shape has area {:.2}", self.area())
}
}
#[derive(Debug, Clone)]
struct Circle {
radius: f64,
}
#[derive(Debug, Clone)]
struct Rectangle {
width: f64,
height: f64,
}
impl Area for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
impl Area for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
impl fmt::Display for Circle {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Circle(r={})", self.radius)
}
}
fn print_area(shape: &impl Area) {
println!("{}", shape.describe());
}
fn largest_area(shapes: &[Box<dyn Area>]) -> f64 {
shapes.iter()
.map(|s| s.area())
.fold(0.0_f64, f64::max)
}
fn main() {
let c = Circle { radius: 5.0 };
let r = Rectangle { width: 4.0, height: 6.0 };
print_area(&c);
print_area(&r);
let shapes: Vec<Box<dyn Area>> = vec![
Box::new(Circle { radius: 3.0 }),
Box::new(Rectangle { width: 10.0, height: 2.0 }),
Box::new(Circle { radius: 7.0 }),
];
println!("Largest area: {:.2}", largest_area(&shapes));
}Conclusion
Traits and Generics together solve the duplication problem we started with - traits define what a type must do, generics let you write code that works for any type that qualifies. The compiler resolves generics at compile time (monomorphisation, zero cost), while dyn Trait defers the decision to runtime when flexibility matters more than speed. Master these two, and you hold the key to nearly every abstraction in Rust's standard library.
Enjoyed this post?
2 reactions