When learning Rust, it’s likely that you’ll run into the concept of traits sooner rather than later.
Traits allow us to share behavior across types and facilitates code reuse. They also enable us to write functions that accept different types for their arguments, as long as the type implements the required behavior in the form of a trait.
In this tutorial, we’ll go over the basics of traits in Rust and cover some issues you’ll run into most often.
A trait is a way to define shared behavior in Rust. As Rust by Example puts it:
A
trait
is a collection of methods defined for an unknown type:Self
. They can access other methods declared in the same trait.
When we want to define a function that can be applied to any type with some required behavior, we use traits.
To start us off, let’s look at some trait basics, including how we define and implement them, some terminology and syntactic sugar, and what method types we can define on a trait.
To define a trait, we use the trait
keyword:
trait WithName { fn new(name: String) -> Self; fn get_name(&self) -> &str; fn print(&self) { println!("My name is {}", self.get_name()) } }
Because a trait is a way to define shared behavior, we can (and usually will) define methods that belong to the trait we’re defining. While most traits exist to share behavior, Rust also has something called marker traits. Marker traits don’t have any behavior but are used to give the compiler certain guarantees. You can read more about the std::marker
module in the Rust documentation.
In the above trait definition, there are three methods that all behave a little differently. We’ll look closer at this in a moment, but first, let’s examine the use of Self
and &self
.
Self
and &self
?In a trait definition (including the one above), we have access to a special type: Self
. Self
is a special keyword that is only available within type definitions, trait definitions, and impl
blocks (according to the Rust documentation).
In trait definitions, it refers to the implementing type. In other words, if we were to declare a struct A
and implement the WithName
trait for it, then the Self
type that is returned by the new
method would be A
. For a struct B
, the type would be B
.
The &self
argument is syntactic sugar for self: &Self
. This means that the first argument to a method is an instance of the implementing type. To require a mutable reference to self, use &mut self
. When calling instance methods on an instance of a type (let name = x.get_name()
), the &self
argument is implicit.
Because the &self
argument in a trait method requires an instance of the implementing type, these methods are known as instance methods. Trait methods that don’t require an instance are called static methods. As with the new
method above, these are commonly used to instantiate types that implement the trait.
Methods can also have default implementations, such as the WithName
trait’s print
method. This makes implementing this method optional when implementing the trait. If it is not overridden, the default implementation is used.
To implement a trait, declare an impl
block for the type you want to implement the trait for. The syntax is impl <trait> for <type>
. You’ll need to implement all the methods that don’t have default implementations.
For instance, if we wanted to implement the WithName
trait for a struct Name(String)
, we could do it like this:
trait WithName { fn new(name: String) -> Self; fn get_name(&self) -> &str; fn print(&self) { println!("My name is {}", self.get_name()) } } struct Name(String); impl WithName for Name { fn new(name: String) -> Self { Name(name) } fn get_name(&self) -> &str { &self.0 } }
If your implementation is incomplete, your trusty friend the compiler will let you know.
To import a trait from an external crate, use a regular use
statement. For instance, if you want to import the Serialize
trait from Serde, a popular serialization/deserialization crate for Rust, you could do it like this:
use serde::Serialize; struct A; impl Serialize for A { fn serialize<S>( &self, _: S, ) -> Result<<S as serde::Serializer>::Ok, <S as serde::Serializer>::Error> where S: serde::Serializer, { todo!() } }
Having gone over how we define and implement traits, let’s walk through how to use them in practice.
We can use traits as function parameters to allow the function to accept any type that can do x
, where x
is some behavior defined by a trait. We can also use trait bounds to refine and restrict generics, such as by saying we accept any type T
that implements a specified trait.
At first glance, these two ways to use traits may sound like they achieve the same thing, but there is a subtle difference. In cases where there are multiple parameters, we can make sure they are the same type by using trait bounds.
Consider these two functions:
use std::fmt::Debug; fn f(a: &Debug, b: &Debug) { todo!() } fn g<T: Debug>(a: &T, b: &T) { todo!() }
Ignoring the fact that they don’t do anything, the function f
will accept any two arguments that implement the Debug
trait, even if they are two different types. On the other hand, g
will only accept two arguments of the same type, but that type can be any type that implements Debug
.
In other words, the arguments to g
must both be of the same type, whereas the arguments to f
may be of the same type, but may also be two different types.
We can use traits as return types from functions. There are two different ways to do this: impl Trait
and Box<dyn Trait>
. Again, the differences are subtle but important.
use std::fmt::Debug; fn dyn_trait(n: u8) -> Box<dyn Debug> { todo!() } fn impl_trait(n: u8) -> impl Debug { todo!() }
The dyn_trait
function can return any number of types that implement the Debug
trait and can even return a different type depending on the input argument. This is known as a trait object. “The Rust Programming Language” book has a section on using trait objects for dynamic dispatch if you want to delve further.
The impl_trait
method, on the other hand, can only return a single type that implements the Debug
trait. In other words, the function will always return the same type.
While this difference may make impl Trait
appear less useful than trait objects, it has a very important feature (in addition to being easier to type and to work with): you can use it to return iterators and closures. This is explained further in the book’s chapter on traits, “Returning Types that Implement Traits.”
You may want to use multiple traits together. This is what’s known as a trait combo. To do this, you ‘add’ the traits together: T: Trait1 + Trait2 + Trait3
.
At the time of writing, there’s no way to refer to a specific combination of traits by another name on stable Rust. This feature, known as trait aliases, is, however, available on the nightly channel. Check out the tracking issue on GitHub.
Rust has a way to specify that a trait is an extension of another trait, giving us something similar to subclassing in other languages.
To create a subtrait, indicate that it implements the supertrait in the same way you would with a type:
trait MyDebug : std::fmt::Debug { fn my_subtrait_function(&self); }
To implement this new subtrait, you must implement all the required methods on the supertrait as well as any required methods on the subtrait.
When working with traits, associated types is a term that shows up pretty often. One of the most prominent examples is in the Iterator
trait, where it is used to indicate what the return type of the next
function should be. They share a number of similarities with generics (or type parameters), but have some key differences.
In short, generics can do anything associated types can. However, using associated types constrains the implementing type to only have a single implementation of this trait. This is useful in a number of situations, such as in the Iterator
and Deref
traits.
To read more about associated types, check out the section of the book titled “Specifying Placeholder Types in Trait Definitions with Associated Types.”
Traits can also have associated constants. This is less common than trait methods, but is not without its uses. Like with methods, constants may provide a default value.
trait ConstTrait { const GREETING: &'static str; const NUMBER: i32 = 42; }
#[derive]
Some traits can be automatically derived, meaning the compiler can implement these traits for you. Rust by Example has a good explanation of how to do this and how it works. This is commonly seen with the std::fmt::Debug
trait (#[derive(Debug)]
) among others.
Deriving traits is a great way to get additional functionality for your types without having to do the work yourself.
When working with traits, you might run into a case where you want to implement a trait from an external crate for a type from another external crate. This doesn’t work because of what’s known as the orphan rule: If you don’t own the trait or the type, you can’t implement the trait for the type. But if you own one or the other, you can.
According to the Book,
Without [this] rule, two crates could implement the same trait for the same type, and Rust wouldn’t know which implementation to use.
If you run into cases where you need to work around this restriction, you can use the newtype pattern.
Traits are an important part of Rust, so there’s a lot of ground to cover. Hopefully, this tutorial has given you an understanding of what traits are, how they work, and how to approach advanced use cases.
I urge you to take a look at the references littered throughout the text if you want to learn more about any of these topics on your own. If you want to dive deeper after reading this guide, I recommend checking out the chapters on traits and advanced traits in “The Rust Programming Language,” and the chapters on traits in Rust by Example.
Debugging Rust applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking the performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust application. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Modernize how you debug your Rust apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
One Reply to "Rust traits: A deep dive"
great! thanks for this blog on traits