Thomas Heartman Developer, speaker, musician, and fitness instructor.

Rust traits: A deep dive

6 min read 1829

The Rust Symbol

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.

What are Rust traits?

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.

Traits in Rust

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.

Defining a trait in Rust

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.

What are 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.

Methods: Instances, static context, and default implementations

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.

Implementing a trait in Rust

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.

Importing a trait from an external crate

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!()
    }
}

How to use traits in Rust

Having gone over how we define and implement traits, let’s walk through how to use them in practice.

Traits in function arguments and trait bounds

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.

Returning traits

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.


More great articles from LogRocket:


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.”

Trait combos

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.

Supertraits

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.

Associated types

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.”

Trait constants

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.

The orphan rule

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.

Further reading

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.

LogRocket: Full visibility into web frontends for Rust apps

Debugging Rust applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking 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 app. 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 — .

Thomas Heartman Developer, speaker, musician, and fitness instructor.

One Reply to “Rust traits: A deep dive”

Leave a Reply