It's Traits (Almost) All the Way Down (Rustioms)
Traits are seen in a lot of places in the standard library, some are markers (the presence or not of an implementation determines some other behavior), capabilities that the compiler may use for type conversion for example. The fact that traits are a very light-weight mechanism and support default method implementation means the implementation of sets of traits are inexpensive to use but add significant value. Library writers definitely should to look at existing types in the standard library and see what traits they implement.
For example, here are some ways in which we use common traits as library writers.
- It’s recommended practice to always implement
std::fmt::Debug
for all public types, this is simple and usually done using the#[derive(Debug)]
attribute on your type. std::clone::Clone
andstd::marker::Copy
are used by the compiler to determine how values may be passed between functions.- Traits are used to provide operator overloading, rather than most languages where you have to create a method called
“+” somehow, in Rust you just implement the
std::ops::Add
trait for your type. All operator traits are found instd::ops
. - Relational operators are also implemented using traits such as
PartialEq
andEq
for “=” and “!=”,PartialOrd
andOrd
for “<” and “>”. These can be found instd::cmp
. - We mentioned
From
,Into
,TryFrom
andTryInto
instd::convert
as ways to convert between types, there’s alsostd::str::FromStr
andstd::string::ToString
:FromStr
is useful to implementMyType::from_str()
, but the presence of this type also allows you to usestr::parse()
to achieve the same result.ToString
is rarely necessary if you implementDisplay
, which you usually should, so thatto_string()
is available for any type that implements Display.
- Consider implementing
std::default::Default
for your types if there is a valid default value, some functions are bound to types that can be created by default. - If you are making some kind of container type, consider implementing
std::borrow::Borrow
orstd::borrow::BorrowMut
to access contained values.
Trait Bounds
Let’s assume we want to create some new container type, we will also assume you have some good reason for this, rather than using one of the existing ones. Now, the trade-off with generics, is to try and make your type as flexible as possible, but to provide as much expected/common behavior as you can. This is where trait bounds help, you can define the type itself to know nothing about it’s content, but implement core traits with bounds so that they apply if the compiler knows the contained type is able to support it.
Taking the first impl in the example below, impl<T: Clone> Clone for MyContainer<T>
can be read as “implement Clone
for MyContainer
, IFF the type T
implements Clone
”. So we can now see that we have implemented a bunch of core
traits, Clone
, Copy
, Debug
, Display
, From
, Borrow
, and BorrowMut
for our type, all dependent on T
supporting the same.
We also have two impl
blocks for MyContainer
itself, one is unconstrained and provides map()
and take()
, the
other provides a contains()
function, but only if T
supports “==” by implementing PartialEq
.
pub struct MyContainer<T> {
inner: T,
}
impl<T: Clone> Clone for MyContainer<T> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
}
}
}
impl<T: Copy> Copy for MyContainer<T> {}
impl<T: Debug> Debug for MyContainer<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
if f.alternate() {
write!(f, "MyContainer({:#?})", self.inner)
} else {
write!(f, "MyContainer({:?})", self.inner)
}
}
}
impl<T: Display> Display for MyContainer<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
if f.alternate() {
write!(f, "MyContainer({:#})", self.inner)
} else {
write!(f, "MyContainer({:})", self.inner)
}
}
}
impl<T: Default> Default for MyContainer<T> {
fn default() -> Self {
Self {
inner: Default::default(),
}
}
}
impl<T> From<T> for MyContainer<T> {
fn from(inner: T) -> Self {
Self { inner }
}
}
impl<T> Borrow<T> for MyContainer<T> {
fn borrow(&self) -> &T {
&self.inner
}
}
impl<T> BorrowMut<T> for MyContainer<T> {
fn borrow_mut(&mut self) -> &mut T {
&self.inner
}
}
impl<T: PartialEq> MyContainer<T> {
pub fn contains(&self, test: &Self) -> bool {
self.inner == test.inner
}
}
impl<T> MyContainer<T> {
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> MyContainer<U> {
MyContainer {
inner: f(self.inner),
}
}
pub fn take(self) -> T {
self.inner
}
}
Now, all of that may seem like a lot of boiler-plate code, and it was, the following provides Clone
, Copy
, Debug
,
and Default
using derive. But, to achieve this you have to constrain T
at this early stage to also be those things.
The composition of traits in Rust, while maybe adding some verbosity to library types is a trade-off for the level of
flexibility you get and the ability to compose-in functionality.
#[derive(Clone, Copy, Debug, Default)]
pub struct MyOtherContainer<T: Clone + Copy + Debug + Default> {
inner: T,
}
Any
Another interesting trait is Any
, whose description includes
Most types implement Any. However, any type which contains a non-‘static reference does not. What this means is that
you can test whether a value is of a given type using the of
method on TypeId
and the support for TypeId
equivalence. In the following example we see a value passed into is_of_type
and all we know is that the value implements
Any
and maybe Sized
. Interestingly we name this value _
as we are actually uninterested in the value but only the
type T
of this value. We then compare the type ID of this value to the type_id
passed to us.
fn is_of_type<T: ?Sized + Any>(_: &T, type_id: TypeId) -> bool {
TypeId::of::<T>() == type_id
}
println!(
"is string() {:?}",
is_of_type(&String::new(), TypeId::of::<String>())
);
println!(
"is debug() {:?}",
is_of_type(&String::new(), TypeId::of::<Display>())
);
This only works for non-trait types, so the first result is true
, however the second is false
as the value IS a
string, but only implements Debug.
The type_name
function in the same std::any
module is rather useful in debugging, so the following little addition
is worth keeping around.
fn type_name_of<T: ?Sized + Any>(_: &T) -> &'static str {
std::any::type_name::<T>()
}
Num Traits
One area where the language is lacking, in my opinion, is in not having traits for certain type classes. For example,
I can have an trait bound to the Add
trait to ensure I can use “+” on a generic, but I can’t have a trait bound to
an integer (regardless of size). There is a way to plug this gap, an excellent crate,
num-traits, that I have used but it seems like something pretty core.
Documentation Links
-
Traits in Rust by Example.
- Commonly derived
std::fmt::Debug
; Debug should format the output in a programmer-facing, debugging context.std::clone::Clone
; A common trait for the ability to explicitly duplicate an object.std::marker::Copy
; Types whose values can be duplicated simply by copying bits.std::cmp::PartialEq
; Trait for equality comparisons which are partial equivalence relations.std::cmp::Eq
; Trait for equality comparisons which are equivalence relations.std::cmp::PartialOrd
; Trait for values that can be compared for a sort-order.std::cmp::Ord
; Trait for types that form a total order.
- Operations
std::ops::Add
; The addition operator+
.std::ops::Index
; Used for indexing operations (container[index]
) in immutable contexts.
- Conversions
std::convert::From
; Used to do value-to-value conversions while consuming the input value. It is the reciprocal ofInto
.std::convert::Into
; A value-to-value conversion that consumes the input value. The opposite ofFrom
.std::str::FromStr
; Parse a value from a string.std::string::ToString
; A trait for converting a value to aString
.std::convert::TryFrom
; Simple and safe type conversions that may fail in a controlled way under some circumstances. It is the reciprocal ofTryInto
.std::convert::TryInto
; An attempted conversion that consumesself
, which may or may not be expensive.
- Container-like
std::borrow::Borrow
; A trait for borrowing data.std::borrow::BorrowMut
; A trait for mutably borrowing data.
std::any::Any
; A trait to emulate dynamic typing.std::default::Default
; A trait for giving a type a useful default value.std::fmt::Display
; Format trait for an empty format,{}
.