Generics allow us to replace specific types with a placeholder that represents multiple types to remove code duplication. We use generics to create definitions for items like function signatures, structs, enums and methods, which we can use with many concrete data types. Below, I'll provide basic examples for each type, highlighting potential gotchas.
Sometimes, you might find that the functionality you need isn't supported by every type in the Rust language. To overcome this, you can restrict which types are valid for the generic type T
in your declarations, typically done using traits.
Generics
Function Definitions
fn largest<T>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
💡 The code above exemplifies a common challenge with generics. Not all types inherently support all operations. In this case, trying to compile the code will result in an error message: binary operation > cannot be applied to type &T
. To rectify this, you'll need to constrain the types that T
can represent. This can be achieved by specifying that T
must implement the PartialOrd
trait.
fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
// ...
}
Struct Definitions
Structs can be defined with a generic type in one or more of their fields.
struct Point<T> {
x: T,
y: T,
name: String,
}
💡 Structs can also be defined with multiple generic types:
struct Point<T, U> {
x: T,
y: U,
name: String,
}
Enum Definitions
The Option
and Result
enums are prime examples of using generics. Below are their definitions:
enum Option<T> {
Some(T),
None,
}
Option
will return a Some
variant with a value of type T
when there's a result, and the None
variant when there isn't.
enum Result<T, E> {
Ok(T),
Err(E),
}
The Result
enum showcases the use of two distinct generic types: T
for successful outcomes and E
for failures.
Method Definitions
When implementing a method for a generic type, you need to specify it at the start of your impl
block, as in impl<T>
.
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
💡 Beyond generic method implementation, you can also define methods specific to certain data types. The following example showcases the distance_from_origin
method available only for points with f32
coordinates:
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
Performance
In Rust, using generic type parameters doesn't slow down your runtime. This efficiency is achieved through "monomorphization" during compile time, where the compiler turns generic code into specific code tailored to the concrete types used. For instance, with the generic Option<T> enum
used for an i32
and f64
, Rust would generate two specific versions: Option_i32
and Option_f64
. By doing this, the runtime performance remains optimal, as if you manually wrote specific code for each type, making Rust's generics highly efficient at runtime.
Traits
Defining and Implementing a Trait
Traits in Rust define shared behaviours across various types. They group method signatures to represent essential behaviours and standardize how types should implement these shared features. While traits outline the methods, the actual implementations are distinct for each type (if the default implementation is not defined).
One trait can have multiple methods defined inside its body.
The syntax for implementing a trait for a certain type is impl $TRAIT_NAME for $TYPE_NAME
// src/lib.rs
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct Tweet {
pub username: String,
pub content: String,
pub reply: bool,
pub retweet: bool,
}
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
💡 You can only implement a trait on a type if one of them is local to your crate.
For example, we can implement standard library traits like
Display
on a custom type likeTweet
as part of ouraggregator
crate functionality, because the typeTweet
is local to ouraggregator
crate. We can also implementSummary
onVec<T>
in ouraggregator
crate, because the traitSummary
is local to ouraggregator
crate. But we can’t implement external traits on external types. For example, we can’t implement theDisplay
trait onVec<T>
within ouraggregator
crate, becauseDisplay
andVec<T>
are both defined in the standard library and aren’t local to ouraggregator
crate.(c) The Rust Programming Language
Default Implementations
Traits can have default implementations, providing a standard behaviour that isn't dependent on the type:
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
Once the default implementation is set, it's available for instances of types implementing that trait without extra configuration.
let article = NewsArticle {
// ...
};
println!("New article available! {}", article.summarize());
💡 If you have already defined a custom trait implementation for your type, it will act like an override to the default implementation.
Traits with default methods can invoke other trait methods that lack default implementations. This modularization allows developers to only implement a subset of a trait's methods, promoting simplicity:
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}
In the example above, the developer who uses our trait will only have to implement the summarize_author
method.
The Default
trait, commonly used, sets default values for a type's instance:
struct Package {
weight: f64,
}
impl Default for Package {
fn default() -> Self {
Self { weight: 4.5 }
}
}
fn main() {
let p = Package::default();
}
Traits as Parameters
A fascinating aspect of the trait system is the ability to pass a trait as a function parameter. This means a function will accept any type implementing the specified trait:
// accept any type that implements the Summary trait
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}
💡 You can use trait bounds when you need multiple function parameters of identical types.
pub fn notify<T: Summary>(item1: &T Summary) {
println!("Breaking news! {}", item.summarize());
}
💡 You can stipulate that a type must implement several traits using the +
syntax, this approach works for both, trait bound and shorthand syntax.
// the type must implement both, Symmary and Diplay traits.
pub fn notify(item: &(impl Summary + Display)) {
// ...
}
💡 To make trait bounds more explicit, use the where
keyword:
fn notify(item: &T, next_item: &U)
where
T: Display + Clone
U: Summary + Display {
// ...
}
struct Person<T, U>
where
T: Display + Clone,
U: Symmary + Display,
{
name: T,
classes: U,
}
💡 The impl Trait
syntax lets you return a value of a type implementing a specific trait, but it restricts you to a single type. You can not return either TypeA
or TypeB
pub fn do_something() -> impl Summary {
// allows you to return any type that returns
}
💡 In Rust, there's a powerful capability where you can conditionally implement methods for a type based on the traits that the type implements. This feature is especially useful for extending functionality without altering the core design of your types.
use std::fmt::Display;
struct Pair<T> {
x: T,
y: T,
}
impl<T> Pair<T> {
fn new(x: T, y: T) -> Self {
Self { x, y }
}
}
impl<T: Display + PartialOrd> Pair<T> {
fn cmp_display(&self) {
if self.x >= self.y {
println!("The largest member is x = {}", self.x);
} else {
println!("The largest member is y = {}", self.y);
}
}
}
In the code above, the cmp_display
method is conditionally implemented for the Pair
type. Specifically, this method will only be available for instances of Pair
that implement both the Display
and PartialOrd
traits. This approach ensures that you can harness the power of traits to enhance the functionality of your types in a flexible and modular way.
Trait Objects
Static Dispatch - calculate objects at compile time. Generics use Static Dispatch
Dynamic Dispatch - calculate objects at runtime. Trait Objects use Dynamic Dispatch. Has a small performance penalty when it comes to speed and memory usage.
One of the major benefits of the trait objects is the ability to store different types in a single collection. For example, you can store Employee
, Manager
and Contractor
types in a single vector.
The code below presents an example of defining trait objects by putting them in a Box
object and then storing the different types implementing the Clicky
trait in one vector.
// define dummy stuff
trait Clicky {
fn click(&self);
}
struct Keyboard;
struct Mouse;
impl Clicky for Keyboard {
fn click(&self) {
println!("clickidy clack")
}
}
impl Clicky for Mouse {
fn click(&self) {
println!("the mouse goes click clack")
}
}
// create a trait object
let kbrd: Box<dyn Clicky> = Box::new(Keyboard);
// you can use the power of Rust to transform the Box into trait object
// since the peripherals vector has the right annotation, Rust will take
// care of making mouse the right type.
let mouse = Box::new(Mouse);
let peripherals: Vec<Box<dyn Clicky>> = vec![kbrd, mouse];
for peripheral in peripherals {
peripheral.click();
}
Usually, when using the trait objects as a function parameter, you can either borrow the trait object or move them using Box
fn borrow_clicky(obj: &dyn Clicky) {
obj.click()
}
fn move_clicky(obj: Box<dyn Clicky>) {
obj.click()
}
let br_keeb = Keyboard;
let mv_keeb = Box::new(Keyboard);
borrow_clicky(&br_keeb);
move_clicky(mv_keeb);
Reference
Best Video Material
[YouTube] 300 seconds of Rust - 12. Traits
Best Text Material
The Rust Programming Language: Generic Types, Traits and Lifetimes