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!