It’s possible in Rust to conditionally implement methods and traits based on the traits implemented by a type’s own type parameters. While this is used extensively in Rust’s standard library, it’s not necessarily obvious that this is possible.
In this article I’ll break down what the pattern is, give some examples of its use in the standard library to show why it’s valuable, and explain when you might want to use it.
What is a Conditional Impl?
A conditional impl is when a type implements methods or traits only if its own type parameters implement specific prerequisite traits. For methods, it looks like this:
struct MyStruct<T> {
inner: T,
}
impl<T> MyStruct<T> {
fn always_available_method(&self) {
// This method is always available, regardless of what
// traits `T` implements.
}
}
impl<T: Clone> MyStruct<T> {
fn only_when_clone_is_implemented(&self) -> T {
// This method is only available when `T` implements `Clone`.
}
}
A conditionally implemented method.
↺For those new to Rust, you may be surprised that having multiple impl blocks for a single type is valid. It is absolutely valid to do this!
In this example, the type MyStruct has a type parameter, T. The first
impl block, the kind you’re likely familiar with, is an unconditional
impl block, meaning that the methods it defines are always available on
MyStruct.
The second impl block is a conditional impl block, meaning that the methods
it defines are only available on MyStruct when T implements Clone.
You can also do something similar with conditional trait implementations. For example:
struct MyStruct<T> {
inner: T,
}
impl<T: Clone> Clone for MyStruct<T> {
fn clone(&self) -> Self {
Self { inner: self.inner.clone() }
}
}
A conditionally implemented trait.
↺In this example, MyStruct conditionally implements Clone only when T
implements Clone.
So, we now understand what a conditional impl is, but why would you want to do this?
Why are Conditional Impls Useful?
Put simply: conditional impls let you extend the powers afforded to a type in specific situations. To understand better, let’s look at some examples from the Rust standard library.
The Cell type
The Cell type is one of Rust’s container types that provides
“interior mutability.” Interior mutability is the property that you can still
mutate the value inside of the container when you only have an immutable
reference to the container itself. Put simply, the following is valid with
Cell:
use std::cell::Cell;
fn modify_cell(cell: &Cell<i32>) {
cell.set(10);
}
fn main() {
let cell = Cell::new(5);
// Notice, we only pass a `&` reference, not a `&mut` reference.
modify_cell(&cell);
println!("{}", cell.get());
// Prints: 10
}
Interior mutability with Cell.
As you can see, modify_cell takes only an immutable reference to Cell<i32>,
and yet it is able to mutate the value inside of the Cell with the method
Cell::set. That’s the power of interior mutability.
So why is this safe? Well, Cell does not permit direct reference access to
the inner value, so all access to that inner value is mediated through Cell’s
API. For all T, Cell offers three methods for accessing the inner value:
replace: Replaces the inner value with a new value, returning the old value.into_inner: Returns the inner value, consuming theCell.set: Sets the inner value to a new value, dropping the old value.
Rust’s guarantee is “aliasing XOR mutability,” meaning you can have either
multiple references to a single value or you can have that value be mutable,
but not both at the same time. With Cell, having an immutable reference to
the Cell does let you mutate its interior value, but only in ways that
either replace the value (either returning or dropping the old value), or
destroying the Cell entirely, at which point you lose the interior mutability.
Even better, Cell uses conditional impls to provide more API options
depending on the traits implements by the inner type!
If the inner type implements Copy, Cell::get returns a copy of the inner
value, leaving the Cell itself unchanged. This is safe for copyable types
because the copy is independent of the original value, so there’s no aliasing
introduced.
If the inner type implements Default, Cell::take returns the inner value,
replacing it with the default value for that inner type! Again, this is safe
because it doesn’t permit aliasing of the inner type, instead moving it out
and replacing it with a new default value.
Cell has even more conditional impls than these, including my personal
favorites: the cell “projection” methods! If you have a Cell that contains a
slice ([T]) or array ([T; N] where N is the size of the array), you get
methods that distribute the Cell over the slice or array elements.
For slices, you have Cell::as_slice_of_cells, which converts a &Cell<[T]>
into a &[Cell<T>].
For arrays, you have Cell::as_array_of_cells, which converts a &Cell<[T; N]>
into a &[Cell<T>; N].
These are valid because the Cell type is guaranteed to be the same size as the
value it contains since there’s no runtime bookkeeping involved in Cell’s API,
so it doesn’t actually store any extra metadata about the inner value.
With all of these APIs, Cell becomes more powerful depending on what is
stored in it.
The Clone derive macro
The conditional impl pattern also appears in some common derive macros,
including the Clone derive macro.
As you’re likely aware, Clone is a derive macro that generates an impl
block for the Clone trait for the type to which it is applied.
It looks like this:
#[derive(Clone)]
struct MyStruct {
inner: i32,
}
The Clone derive macro.
However, when the annotated type takes type parameters, the Clone derive
macro generates a conditional impl block, which is dependent on those type
parameters themselves implementing Clone.
This is a design choice in the Clone derive macro to make it maximally useful.
It could instead produce an error on types with type parameters, or it could
generate an unconditional Clone trait impl that would fail to compile when
type parameters do not implement Clone, but this would be overly restrictive
for a derive macro provided in the standard library.
With that said, this is what that conditional impl block (pretty much) looks like in practice:
For the following code:
#[derive(Clone)]
struct MyType<T> {
inner: T,
}
The Clone derive macro with type parameters.
The Clone derive macro generates the following conditional impl block:
impl<T: Clone> Clone for MyType<T> {
fn clone(&self) -> Self {
MyType { inner: self.inner.clone() }
}
}
The Clone derive macro’s conditional impl block.
While this is usually what you want when using the Clone derive macro, there
are contexts when this will not work as expected. In particular, there are
types which take type parameters which are not reflected in any stored value
in the type (meaning they only exist at compile time), and which therefore
should not be considered when implementing Clone.
The Clone derive macro is not able to identify what type parameters are
relevant vs. irrelevant, and so in these cases it will generate a conditional
impl block such that the type will not implement Clone even when all of its
relevant type parameters implement Clone.
I’ve encountered this myself in the writing of the omnibor
crate. OmniBOR is a specification for reproducible
identifiers for software artifacts, and the omnibor crate provides a type
called ArtifactId for that identifier.
This ArtifactId is a wrapper around a buffer which stores the result of
hashing the target artifact as a Git blob object. Since the OmniBOR
specification permits multiple hash algorithms for forward compatibility
(although only SHA-256 may be used today), the ArtifactId type takes a
type parameter which reflects the hash algorithm used. ArtifactId is more
accurately ArtifactId<H: HashAlgorithm>.
When writing this API, I’d initially used the Clone derive macro to implement
Clone for ArtifactId. However, this made the implementation of the Clone
trait conditional on whether H implemented Clone, which it does not.
The solution here was to not use the Clone derive macro, and instead to write
my own Clone implementation which properly ignored the H type parameter.
Conclusion
As you can see, conditional impls are a useful pattern for extending the powers of a type based on its type parameters. Their use in the standard library results in container types which gain new APIs based on the operations available on the types they contain, and in derive macros which are maximally flexible and useful for their users.
While this pattern will not suit every situation, I recommend keeping it in mind when designing your own types and APIs in the future.