Notes on Traits and You: A Deep Dive Part 3

Original content by Nell Shamrell-Harrington

When looking for resources to help me better understand Rust Traits, I found this videofrom Nell Shamrell-Harrington. It's a great intro and overview into using Traits, which can be a little confusing when you first start learning Rust.

So these are my notes on this talk, if you haven't watched it and are struggling with learning Traits, I strongly suggest that you watch the video above before you do anything else. But if you're short on time and just want to reference my notes -- here they are. Disclaimer: these notes are my own interpretation of the talk and may not necessarily reflect the views of the original speaker. These note are primarily for me to learn these concepts, and I publish them for others to see and maybe benefit from. Feel free to let me know if I have errors or have misrepresentations.

Introduction

The talk covers 3 different ways that we can use Traits, I have already published the first two parts of these notes, you can see those linked below. So today we'll cover the final note with Trait Objects.

Nell brilliantly uses D&D as a metaphor to help explain Traits. It should be clear that the use of D&D is just to help grasp some concepts, and the D&D rules do not strictly apply in these concepts, so now that we have that out of the way let's begin.

Traits 301: Trait Objects

This is where the magic really starts to happen and it might take a little to wrap your head around this concept but let's take a look at how traditional object oriented programming languages hand this.

In traditional Object Oriented Programming we have Data & Behavior in one "place". With Rust we can say that we use enums and structs for data, and traits for behavior, this actually gives us more flexibility and control

  • Object Oriented Programming = (data and behaviour together)
  • Rust = enums/structs (data), traits (behavior)

This is actually one of the powers that Rust has as we can mix and match behaviours as we need it in our code.

Note: Trait Objects allow us to work with types that implement a specific trait without knowing their exact type. They enable a more dynamic approach similar to traditional object-oriented programming languages but maintain Rust's separation of data and behavior.

So how does a Trait Object store it's data? Well, a Trait Object contains the data in a trait pointer to a value on the heap.

Trait Object: Pointer(Data) -> Value(Heap) The advantage of doing this is that even if the size of the value on the heap varies, the size of the pointer will always be the same and it's more predictable as well.

Trait Object: Trait(Behavior) Although the Data is located on the heap, one thing to note is you cannot add data to a trait object, this is key to understanding trait objects and how to use them. We point to the data at one specific point in time.

Back to D&D

Let's go back our D&D analogy and this time we'll look at different spells that can be cast in the game. Let's say we have a wizard that needs to cast a bunch of different spells.

  • Cantrip
  • Transmutation
  • Enchantment
  • Necromancy

Let's start by creating these spells as structs

// let's create these structs
struct Cantrip {

}

struct Enchantment {

}

struct Transmutation {

}

struct Necromancy {

}

Although these are 4 different kinds of spells, the thing that they all have in common is that they all need to be cast, even if they specific way that they are cast is different from spell to spell.

So with this information let's make a Cast trait

// let's make a trait, for now we won't define a default behaviour
pub trait Cast {
	fn cast(&self);
}

Now let's implement the Cast trait on one our spells, let's use Cantrip

// now we implement this trait for the Cantrip struct
impl Cast for Cantrip{
	fn cast(&self){
		// We'd put details of casting a Cantrip spell here
	}
}

Let's do the same for the Transmutation spell

// we do the same for the transmutation struct
impl Cast for Transmutation
	fn cast(&self){
		// We'd put details of casting a Transmutation spell here
	}

We can do the same for our Enchament and Necromancy spells, so each of the spells can now be cast, although each spell is defined differently, below is the full list of spells with Cast implemented on them.

// now we implement this trait for the Cantrip struct
impl Cast for Cantrip{
	fn cast(&self){
		// We'd put details of casting a Cantrip spell here
	}
}

// we do the same for the transmutation struct
impl Cast for Transmutation
	fn cast(&self){
		// We'd put details of casting a Transmutation spell here
	}


// we do the same for the Enchantment struct
impl Cast for Enchantment
	fn cast(&self){
		// We'd put details of casting a Enchantment spell here
	}

// we do the same for the Necromancy struct
impl Cast for Necromancy
	fn cast(&self){
		// We'd put details of casting a Necromancy spell here
	}

Cool, we're building up a little library of spells, so how do we organize these spells, where do we keep them? In a spell book of course! We'll represent our spell book as a struct

struct Spellbook {
	pub spells: Vec<Box><Cast>>, // here is where we define the spell field
}

Let's break this line of code a little bit. As you can see we define spells as a Vec<T>, (we don't go into generics in this blog but that's what the T represent, a generic type), a Vector that contains an object of a type, the Type that we are grouping in our vector for spells is a <Box<T>>. A Box in Rust is a pointer to a value on the heap (remember we talked about that earlier?). The reason we choose to use Box in Vec<Box><<Cast>> is because it allows us to store the trait object in a dynamically sized container, such as a vector, since trait objects have an unknown size at compile time.

With a Vec<Box<T>> we can only point to a value of a certain type (T) as we see here, in our case we say that we can contain any type that implements the Cast trait: Vec<Box<Cast>> so Box must point to a value that implements the Cast trait. It doesn't matter what type as long as it implements that particular trait.

Let's review: The pub spells field is a vector Vec, that vector contains Boxes and those boxes point to values that implement the Cast trait represented as: pub spells: Vec<Box><Cast>>.

What this means is that our Wizard can now cast ALL of these spells, one right after the other and we can do this in Rust by defining the behavior in our Spellbook struct.

impl Spellbook {
	pub fn run(&self) { // implementing a function called run
		for spell in self.spells.iter(){ // this will iterate through the spells
			spell.cast(); // and casting spells one after the other
		}
	}
}

This is a function that "runs" the spells essentially iterating through them and casting them one after the other.

Let's see a visual representation of our Wizard in action.

wizard

Now let's look at how this code would look like:

let spell_book = Spellbook {
	spells: vec![
	// different types of spells, each implement the `cast`trait
		Box::new(Cantrip{}),
		Box::new(Transmutation{}),
		Box::new(Enchantment{}),
		Box::new(Necromancy{}),
	],
};
spell_book.run(); // this casts each spell, in which ever way they need to be cast

What this highlights is that Trait Object are great for heterogeneous collections or diverse and mixed collections. Where we can have objects of different types stored in the same place It doesn't matter what type something is as long as it implements a certain trait, this gives us a ton of flexibility when using Rust.

Recap

In this blog post, we explored Rust Traits, focusing on Trait Objects. By diving into a D&D-themed example, we demonstrated the power and flexibility of Traits in Rust. Here are the key takeaways:

  • Rust separates data (enums/structs) and behavior (traits), which allows for greater flexibility and control compared to traditional object-oriented programming languages.
  • Trait Objects enable us to work with types that implement a specific trait without knowing their exact type. This approach is similar to traditional object-oriented programming languages but maintains Rust's separation of data and behavior.
  • Trait Objects are particularly useful for heterogeneous collections or diverse and mixed collections. They allow us to store objects of different types in the same place as long as they implement a certain trait.
  • The Box pointer is utilized for storing trait objects in dynamically sized containers, such as vectors, since trait objects have an unknown size at compile time.
  • The D&D example illustrated how to create structs, implement traits, and use Trait Objects to organize and execute a collection of diverse spells.

By understanding and leveraging Rust Traits and Trait Objects, you can harness the flexibility and power of the Rust programming language to build efficient and maintainable code. With practice and review of Nell's excellent video, you'll be mastering Traits in no time.