Traits in-depth
Prerequisites
- All of the previous chapter
- constants
- Debug and Display
Anytrait- Brief look at what Arc and Rc is (more in-depth in next chapter)
Traits are the cornerstone of Rust programming. Let's take a more in-depth look.
A trait describes an abstract behavior that types can provide, you can compare it with an interface in different programming languages.
In Rust, we call the members of a trait associated items and they come in three forms:
- associated types
- associated functions (also called trait methods)
- associated constants
Here is an example of a simple trait with all of these:
#![allow(unused)] fn main() { // Examples of associated trait items with and without definitions. trait Example { const CONST_NO_DEFAULT: i32; const CONST_WITH_DEFAULT: i32 = 99; type TypeNoDefault; fn method_without_default(&self); fn method_with_default(&self) {} } }
As you can see, associated items can have a default, this is commonly used as a pattern, where the end programmer has to only provide a small number of trait methods and gets the benefit of a number of methods that are implemented using the ones they had to provide.
The most common example of this is the Iterator trait, which only requires an implementation
of fn next(&mut self) -> Option<Self::Item>; but in return provides (at the time of this writing) 69
other methods.
Given Rust's approach towards Object-Oriented programming being one that strongly favors composition over inheritance, it is seldom seen that a large trait inheritance hierarchy would be built, however, Rust still provides facilities for inheritance in the form of supertraits.
Supertraits and inheritance
The supertrait syntax allows defining a trait as being a superset of another trait, for example:
#![allow(unused)] fn main() { trait Person { fn name(&self) -> String; } // Person is a supertrait of Student. // Implementing Student requires you to also impl Person. trait Student: Person { fn university(&self) -> String; } trait Programmer { fn fav_language(&self) -> String; } // CompSciStudent (computer science student) is a subtrait of both Programmer // and Student. Implementing CompSciStudent requires you to impl both supertraits. trait CompSciStudent: Programmer + Student { fn git_username(&self) -> String; } fn comp_sci_student_greeting(student: &dyn CompSciStudent) -> String { format!( "My name is {} and I attend {}. My favorite language is {}. My Git username is {}", student.name(), student.university(), student.fav_language(), student.git_username() ) } }
example taken from https://doc.rust-lang.org/rust-by-example/trait/supertraits.html
In trait implementations of subtraits, you can use all trait associated items of all supertraits.
This also applies to default implementations in trait {} block.
#![allow(unused)] fn main() { use std::fmt::Display; // can only be implemented for types implementing debug trait MyTrait: Display { const NAME: String; fn print_self(&self) { // I can do this because I know Self: Display holds println!("{} = {}", Self::NAME, &self); } } }
Although it is seldom used in Rust, supertraits can be used to model an inheritance hierarchy similar to what you would use classes for in languages like C# or Java. However, the preferred approach in Rust is to use OOP composition over OOP inheritance.
Instead of modeling a type (or in Rust's case trait) hierarchy, you create traits for behaviors and
in applicable parameters or generic struct fields you insert appropriate trait bounds which list all
required traits such as T: Renderable + TakesInput + CanMove.
This composition pattern is utilized all over Rust code, including game development where it meshes nicely with the currently popular ECS - Entity Component Model architecture.
Bounds and type parameters
Further continuing on the topic of trait bounds, just like structs and functions (and by extension methods), traits can have parameters and trait bounds.
These can be used for two things:
- to facilitate providing multiple non-conflicting implementations of a trait on a single type, each with different parameters
- to model relationships between types and traits
Rust is fairly lax with trait bounds (provided you don't create a bound that isn't recursive and unresolvable), so you can do even things like this, which do not make much sense on first sight:
#![allow(unused)] fn main() { use std::fmt::Debug; trait Test where i32: Debug {} }
This is a constant bound, it is either always true or always false, possibly rendering the entire trait useless. While this may seem non-sensical, it is sometimes used in macros.
Trait bounds can make requirements on implemented type as well by referencing Self in terms of different traits,
such as:
#![allow(unused)] fn main() { trait Example where <Self::AssociatedType as Trait2>::Trait2AssociatedType: Add<Rhs = <Self as Trait3>::Trait3AssociatedType> { type AssociatedType; } }
Which can be read as following:
It holds that I have an associated type
AssociatedTypewhich implementsTrait2such that the associated typeTrait2AssociatedTypeof said implementation satisfies the traitAddwhere where theRhsassociated type equals the associated typeTrait3AssociatedTypefrom theTrait3implementation onSelf(sort of implicitly creating a requirement thatSelf: Trait3)
So, as you can see, trait bounds can be quite extensive.
Orphan instances and trait coherence
In Rust, trait implementations must uphold trait coherence. In layman's terms, coherence is a property that there exists at most one implementation of a trait for any given type.
Languages with features like traits must either:
- Enforce coherence by refusing to compile programs with conflicting implementations
- Or embrace incoherence and give programmers a way to manually choose an implementation in case of a conflict
Rust chooses the former option.
However, you may have seen something like this in Rust nightly libraries:
#![allow(unused)] fn main() { #![feature(min_specialization)] trait TestTrait<T> {} impl<T, I: Iterator<Item=T>> TestTrait<T> for I {/* default impl */ } impl<T> TestTrait<T> for std::vec::IntoIter<T> { /* specialized impl */ } }
This is called specialization: for a given type, only the more specialized implementation is considered. Specialization is however to a large degree a nightly-only feature (broader specialization is actually used on a number of places in the standard library, but not available to end programmers outside nightly, only a very limited subset of specialization is merged to stable).
Overlapping is also forbidden, you can't write impl<T: Trait1> Trait for T and impl<T: Trait2> Trait for T because
T: Trait1 + Trait2 might exist and satisfies both trait bounds and that would lead to conflicting implementations.
Trait coherence is related to another concept, and that is orphan instances.
An orphan instance is a trait implementation that does not have an tangible enough link to your crate. In essence,
you cannot create an impl where both the trait and the type are defined outside your crate.
#![allow(unused)] fn main() { // You can't do this use dependency::CoolTrait; impl CoolTrait for String { // invalid, neither String nor CoolTrait comes from your crate, // so when your crate (a library) would disappear from the dependency graph of a project // there would be a change to the type system that does not have a straightforward explanation // ("Why did removing Dep 3 change how Dep 1 and 2 behave when neither of them depend on Dep 3?") } // more insidious example use dependency::CoolTrait; trait MyTrait { } impl<T> Coolrait for T where T: MyTrait { // This might seem like it has // a tangible link to your crate, // however, it is still an orphan instance, // as T might come from a foreign crate // and: // // for Dep 1 which contains T, // Dep 2 which contains CoolTrait, // Dep 3 which contains MyTrait and this blanket implementation // // Another dependency might have another unclear behavior change upon removing // Dep 3 when it only works with Dep 1 and 2 to work generically with types implementing CoolTrait } }
You are much more likely to run into a variation on the second example once you start paying attention to the issue, however as long as you remember that you shouldn't try to tie foreign traits and foreign types if there is no concrete type of yours involved, you should be fine.
The precise so-called "orphan rules" are rather complex, but if you try to avoid the above, you will generally not run into them.
At the time of this writing, it appears the most precise description of the current orphan rules is in RFC 2451, feel free to check it out.
Object safety and trait objects
As stated in The Case of the Strange Linked List, Rust also supports dynamic dispatch by way of trait objects.
A trait object is an opaque value of a type that implements a set of one or more traits. The set must be made of an object-safe base trait plus any number of auto traits.
For brevity, think of auto traits as memory-safety describing marker (ie. without associated items) traits from the standard library, these are:
SyncSendUnpinUnwindSafeRefUnwindSafe
(You can think of Sized as an auto-trait as well, but it is actually not implemented like one)
The trait object opaque value is made up of a fat pointer, which contains both a pointer to the underlying type instance and a table of virtual functions lookup in which leads to the corresponding trait method implementations.
This feature makes comparing trait object tricky, as the same trait object might have different vtable pointers in different parts of code, across codegen unit boundary.
The trick to comparison is to cast to raw pointer of any type to drop the vtable part of the fat pointer:
#![allow(unused)] fn main() { &*trait_obj as *const _ as *const () }
and that can then be compared and produce sane results.
You can see examples of trait objects in The Case of the Strange Linked List 1.
A couple paragraphs back, it was mentioned that for a trait to be a valid trait-object producing candidate, it must be object-safe.
The following are the conditions for object safety:
- All supertraits also must be object safe.
- Sized must not be a supertrait. In other words, it must not require Self: Sized.
- It must not have any associated constants.
- All associated functions must either be dispatchable from a trait object or be explicitly non-dispatchable:
- Dispatchable functions require:
- Not have any type parameters (although lifetime parameters are allowed),
- Be a method that does not use Self except in the type of the receiver.
- Have a receiver with one of the following types:
&Self(i.e.&self)&mut Self(i.e&mut self)Box<Self>Rc<Self>Arc<Self>Pin<P>where P is one of the types above
- Does not have a
where Self: Sizedbound (receiver type of Self (i.e. self) implies this).
- Explicitly non-dispatchable functions require:
- Have a
where Self: Sizedbound (receiver type of Self (i.e. self) implies this).
- Have a
- Dispatchable functions require:
Sizedness
As you might have already noticed at this point, in Rust, we speak about types that are sized and unsized.
Unsized types are generally seen behind pointers, you may have encountered str, Path, or trait objects.
There is one thing to be known: &SizedType is not the same as &UnsizedType. In the latter case, the pointer
becomes a so-called fat pointer and it contains not only the in-memory location of the instance of the
type, but also its size.
You can verify this quickly by using the mem::size_of::<T>() function in Rust.i
use std::mem; fn main() { println!("size of string pointer: {}", mem::size_of::<&String>()); println!("size of str pointer: {}", mem::size_of::<&str>()); }
If all goes well, something like this should be the result:
size of string pointer: 8
size of str pointer: 16
A common mistake people make is thinking that pointers to arrays are fat pointers (because pointers to slices are). However, we don't need to store the size of an array in a pointer, since it is constant and always known. There is no such thing in Rust as a variable length array.
If you are writing generic bounds, they expect Sized types by default (meaning either Sized types, or Unsized types behind a pointer).
However, if you want to accept them also, you can do it by manually opting out of the where: Sized thread bound.
#![allow(unused)] fn main() { fn my_function<T>(_: &T) where T: ?Sized {} }
The question mark syntax is a special syntax that is currently only used with Sized.
The implicit Self type in a trait does not have this bound, although it can be added manually.
You can also use Sized in a supertrait:
#![allow(unused)] fn main() { trait MyTrait: Sized {} }
This will make it impossible to ever construct a trait object of said trait with *any type, preventing the use of dynamic dispatch with this trait.
Implementing trait objects
You can implement methods on trait objects just like you can do it with structures. This comes in handy, if you want to do some things that are forbidden on object-safe traits, such as generic methods.
#![allow(unused)] fn main() { impl dyn Trait { fn my_generic<T>(&self, _: T) {} } }
This feature is heavily utilized by std::any::Any to provide its downcasting-to-concrete-type functionality.
The First Task: Table traits
Imagine the following scenario:
Your project uses a table internally, which is stored on disk, and a cache of the table.
The cache and the table share many features, in fact, they have to so that the cache description matches the table.
You need to uphold that for a given table type, a cache implementation matches the table implementations.
Do the following:
- Create a trait
Innerwith a further unspecified associated type - Create traits
TableandMemory, which both contain an associated type satisfyingInner - Create a trait
Cachewhich contains an associated type satisfyingTableand an associated type satisfyingMemory - Add a trait bound to
Cache(or perhaps its associated type(s)) such that the associated type inInnerofTableis the same as the associated type inInnerofMemory
Ensure your code is properly formatted, produces no warnings, works on stable Rust and follows the Braiins Standard
The Second Task: Super and subtrait casting in trait objects
Consider the following functions:
fn wants_super(arc: Arc<dyn SuperTrait>) { arc.hello(); } fn wants_sub(arc: Arc<dyn SubTrait>) { wants_super(/* your code here */) } fn main() { wants_sub(Arc::new(MyStruct)) }
- Create
SuperTraitsuch thatfn hello(&self)prints something nice - Create
SubTraitsuch that you can cast it toSuperTrait - Implement the traits on a unit structure called
MyStruct
End product
In the end you should be left with a well prepared project, that has the following:
- documented code explaining your reasoning where it isn't self-evident
- optionally tests
- and an example or two where applicable
- clean git history that does not contain fix-ups, merge commits or malformed/misformatted commits
Your Rust code should be formatted by rustfmt / cargo fmt and should produce no
warnings when built.