Intro to advanced Rust traits & generics

shuttle_dev

Shuttle

Posted on April 18, 2024

Intro to advanced Rust traits & generics

Hello world! In this post we’re going to give a quick refresher course on Rust traits and generics, as well as implementing some more advanced trait bounds and type signatures.

A quick refresher on Rust traits

Writing a Rust trait is as simple as this:

pub trait MyTrait {
    fn some_method(&self) -> String;
}
Enter fullscreen mode Exit fullscreen mode

Whenever a type implements MyTrait, you can guarantee that it will implement the some_method() function. To implement a trait simply requires that you implement the required methods (the ones with a semi-colon at the end).

struct MyStruct;

impl MyTrait for MyStruct {
    fn some_method(&self) -> String {
        "Hi from some_method!".to_string()
    }
}
Enter fullscreen mode Exit fullscreen mode

You can also implement traits you don’t own on types you do own, or traits you do own on a type you don’t own - but not both! The reason you can’t do this is because of trait coherence. We want to make sure that we don’t accidentally have conflicting trait implementations:

// implementing Into<T>, a trait we don't own, on MyStruct
impl Into<String> for MyStruct {
    fn into(self) -> String {
        "Hello world!".to_string()
    }
}

// implementing MyStruct for a type we don't own
impl MyTrait for String {
    fn some_method(&self) -> String {
        self.to_owned()
    }
}

// You can't do this!
impl Into<String> for &str {
   fn into(self) -> String {
       self.to_owned()
   }
}
Enter fullscreen mode Exit fullscreen mode

A common workaround for this is to create a newtype pattern - that is, a one-field tuple struct encapsulating the type we want to extend.

struct MyStr<'a>(&'a str);

impl<'a> Into<String> for MyStr<'a> {
    fn into(self) -> String {
        self.0.to_owned()
    }
}

fn main() {
    let my_str = MyStr("Hello world!");
    let my_string: String = my_str.into();

    println!("{my_string}");
}
Enter fullscreen mode Exit fullscreen mode

If you have multiple traits that have the same method name, you need to manually declare what trait implementation you’re calling the type from:

pub trait MyTraitTwo {
    fn some_method(&self) -> i32;
}

impl MyTraitTwo for MyStruct {
    fn some_method(&self) -> i32 {
        42
    }
}

fn main() {
    let my_struct = MyStruct;
    println("{}", MyTraitTwo::some_method(&my_struct);
}
Enter fullscreen mode Exit fullscreen mode

Sometimes, you might want the user to be able to have a default implementation as it may otherwise be quite tricky to do so. We can do this by simply defining the method within the trait.

trait MyTrait {
    fn some_method(&self) -> String {
        "Boo!".to_string()
    }
}
Enter fullscreen mode Exit fullscreen mode

Traits can also require other traits! Take the std::error::Error trait for example:

trait Error: Debug + Display {
    // .. re-implement the provided methods here if you want
}
Enter fullscreen mode Exit fullscreen mode

Here, we explicitly tell the compiler that our type must implement both Debug and Display traits before it can implement Error.

An introduction to marker traits

Marker traits are used as a “marker” for the compiler to understand that when a marker trait is implemented for a type, certain guarantees can be upheld. They have no methods or specific properties but are often used to ensure certain behaviors by the compiler.

There’s a couple of reasons why you would want marker traits:

  • The compiler needs to know if something can be guaranteed to do something
  • They’re an implementation-level detail that you can also implement manually

Two marker traits in particular, in conjunction with other lesser-used marker traits, are quite important to us: Send and Sync. Send and Sync are unsafe to implement manually - this is typically because you need to manually ensure that it is implemented safely. Unpin is also another example of this. You can find more about why it's unsafe to manually implement these traits here.

In addition to this, marker traits are also (generally speaking) auto traits. If a struct has fields that all implement an auto trait, the struct itself will also implement the auto trait. For example:

  • If all field types within a struct are Send, the struct is now automatically marked Send by the compiler with no input required from the user.
  • If all but one of your struct fields implement Clone but one doesn’t, your struct now cannot derive Clone anymore. You can get around this by wrapping the relevant type in an Arc or Rc - but this depends on your use case. In certain cases, this is not possible and you may need to think about an alternative solution.

Why do marker traits matter in Rust?

Marker traits in Rust form the core of the ecosystem and allows us to provide garuantees that may not be possible in other languages. For example, Java has marker interfaces which are analogous to Rust’s marker traits. However, marker traits in Rust are not just for behavior like Cloneable or Serializable; they also ensure that types can be sent across threads, for example. This is a subtle but far-reaching difference within the Rust ecosystem. By being able to declare a type as !Send for example, we can ensure that the user never attempts to try to send the type across a thread. This makes the problem of concurrency much easier to handle, as well as a few other things:

  • The Clone trait being required to clone things, but Clone also requires Copy (bitwise copying of a variable).
  • ?Sized - Generics initially are given a static lifetime. You can allow them to be unsized (ie, use references) by adding ?Sized as a trait bound. Essentially what this means is when we implement ?Sized for a type, we are allowing it to be dynamically sized - so the compiler won't be able to allocate space for it at compile-time because it's not constant-size.

Object traits and dynamic dispatch

In addition to all of the above, traits can also make use of dynamic dispatch. Dynamic dispatch is essentially moving the process of selecting which implementation of a polymorphic function to use at runtime. While Rust does favour static dispatch for performance reasons, there are benefits to using dynamic dispatch through trait objects.

The most common pattern for using trait objects would be Box<dyn MyTrait>, where we are required to wrap the trait object in Box to make it implement the Sized trait. Because we’re moving the polymorphism process to runtime, the compiler can’t know what size the type is. Wrapping the type in a pointer (or "boxing" it) puts it on the heap instead of the stack.

// a struct with your object trait in it
struct MyStruct {
     my_field: Box<dyn MyTrait>
}

// an illegal struct that won't compile because of the Sized bound
struct MyStruct: Sized {
    my_field: Box<dyn MyTrait>
}

// this works!
fn my_function(my_item: Box<dyn MyTrait>) {
     // .. some code here
}

// this doesn't!
fn my_function(my_item: dyn MyTrait) {
     // .. some code here
}
Enter fullscreen mode Exit fullscreen mode

The object type will then be computed during runtime, as opposed to generics which use compile-time.

The main advantages of dynamic dispatch are that your function doesn’t need to know the concrete type; as long as the type implements the trait, you can use it as a trait object (as long as it’s trait object safe). This is similar to the concept of duck-typing in other languages, where the functions and properties available to an object determine the typing. You also save some code bloat, which depending on your use can be a good thing.

Errors are also easier to understand from a library user perspective. From a library developer’s point of view this is not such an issue, but if you need to use a generics-heavy library, you can get some very confusing errors! Axum and Diesel are two libraries that can sometimes be guilty of this and have workarounds for this (Axum’s #[debug_handler] macro and Diesel’s documentation, respectively). Because you’re moving the dispatch process to runtime, you also save compilation time.

The downsides are that you need to ensure object trait safety. This basically means ensuring that your type doesn’t require Self: Sized. As mentioned earlier, this stems from the fact that by moving dispatch to runtime, the compiler can’t guess the size of your type - object traits don’t have a constant size at compile-time. This is also why we need to box dynamically dispatched objects and put them on the heap as mentioned earlier. Because of this, your application also takes a performance hit - although of course this depends on how many dynamically dispatched objects you’re using and how large they are!

To illustrate these points further, there are two HTML templating libraries that come to mind:

  • Askama, which uses macros and generics for compile-time checking
  • Tera, which uses dynamic dispatch for hot-reloading

Both of these libraries, while they can be used somewhat interchangeably for most use cases, have different trade-offs. Askama takes longer to compile and any errors will show in compile-time, but Tera only throws compilation errors at runtime and takes a performance hit due to dynamic dispatch. Zola, the Static Site Generator, uses Tera specifically because of certain design conditions that are unable to be satisfied by Askama.

Combining traits and generics

Getting started

Traits and generics synergise very well together and are easy to use. You can write a struct that implements generics like this without much trouble:

struct MyStruct<T> {
    my_field: T
}
Enter fullscreen mode Exit fullscreen mode

However, to be able to use our struct with types from other crates, we will need to ensure that our struct can garuantee certain behavior. This is where we add trait bounds: conditions that a type must satisfy in order for the type to compile. A common trait bound you may find would be Send + Sync + Clone:

struct MyStruct<T: Send + Sync + Clone> {
    my_field: T
}
Enter fullscreen mode Exit fullscreen mode

Now we can use any value we want for T as long as that type implements the Send, Sync and Clone traits!

As a more complex example of using traits with generics that you may occasionally need to re-implement for your own types, take the FromRequest trait from Axum for example (the below code snippet is a simplification of the original trait to illustrate the point):

trait FromRequest<S>
   where S: State
    {
    type Rejection: impl IntoResponse;

    fn from_request(r: Request, _state: S) -> Result<Self, Self::Rejection>;
}
Enter fullscreen mode Exit fullscreen mode

Here we can also add trait bounds by using the where clause. This trait simply tells us that S implements State. However, State also requires the inner object to be Clone. By using complex trait bounds, we can create framework systems that make heavy use of traits to be able to do what some might refer to as “trait magic”. Take a look at this trait bound, for example:

use std::future::Future;

struct MyStruct<T, B> where
   B: Future<Output = String>,
   T: Fn() -> B
   {
    my_field: T
}

#[tokio::main]
async fn main() {
    let my_struct = MyStruct { my_field: hello_world };
    let my_future = (my_struct.my_field)();
    println!("{:?}", my_future.await);
}

async fn hello_world() -> String {
    "Hello world!".to_string()
}
Enter fullscreen mode Exit fullscreen mode

The above one-field struct stores a function closure that returns impl Future<Output = String>, that we store hello_world in and then call it in the main function. We then wrap parenthesis around the field to be able to call it, then await the future. Note that we do not have brackets at the end of the field. This is because adding () at the end actually invokes the function! You can see where we invoke the function after declaring the struct, then awaiting it.

Usage in libraries

Combining traits and generics like this is extremely powerful. One use case where this is effectively leveraged is in HTTP frameworks. Actix Web for example, has a trait called Handler<Args> that takes a number of arguments, calls itself and then has a function called call that produces a Future:

pub trait Handler<Args>: Clone + 'static {
     type Output;
     type Future: Future<Output = Self::Output>;

     fn call(&self, args: Args) -> Self::Future;
}
Enter fullscreen mode Exit fullscreen mode

This then allows us to extend this trait to a handler function. We can tell the web service that we have a function that has an inner function, some arguments and implements Responder (Actix Web’s HTTP response trait):

pub fn to<F, Args>(handler: F) -> Route where
    F: Handler<Args>,
    Args: FromRequest + 'static,
    F::Output: Responder + 'static {
         // .. the actual function  code here
    }
Enter fullscreen mode Exit fullscreen mode

Note that other frameworks like Axum also follow this same methodology to provide an extremely ergonomic developer experience.

Finishing up

Thanks for reading! While traits and generics can be a confusing topic to understand, hopefully this guide to using Rust traits and generics has shed some light on the subject!

Read more:

💖 💪 🙅 🚩
shuttle_dev
Shuttle

Posted on April 18, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Number types ( in RUST )
beginners Number types ( in RUST )

April 3, 2023