Intro to advanced Rust traits & generics
Shuttle
Posted on April 18, 2024
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;
}
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()
}
}
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()
}
}
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}");
}
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);
}
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()
}
}
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
}
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 markedSend
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 deriveClone
anymore. You can get around this by wrapping the relevant type in anArc
orRc
- 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, butClone
also requiresCopy
(bitwise copying of a variable). -
?Sized
- Generics initially are given astatic
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
}
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
}
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
}
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>;
}
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()
}
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;
}
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
}
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:
Posted on April 18, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.