Unlocking the Power of Traits in Rust
What are Traits?
A trait is a way to define shared behavior in Rust. It’s a collection of methods defined for an unknown type, referred to as Self
. Traits can access other methods declared in the same trait. When you want to define a function that can be applied to any type with some required behavior, you use traits.
Defining and Implementing Traits
To define a trait, you use the trait
keyword. Traits can have methods, and Rust also has something called marker traits, which don’t have any behavior but give the compiler certain guarantees. Let’s examine the use of Self
and &self
in trait definitions.
trait MyTrait {
fn my_method(&self);
}
Understanding Self and &self
In a trait definition, you have access to a special type: Self
. Self
refers to the implementing type. The &self
argument is syntactic sugar for self: &Self
, which means the first argument to a method is an instance of the implementing type.
Methods: Instances, Static Context, and Default Implementations
Trait methods that require an instance are known as instance methods. Those that don’t require an instance are called static methods. Methods can also have default implementations, making implementing this method optional when implementing the trait.
trait MyTrait {
fn my_instance_method(&self); // instance method
fn my_static_method(); // static method
fn my_default_method(&self) { /* default implementation */ }
}
Implementing Traits
To implement a trait, declare an impl
block for the type you want to implement the trait for. You’ll need to implement all the methods that don’t have default implementations.
struct MyStruct {}
impl MyTrait for MyStruct {
fn my_instance_method(&self) { /* implementation */ }
fn my_static_method() { /* implementation */ }
}
Importing Traits from External Crates
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 can do it like this:
use serde::Serialize;
Using Traits in Practice
Traits can be used as function parameters to allow the function to accept any type that can do x, where x is some behavior defined by a trait. You can also use trait bounds to refine and restrict generics.
fn my_function(t: &T) { /* implementation */ }
Returning Traits
You can use traits as return types from functions. There are two different ways to do this: impl Trait
and Box<dyn Trait>
. The differences are subtle but important.
fn my_function() -> impl MyTrait { /* implementation */ }
fn my_function() -> Box<dyn MyTrait> { /* implementation */ }
Trait Combos and Supertraits
You may want to use multiple traits together, known as a trait combo. To do this, you ‘add’ the traits together: T: Trait1 + Trait2 + Trait3
. Rust has a way to specify that a trait is an extension of another trait, giving us something similar to subclassing in other languages.
fn my_function() { /* implementation */ }
Associated Types and Trait Constants
Associated types are a term that shows up often when working with traits. They share similarities with generics, but have some key differences. Traits can also have associated constants, which is less common than trait methods.
trait MyTrait {
type MyType;
const MY_CONSTANT: i32;
}
Deriving Traits and the Orphan Rule
Some traits can be automatically derived, meaning the compiler can implement these traits for you. However, 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 the orphan rule.
- Recommended reading:
- The Rust Programming Language (chapters on traits and advanced traits)
- Rust by Example (chapters on traits)