Using Traits

Traits provide similar abstraction to interfaces, but without some of the limitations of interfaces in a classical Object Oriented perspective. To illustrate the similarity of traits to interfaces I will create an example where a Circle and a Square are both things that have an area() method, but they calculate them differently.

Below a Shape is defined as something that requires the implementation for an area method accepting a reference to itself self and returning an f32. Then you can implement this trait for a Circle and a Square since both of these can be shapes with an area, even though their implementations may be different. We then need to create a way to display the area of anything that implements the Shape interface, this can be done with a function display_area that can correctly dispatch the .area() method for the underlying type.

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

struct Circle {
    radius: f32,
}

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

struct Square {
    side: f32,
}

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

// dyn is used for trait objects that use dynamic dispatch
// unlike generics the compiler will not know concrete types until runtime
// https://doc.rust-lang.org/std/keyword.dyn.html
fn display_area(shape: &dyn Shape) {
    println!("area is {}", shape.area())
}

fn main() {
    // display area accepts arguments that implement Shape
    display_area(&Circle { radius: 1. });
    display_area(&Square { side: 1. });
}

Even though this is similar to how interfaces work, traits can do much more than just abstracting over shared behaviors.

Dispatch

Dispatch is the process of determining the right data and methods to reference when you are referencing an abstraction and not a concrete type

  • Rust supports both static and dynamic dispatch

Static Dispatch

is when dispatching a traits data/methods uses no overhead at runtime. The Rust compiler checks the validity of method dispatches using “hints” at compile time to create a concrete function reference based on what types are being used.

fn display_area(shape: &impl Shape) {
    println!("areaa is {}", shape.area())
}

By using impl Shape, the compiler understands that this function can accept any generic type that implements Shape. This will cause the compiler to create a reference for the display_area() function for every type that implements Shape. This may cause a slightly longer compile time, but has the advantage of reducing runtime, since the references will already be defined regardless of the type argument.

- NOTE: this is similar to how C++ templates behave when using `SFINAE`

Dynamic Dispatch

This is the process of determining the correct data/methods to reference at runtime, this is how languages like C# and Java resolve interface references.

fn display_area(shape: &dyn Shape) {
    println!("area is {}", shape.area())
}

The function accepts a borrow of anything that implements Shape preceded by the dyn tag. The dyn tag is important because it tells the compiler that we don’t know the concrete type, and that we don’t know the memory size of the type with a Shape implementation. The Rust compiler will not create a reference for every type that implements Shape and instead we pay a slight penalty at runtime to figure this out.

Using Traits to separate Data and Implementation

If you have ever written an extension method in C# or similar language, then you will notice the benefits of the trait system by how Rust can define extension methods. I will create an example that extends the string literal (slice) str with a Hello trait to print some new text and the original str. - Note: if you want to learn more about str vs String checkout this chapter in the Rust book. https://doc.rust-lang.org/nightly/book/ch04-03-slices.html

// Define a trait that defines our behavior
trait Hello {
    fn say_hello(&self);
}

// implement the behavior for a static slice
// the single quote infront of static is a lifetime identifier, we will cover this another time
impl Hello for &'static str {
    fn say_hello(&self) {
        println!("Hello, {}!", *self)
    }
}

// now we can use the behavior for any static slice
fn main() {
    "world".say_hello();
}

The benefit to separating data and implementation is that you can implement a trait for a type without modifying the type itself. In contrast, in classical object oriented languages, you must modify the class to implement another interface. Said otherwise, you can implement your own traits for external data.

This separation is defined at the lowest behavioural level in Rust, for dynamic dispatch the methods being called are only going to be given two pointers, one to the un-abstracted data type, and one to that data type’s method implementation. - To see a more detail account of this, read Huon Wilson’s blog post about trait objects http://huonw.github.io/blog/2015/01/peeking-inside-trait-objects/

Default Implementation

Traits can also provide default method implementation, just like an abstract class in C#, or a defender method in Java

// Define our trait and method
trait Hello {
    fn say_hello(&self) {
        println!("Hello there!")
    }
}

// reference the trait for another type, without any overwriting implementation 
impl Hello for i32 {}

fn main() {
    123.say_hello(); // call default implementation
}

Similar to an abstract class, default implementations can be overwritten. All that has to be done is provide an implementation for the impl block.

Inheritance

  • The Rust trait’s system is not an inheritance system. You cannot try to downcast, for example, or try to cast a reference on a trait to another trait. To get more information about this, see this question about upcasting.
  • Moreover, you can use the dynamic type to simulate some behavior you want.
  • While you can simulate the inheritance mechanism in Rust with various tricks, it is a better idea to use idiomatic designs instead of twist the language to a foreign way of thinking that will uselessly increase the complexity of code.
  • You should read the chapter about traits in the Rust book to learn more about this topic.