Traits and Generics
Learning objectives
- You know how to create and implement traits in Rust
- You know how to use traits to implement generic functions and structs
- You know how to use trait objects
Traits
Traits are used to define shared behaviour for any Rust data type. For instance, the Display
trait is used to print values of any type to the console and the Clone
trait is used to create a copy of any type. Similarly, numerical operations like addition (+
) and multiplication (*
) are defined for types with traits Add
and Mul
.
Defining a trait
Traits are defined using the trait
keyword. The trait
keyword is followed by the name of the trait and a list of associated functions of the trait. The functions are defined in the same way as they are in a regular implementation block, but can be left without the function body, ending in a semicolon (;
) instead. — such trait functions are similar to abstract methods in object-oriented languages.
Consider for instance the definition of the Display
trait in std::fmt
, which enables printing for a type.
Like associated functions for types, trait functions can be methods by taking self
as the first parameter alongside other parameters. No need to worry about the '_
syntax in the Formatter
's inner type. It's a lifetime specifier which we'll discuss in the next part of the course.
Traits can also define default implementations for methods. For instance, we can create a trait Shout
that has a default implementation of a shout
method.
Unlike methods for types, the trait methods do not know the type of self
. In fact, it cannot know it, since the trait can be implemented on a number of different types. This causes some problems as printing or calling functions requires knowing that the type supports such behaviour (printing requires the Display
trait but the trait does not know whether self
implements it).
The trait does know its own functions though (duh). To circumvent the aforementioned problem and make the default implementations slightly more practical, we can have a trait method for getting a string that is left for the implementer. We can then use that method in the default implementations.
Trait bounds
Rust does not support inheritance for types, but it does support extending traits through what Rust calls trait bounds.
Trait bounds can provide functionality or behavior for the unknown self
type since they tell the implementing type that it must first implement another trait (the type is bound to the trait). Trait bounds for traits are defined by adding a colon and the binding trait after the trait name (trait TraitName: RequiredTraitName
).
With trait bounds, we have access to the behavior of the bound trait. And so, we can make the default implementation for shout
more practical by requiring the Display
trait for printing self
and also for converting self
to a string with to_string()
for further processing (Display
gives ToString
trait as a freebie).
This makes Shout
a subtrait of Display
since it extends the functionality of Display
. Likewise, Display
would be called a supertrait of Shout
.
Implementing traits
Implementing a trait for a type uses the same impl
keyword we used when implementing associated functions for types. To implement a trait for a type, we write impl Trait for Type { ... }
.
Let us first implement the Display
trait for the type FullName
so that we can print it with the println!
macro. To do this, we implement the fmt
function of the Display
trait with the help of the write
macro for writing our desired display string to the formatter struct given as a parameter.
Note that we used the derive
attribute previously to implement the traits like Debug
for debug printing. It generates the implementation code automatically from a struct's or enum's code, but not all traits can be automatically derived. The Display
trait is one such underivable trait, and so are any custom traits we define. Technically speaking though, any trait can be automatically derived by implementing derive
for them, which we'll discuss later when looking at macros and attributes.
Now that our FullName
implements Display
, we can have it implement the Shout
trait too.
We could override the default implementations of the shout
and shout_loudly
methods if we wanted to.
Implementing functions with the names shout
and shout_loudly
in an impl Type
block does not mean that the type now implements the Shout
trait. Implementing a trait is done only with the impl Trait for Type
syntax — the derive macros (like #[derive(Clone)]
use the syntax too, albeit under the hood.
This is in contrast to the duck typing paradigm: "If it walks like a duck and it quacks like a duck, then it must be a duck".
Trait visibility
Trait functions are always public so they don't need to be marked public explicitly. Traits themselves are importable items and as such need to explicitly set public using the pub
keyword. Further, traits and their implementations need to be in scope to be able to work with them.
A type may have a function with the same name as a trait function.
For example, if the type has a default
function and it also implements the Default
trait (which gives the associated function default
), writing FullName::default()
calls the function in the type's impl
block, not the one defined by the trait. To call the Default
trait implementation's default
function, we need a new special syntax. This syntax is known as fully qualified syntax and is quite common in Rust code.
To call the default
function provided by impl Default
, we have to call <FullName as Default>::default
.
The ambiguity of FullName::default
is a likely source of confusion. It is advisable to avoid giving names to associated functions which are already used by traits in the standard library, such as Default::default
or Iterator::next
.
As a side note, the Default
trait can be implemented by a derive attribute which gets the default
of all the constituent types. If we derive Default
for FullName
, the default
FullName
would have first and last names set to empty strings.
Generics
Generics are a powerful feature of many programming languages that allow code to be written in a way that is more flexible and reusable to avoid duplicated code. Generic data types provide a convenient way to write code that can work with a variety of types. They allow code to operate on abstract (as opposed to concrete) types. Abstract types are filled in by some other part of the code usually with type inference or by using turbofish syntax (::<T>
). For instance the abstract type T
in Vec<T>
can be inferred from a variable type when creating a new vector with Vec::new()
.
The abstract types can be thought of as variables similar to function parameters and are often labelled as single uppercase characters T
, U
and so forth, as we can see for example in the documentation for tuple, but they don't need to be (e.g. Result uses E
for its error type).
The notion of such generic data types that can have multiple forms is commonly known as polymorphism (the word is Greek where poly means many and morph means form or shape), or more precisely, parametric polymorphism.
Then, how does Rust handle these generic types? The generic types in Rust get monomorphized at compile time, which means that the compiler generates a separate concrete item for each unique usage of the generic item. This makes using generics as fast (during runtime) as using only concrete types, since the end result that gets compiled is concrete types in both cases. On the other hand, monomorphization may cause slowness during compile time and the generated concrete types will naturally take space in a compiled binary.
Let's consider an example with functions to duplicate values of various types by copying or cloning them.
In Rust, we cannot create two functions with the same name (in the same scope) so we need a unique name for each function. This code for duplicating two types is somewhat verbose, and more so if it needs to support more types in the future.
We have actually gone through one form of polymorphism in Rust already before as we could use enums to have a collection of multiple different types. However, if we resorted to an enum for polymorphism here, we could work with only one function, but that would be quite the hassle.
Generics to the rescue!
Generic types and functions
To create a generic function, we append the function name with angle brackets <>
, and insert generic type parameters within, like <T>
(not to be confused with parameter type). A generic type parameter is similar to a function parameter, but instead of taking a value argument, it takes a type argument. The generic type T
can then be used in the function declaration as well as inside the function scope like any other type.
With generics, we can have a clean and concise duplicate
function that works with any type.
However, the code doesn't compile due to, you probably already guessed it, the generic type T
does not implement Clone
and therefore there is no method clone
to call.
Examining the compiler error, we see that in order to use the clone
method, we need to add a trait bound to the generic type T
so that T
implements Clone
, similarly as we needed to add a Display
trait bound for the subtrait Shout
to print self
in a trait method.
We can add multiple trait bounds to a generic type parameter by separating them with a +
sign. For example, we can add a Debug
trait bound to the generic type T
in duplicate
so that we can print the value of t
in the println!
macro.
Where syntax 🔍
With multiple trait bounds, the <>
syntax can become quite verbose and difficult to read. Rust provides also where
syntax to add trait bounds to generic types, which is useful for longer trait bounds, and especially when the trait bound is too long to fit on a single line.
Impl trait
The duplicate
and tuplify
functions use the generic type T
in their parameter type as well as in their return type. Sometimes the function only needs to be generic over its input or its output. In such cases, we can use shorter syntax with by writing merely impl Trait
in place of a generic type.
To see this in action, let's first create a generic greet
function which says hello to a given value as long as the value is printable. In case we don't remember or know the type of the input, we can give our parameter a generic type with no bounds whatsoever.
Then, from the compiler error, we can see that the type T
does not implement the std::fmt::Display
trait. So we can either add that as a trait bound, or replace the generic type parameter with just impl std::fmt::Display
.
Except, we have a problem with greet("👋");
because the compiler doesn't know the size of the string literal "👋"
at compile time, and the size is implicitly required by any type parameter through the Sized
trait. We can fix this by following compiler instructions, which tells us to relax the constraint with the special syntax + ?Sized
that removes the implicit Sized
constraint in impl Trait
.
The two versions of the greet
function, (1) the one with the named generic T
and trait bound, and (2) the one with just the &impl Display
for type, behave in exactly the same way. The setback when using the impl Trait
syntax as opposed to having a generic type is that there is no way to refer to the type, for example to constrain two values to have the same generic type T
. The lack of a generic type parameter also means that we cannot use the turbofish syntax to specify the concrete type.
Returning impl Trait
is also subtly different to having a generic return type. As an example, with impl Clone
s in place of generic types in the duplicate
function from the earlier example would not allow assigning values from the function call to an integer tuple.
Running the above example reveals that the compiler identifies a mismatch between the return type (impl Clone, impl Clone)
and the expected type (i8, i8)
of the variable. We can also see that the compiler calls the types of the form impl Trait
as opaque types. The term opaque refers to the only thing known about such types is that they implement the trait and nothing else. Knowing only that the returned values implement the trait Clone
, the compiler has no way to identify that such values are of type i8
, causing the error. Thus, using a generic type is much more useful here — using opaque types obfuscates the relation of the parameter type and return type which leads to very limited usability.
Generics in structs and enums
The standard library contains a plethora of structs with generic data types, like the Option<T>
, Result<T, E>
and Vec<T>
. There are even some primitive types with generics, like &T
and &mut T
(any borrowed type and any mutably borrowed type). Generics in structs can be used with similar syntax as with generic functions.
As an example, we'll create a generic Optional
data type using an enum identical to that of Option<T>
. We write enum Optional<T>
to declare T
as a generic type parameter which can (and has to) be used inside the enum. Each concrete Optional
type, like Optional<i32>
or Optional<String>
, is unique, which means we can implement different methods for different Optional
s based on the type of T
.
Like the solution code shows, we can specify the concrete type of Optional
using the turbofish operator. Type inference wouldn't work here, because both five
functions are distinct, and type inference cannot select between distinct functions.
Note also that we can use the derive
attribute to automatically implement traits for structs with abstract types as we would normally. The deriving is done for each concrete use of the abstract type if applicable. For instance, with #[derive(Clone, Copy)]
, Optional<i32>
would get Copy
and Clone
but Optional<String>
would get only Clone
.
Unit structs are useful for parameterizing generic data types to write more abstract code with distinct functions with custom logic.
Let's create a dice rolling game mock-up with a generic Game<D>
data type and different dice D6
and D12
represented by unit structs.
The different games (Game<D6>
and Game<D12>
) as well as the roll
functions are distinct, which we can see in the following example with deliberate illegal code.
This dice rolling example follows the Rust idiom, that we should make the type system work for us to spot bugs at compile time.
We have assumed here that the game cannot change dice in the middle of it. If we wanted the dice to change mid-game we would have to use enums or dyn
trait objects.
Generic implementations
To implement methods and traits for a generic Optional<T>
, we need to parameterize the impl
block with a generic type.
Let's create an unwrap
method for all Optional
types, which given a Some
returns the wrapped value and otherwise panics. Implementation blocks can be parameterized with generics using impl<T>
syntax.
We can write multiple impl
blocks with different generic parameters and trait bounds to specify additional behavior required for calling the associated functions within.
For example, let's add a method called unwrap_or_default
, which unwraps the Optional<T>
value or returns the Default
value of T
. We can write trait bounds on a generic impl
the same way as with functions and structs, impl<T: Trait>
.
As we can see in the error, &i32
does not implement Default
, which means that the method unwrap_or_default
is not implemented for Optional<&i32>
. Most references do not implement Default
except for a couple of slice types, like &str
and &[T]
, which both return an empty slice from their implementation.
Generic methods
Like any other function, associated functions can be generic as well. The data types generic associated functions are implemented for are usually generic, but don't have to be.
For the sake of complexity, let's implement Option::map
for Optional
. The implementation is straight-forward: we just match and call f
with the wrapped value wrapping the result in Some
. However, the type signature will be harder to come up with, because we need map
to accept functions.
Functions and closures in Rust implement traits known as the Fn
traits. The traits Fn
, FnOnce
and FnMut
are described in more detail in The Rust Book chapter 13.1. We will not go into detail on the different Fn
traits but instead pick the most general one, Fn
, as the trait bound for F
. Fn
traits are written using special syntax to indicate the signature of the function. In our case we need F
to take in a value of T
and return a value of T
. This is achieved by the trait bound Fn(T) -> T
.
Because Optional<T>
can hold any type, there is no reason to restrict the mapping function to return a value of the same type. To allow for more generic mapping, we can add another type parameter U
to the method.
To make the return type correct, we need to also return the Some
and None
variants of to the resultant type Optional<U>
. Note that the Self
type is specified as Optional<T>
so Self<U>
would be Optional<T><U>
which is invalid.
Generic traits
A generic trait, like a generic function or a generic struct or enum, is parameterized by one or more generic type parameters. The syntax for creating generic traits is the same old angle brackets appended after the trait's name.
The Rust standard library provides two particularly useful and closely related traits, From
and Into
that we'll take a closer look into. While the as
operator is used to cast primitive types using built-in compiler logic, the From
and Into
traits provide a way to implicitly convert custom types into other types.
The traits are defined as follows.
Let's consider an example with two custom data types, Kilometers
and Miles
.
The floating point values contained within the structs carry different meanings, which conveys that arithmetic operations between Kilometers
and Miles
should not be computed without prior conversion.
To cast Kilometers
into Miles
, we can implement the generic From<Kilometers>
trait for Miles
.
The conversion now works from Kilometers
to Miles
not only by using from
but also through into
. We did not implement the into
method for Kilometers
in the code, so where does the implementation come from? The answer is: from the standard library via a blanket implementation. Blanket implementations are implementations of a trait on any type that satisfies given trait bounds.
The blanket implementation providing us with free Into
implementations in the standard library is defined in simplified form as in the following example.
Similarly, the standard library provides a blanket implementation of ToString
for any type that implements Display
, which is why we get to_string
method for all T: Display
.
Associated types
Associated types are a way of defining types that can be used in a trait's associated functions. They can be considered an advanced concepts, but are still fairly common.
For instance, numerical operators such as +
, -
, ==
, <
and >
are made available for types using traits that leverage associated types.
As an example, we'll implement addition for Vec
s which joins the vectors together as this is not featured in the standard library. The trait for addition is aptly named Add
.
The Add
trait, being the most complex trait we will see on the course, needs some introduction. Its path in the standard library is std::ops::Add
, which needs to be imported in order to be used. The trait is declared as follows.
The trait is parameterized by two types. A generic type used for the value in the right-hand side of the addition is set to Self
by default using default type parameters syntax: <Rhs = Self>
. The Add
trait also has an associated type, Output
, which is used as the return type of the addition. The level of abstraction makes addition very powerful in Rust as it allows for adding different types of values together producing an output value with a potentially different type too.
This will not work, however, because we have broken Rust's orphan rules, which state that we shall not implement a foreign trait for a foreign type. We'll get an error message warning us about this and a suggestion to define a trait or new type instead.
The Rust compiler needs to know which trait implementation (which there can be 0, 1, or any other number of) to use when calling a trait method on a type. This is known as coherence, and the rules that enforce it are known as the orphan rules.
Coherence requires that trait implementations inside a crate, between crates and between versions, can be unambiguously resolved by the compiler.
The orphan rules are as follows.
We are allowed to implement the trait Trait
for the type Type
if:
- The
Trait
is defined in our crate, or - The
Type
is defined in our crate, or - Some confusing nitty gritty detail holds
In order to get around this limitation, we follow the compiler's suggestion and define a new type.
A newtype is a tuple struct with exactly one field. Newtypes are used in many functional programming languages (including Rust) to create alternative trait implementations for types, or to work around the orphan rules.
Let's first create a newtype struct Wrapper<T>
which holds a Vec<T>
.
Then let's implement Add
for all Wrapper<T>
s returning another Wrapper<T>
from the addition operation.
A newtype does not inherit the methods and traits defined on the inner type, thus we always need to use .0
in order to operate on the Vec
inside the Wrapper
.
Hi! Please help us improve the course!
Please consider the following statements and questions regarding this part of the course. We use your answers for improving the course.
I can see how the assignments and materials fit in with what I am supposed to learn.
I find most of what I learned so far interesting.
I am certain that I can learn the taught skills and knowledge.
I find that I would benefit from having explicit deadlines.
I feel overwhelmed by the amount of work.
I try out the examples outlined in the materials.
I feel that the assignments are too difficult.
I feel that I've been systematic and organized in my studying.
How many hours (estimated with a precision of half an hour) did you spend reading the material and completing the assignments for this part? (use a dot as the decimal separator, e.g 8.5)
How would you improve the material or assignments?