Some more Traits

In Chapter 5.1 we introduced the concept of traits. In this chapter we'll look at some more advanced features of traits.

  • How can traits be passed to functions?
  • Associated types
  • Type conversion traits

Passing traits to functions

In the previous chapter we saw how we can implement a trait for a struct. We can also pass a trait to a function. This is useful when we want to write functions that can operate on different types that implement the same trait.

For example:

trait Shape {
    fn area(&self) -> f64;
}

struct Circle {
    radius: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius.powi(2)
    }
}

struct Square {
    side: f64,
}

impl Shape for Square {
    fn area(&self) -> f64 {
        self.side.powi(2)
    }
}

fn print_area<S: Shape>(shape: S) {
    println!("Area: {}", shape.area());
}

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

    print_area(circle);
    print_area(square);
}

In this example, we define a Shape trait with an area method. We then implement the Shape trait for the Circle and Square structs. We can then pass instances of Circle and Square to the print_area function, which takes any type that implements the Shape trait.

We use the S: Shape syntax to specify that the print_area function takes a type S that implements the Shape trait. This is also referred to as a generic type parameter.

When using generics in this way, the compiler will use so-called static dispatch to determine which implementation of the Shape trait to use at compile time. This can be more efficient than dynamic dispatch. Static dispatch can lead to faster code, but it requires the compiler to generate separate code for each function that uses the generic type.

Dynamic dispatch

In some cases, you may want to use dynamic dispatch instead of static dispatch. Dynamic dispatch allows you to work with trait objects, which are objects that implement a trait but have an unknown concrete type at compile time.

To use dynamic dispatch, you can use the dyn keyword to refer to a trait object. For example:

trait Shape {
    fn area(&self) -> f64;
}

struct Circle {
    radius: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius.powi(2)
    }
}

struct Square {
    side: f64,
}

impl Shape for Square {
    fn area(&self) -> f64 {
        self.side.powi(2)
    }
}

fn print_area(shape: &dyn Shape) {
    println!("Area: {}", shape.area());
}

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

    print_area(&circle);
    print_area(&square);
}

Dynamic dispatching is more flexible than static dispatching, but it is slightly less efficient.

Associated types

In Rust, you can define associated types for a trait. Associated types are types that are associated with a trait, but are not specified until the trait is implemented.

For example:

use std::fmt::Display;

trait Shape {
    type Output: Display;

    fn area(&self) -> Self::Output;
}

struct Circle {
    radius: f64,
}

impl Shape for Circle {
    type Output = f64;

    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius.powi(2)
    }
}

struct Square {
    side: f32,
}

impl Shape for Square {
    type Output = f32;

    fn area(&self) -> f32 {
        self.side.powi(2)
    }
}

fn print_area<S: Shape>(shape: S) {
    println!("Area: {}", shape.area());
}

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

    print_area(circle);
    print_area(square);
}

In the above example, we define a Shape trait with an associated type Output. The Output type is not specified in the trait definition, but is specified when the trait is implemented for a type. The only requirement is that the Output type must implement the Display trait. This allows us to use the Display trait in the print_area function.

When implementing the Shape trait for the Circle and Square structs, we specify the Output type as f64 and f32

Type conversion Traits

Rust provides a number of traits that allow you to convert between types. For example, the From and Into traits allow you to convert from one type to another. In Chapter 4.6 we looked at an C-style enumeration:

#![allow(unused)]
fn main() {
#[repr(u8)]
enum Color {
    Red = 0,
    Green = 1,
    Blue = 2,
}
}

We can use the From trait to convert from an u8 to a Color:

#![allow(unused)]
fn main() {
impl From<u8> for Color {
    fn from(value: u8) -> Self {
        match value {
            0 => Color::Red,
            1 => Color::Green,
            2 => Color::Blue,
            _ => panic!("Invalid value"),
        }
    }
}
}

The same can be done for the other direction:

#![allow(unused)]
fn main() {
impl From<Color> for u8 {
    fn from(color: Color) -> Self {
        match color {
            Color::Red => 0,
            Color::Green => 1,
            Color::Blue => 2,
        }
    }
}
}

Now we can easily convert between Color and u8:

#[repr(u8)]
#[derive(Debug)]
enum Color {
    Red = 0,
    Green = 1,
    Blue = 2,
}

impl From<Color> for u8 {
    fn from(color: Color) -> Self {
        match color {
            Color::Red => 0,
            Color::Green => 1,
            Color::Blue => 2,
        }
    }
}

impl From<u8> for Color {
    fn from(value: u8) -> Self {
        match value {
            0 => Color::Red,
            1 => Color::Green,
            2 => Color::Blue,
            _ => panic!("Invalid value"),
        }
    }
}

fn main() {
    let color: Color = 1.into();
    println!("{color:?}");
    let green_value: u8 = Color::Green.into();
    println!("{green_value:?}");
}

TryFrom

The conversion from an u8 to a Color can fail when an integer is passed that is not in the range of the enumeration. In this case the TryFrom conversion trait would have been the better choice. The TryFrom trait is similar to the From trait, but it returns a Result instead of panicking. The Error type is specified as an associated type.

use std::convert::TryFrom;

#[repr(u8)]
#[derive(Debug)]
enum Color {
    Red = 0,
    Green = 1,
    Blue = 2,
}

impl TryFrom<u8> for Color {
    type Error = String;

    fn try_from(value: u8) -> Result<Self, Self::Error> {
        match value {
            0 => Ok(Color::Red),
            1 => Ok(Color::Green),
            2 => Ok(Color::Blue),
            _ => Err(format!("Invalid Color value: {value}")),
        }
    }
}

fn main() {
    match Color::try_from(10) {
        Ok(color) => println!("{color:?}"),
        Err(err) => println!("{err}"),
    }
}

If you prefer to use String representations of the enumeration variants, you can use the FromStr trait. This trait allows you to convert a string to an enumeration variant.

use std::fmt::{Display, Formatter};
use std::str::FromStr;

enum Color {
    Red,
    Green,
    Blue,
}

impl FromStr for Color {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "Red" => Ok(Color::Red),
            "Green" => Ok(Color::Green),
            "Blue" => Ok(Color::Blue),
            _ => Err(format!("Invalid Color value: {s}")),
        }
    }
}

impl Display for Color {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        match self {
            Color::Red => write!(f, "Red"),
            Color::Green => write!(f, "Green"),
            Color::Blue => write!(f, "Blue"),
        }
    }
}

fn main() {
    match "Green".parse::<Color>() {
        Ok(color) => println!("{color}"),
        Err(err) => println!("{err}"),
    }
}

Although you can implement these enum conversions yourself, you may want to look at the great strum crate, which provides a lot of useful macros to make this easier.

Exercises

Continue with the exercise in the Exercises section. Have a great time coding!

Reference material