Traits
You've used traits a lot---they are an important part of Rust. But we haven't really talked about them.
Implementing Traits
Whenever you've used #[derive(Debug, Clone, Serialize)]
and similar---you are using procedural macros to implement traits. We're not going to dig into procedural macros---they are worthy of their own class---but we will look at what they are doing.
Debug
is a trait. The derive macro is implementing the trait for you (including identifying all of the fields to output). You can implement it yourself:
#![allow(unused)] fn main() { use std::fmt; struct Point { x: i32, y: i32, } impl fmt::Debug for Point { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Point") .field("x", &self.x) .field("y", &self.y) .finish() } } }
Traits are an interface. Each trait defines functions that must be implemented to apply the trait to a type. Once you implement the trait, you can use the trait's functions on the type---and you can also use the trait as a type.
Making a Trait
The code for this is in
code/04_mem/make_trait
.
Let's create a very simple trait:
#![allow(unused)] fn main() { trait Animal { fn speak(&self); } }
This trait has one function: speak
. It takes a reference to self
(the type implementing the trait) and returns nothing.
Note: trait parameters are also part of the interface, so if a trait entry needs
&self
---all implementations of it will need&self
.
Now we can make a cat:
#![allow(unused)] fn main() { struct Cat; impl Animal for Cat { fn speak(&self) { println!("Meow"); } } }
Now you can run speak()
on any Cat
:
fn main() { let cat = Cat; cat.speak(); }
You could go on and implement as many speaking animals as you like.
Traits as Function Parameters
You can also create functions that require that a parameter implement a trait:
#![allow(unused)] fn main() { fn speak_twice(animal: &impl Animal) { animal.speak(); animal.speak(); } }
You can call it with speak_twice(&cat)
---and it runs the trait's function twice.
Traits as Return Types
You can also return a trait from a function:
#![allow(unused)] fn main() { fn get_animal() -> impl Animal { Cat } }
The fun part here is that you no-longer know the concrete type of the returned type---you know for sure that it implements Animal
. So you can call speak
on it, but if Cat
implements other traits or functions, you can't call those functions.
Traits that Require Other Traits
You could require that all Animal
types require Debug
be also implemented:
#![allow(unused)] fn main() { trait Animal: Debug { fn speak(&self); } }
Now Cat
won't compile until you derive (or implement) `Debug).
You can keep piling on the requirements:
#![allow(unused)] fn main() { trait DebuggableClonableAnimal: Animal+Debug+Clone {} }
Let's make a Dog that complies with these rules:
#![allow(unused)] fn main() { #[derive(Debug, Clone)] struct Dog; impl Animal for Dog { fn speak(&self) { println!("Woof"); } } impl DebuggableClonableAnimal for Dog {} }
Now you can make a dog and call speak
on it. You can also use DebuggableCloneableAnimal
as a parameter or return type, and be sure that all of the trait functions are available.
Dynamic Dispatch
All of the examples above can be resolved at compile time. The compiler knows the concrete type of the trait, and can generate the code for it. But what if you want to store a bunch of different types in a collection, and call a trait function on all of them?
You might want to try this:
#![allow(unused)] fn main() { let animals: Vec<impl Animal> = vec![Cat, Dog]; }
And it won't work. The reason it won't work is that Vec
stores identical entries for each record. That means it needs to know the size of the entry. Since cats and dogs might be of different sizes, Vec
can't store them.
You can get around this with dynamic dispatch. You've seen this once before, with type GenericResult<T> = std::result::Result<T, Box<dyn std::error::Error>>;
. The dyn
keyword means that the type is dynamic---it can be different sizes.
Now think back to boxes. Boxes are a smart-pointer. That means they occupy the size of a pointer in memory, and that pointer tells you where the data actually is in the heap. So you can make a vector of dynamic, boxed traits:
#![allow(unused)] fn main() { let animals: Vec<Box<dyn Animal>> = vec![Box::new(Cat), Box::new(Dog)]; }
Each vector entry is a pointer (with a type hint) to a trait. The trait itself is stored in the heap. Accessing each entry requires a pointer dereference and a virtual function call. (A vtable
will be implemented, but often optimized away---LLVM is very good at avoiding making vtables when it can).
In the threads class, someone asked if you could "send interfaces to channels". And yes, you can---you have to use dynamic dispatch to do it. This is valid:
#![allow(unused)] fn main() { let (tx, rx) = std::sync::mpsc::channel::<Box<dyn Animal>>(); }
This works with other pointer types like
Rc
, andArc
, too. You can have a reference-counted, dynamic dispatch pointer to a trait.
Using dynamic dispatch won't perform as well as static dispatch, because of pointer chasing (which reduces the likelihood of a memory cache hit).
The Any
Type
If you really, really need to find out the concrete type of a dynamically dispatched trait, you can use the std::any::Any
trait. It's not the most efficient design, but it's there if you really need it.
The easiest way to "downcast" is to require Any
in your type and an as_any
function:
#![allow(unused)] fn main() { struct Tortoise; impl Animal for Tortoise { fn speak(&self) { println!("What noise does a tortoise make anyway?"); } } impl DowncastableAnimal for Tortoise { fn as_any(&self) -> &dyn Any { self } } }
Then you can "downcast" to the concrete type:
#![allow(unused)] fn main() { let more_animals : Vec<Box<dyn DowncastableAnimal>> = vec![Box::new(Tortoise)]; for animal in more_animals.iter() { if let Some(cat) = animal.as_any().downcast_ref::<Tortoise>() { println!("We have access to the tortoise"); } animal.speak(); } }
If you can avoid this pattern, you should. It's not very Rusty---it's pretending to be an object-oriented language. But it's there if you need it.
Implementing Operators
"Operator overloading" got a bad name from C++. You can abuse it, and decide that operators do bizarre things. Please don't. If you allow two types to be added together, please use an operation that makes sense to the code reader!
See the
04_mem/operator_overload
project.
You can implement operators for your types. Let's make a Point
type that can be added together:
use std::ops::Add; struct Point { x: f32, y: f32, } impl Add for Point { type Output = Point; fn add(self, rhs: Self) -> Self::Output { Point { x: self.x + rhs.x, y: self.y + rhs.y } } } fn main() { let a = Point { x: 1.0, y: 2.0 }; let b = Point { x: 3.0, y: 4.0 }; let c = a + b; println!("c.x = {}, c.y = {}", c.x, c.y); }
There's a full range of operators you can overload. You can also overload the +=
, /
, *
operators, and so on. This is very powerful for letting you express functions (rather than remembering to add x
and y
each time)---but it can be abused horribly if you decide that +
should mean "subtract" or something. Don't do that. Please.