Rust: Shared Functionality

traits and generics

·

8 min read

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 like Tweet as part of our aggregator crate functionality, because the type Tweet is local to our aggregator crate. We can also implement Summary on Vec<T> in our aggregator crate, because the trait Summary is local to our aggregator crate. But we can’t implement external traits on external types. For example, we can’t implement the Display trait on Vec<T> within our aggregator crate, because Display and Vec<T> are both defined in the standard library and aren’t local to our aggregator 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