Hi there!
I recently asked this question on StackOverflow but haven't gotten any answer so far. Since this is something that can be discussed about (and SO is no place for this discussion), I thought asking here too would be a good idea! I'm really interested in your opinions on that, because I'm really not sure. Here is the full question copied:
I want to abstract over a variety of different list data structures. My abstraction should be fairly flexible. I want a "base trait" (let's call it List
) that represents the minimal interface required from all data structures. But there are optional capabilities the data structures could offer, which are independent from each other:
- Some data structures allow mutation, while others only provide a read-only interface. This is something which is fairly common for Rust traits: the pair
Trait
andTraitMut
(e.g.Index
andIndexMut
). - Some data structures provide a capability "foo".
- Some data structures provide a capability "bar".
Initially, this seems easy: provide the traits List
, ListMut
, FooList
and BarList
where the latter three have List
as super trait. Like this:
trait List {
fn num_elements(&self) -> usize;
}
trait ListMut: List {
fn clear(&mut self);
}
trait FooList: List {
fn num_foos(&self) -> usize;
}
trait BarList: List {
fn num_bars(&self) -> usize;
}
This works fine for the methods above. But the important part is that there are methods that require multiple capabilities. For example:
-
add_foo(&mut self)
: requires the mutability and the capability "foo"! -
add_foo_and_bar(&mut self)
: requires mutability and the capabilities "foo" and "bar". - ... and more: imagine there is a function for each combination of requirements.
Where should the methods with multiple requirements live?
One Trait per Combination of Capabilities
One way would be to additionally create a trait for each combination of optional requirements:
FooListMut
BarListMut
FooBarList
FooBarListMut
These traits would have appropriate super trait bounds and could house the methods with multiple requirements. There are two problems with this:
- The number of traits would grow exponentially with the number of optional capabilities. Yes, there would only need to be as many traits as methods, but it can still lead to a very chaotic API with loads of traits where most traits only contain one/a small number of methods.
- There is no way (I think) to force types that implement
ListMut
andFooList
to also implementFooListMut
. Thus, functions would probably need to add more bounds. This trait system would give implementors flexibility I might not want to give them.
where Self
bounds on methods
One could also add where Self: Trait
bounds to methods. For example:
trait FooList: List {
// ...
fn add_foo(&mut self)
where
Self: ListMut;
}
This also works, but has two important disadvantages:
- Implementors of
FooList
that don't implementListMut
would need to dummy implementadd_foo
(usually withunreachable!()
) because the Rust compiler still requires it. - It is not clear where to put the methods.
add_foo
could also live insideListMut
with the bound beingwhere Self: FooList
. This makes the trait API more confusing.
Define Capabilities via associated types/consts
In this solution, there would only be one trait. (Note that in the following code, dummy types are used. Ideally, this would be an associated const
instead of type
, but we cannot use consts in trait bounds yet, so dummy types it is.)
trait Bool {}
enum True {}
enum False {}
impl Bool for True {}
impl Bool for False {}
trait List {
type SupportsMut: Bool;
type SupportsFoo: Bool;
type SupportsBar: Bool;
fn add_foo(&mut self)
where
Self: List<SupportsMut = True, SupportsBar = True>;
// ...
}
This solves two problems: for one, we know that if Mut
and Foo
are supported, that we can use add_foo
(in contrast to the first solution, where a data structure could implement ListMut
and FooList
but not FooListMut
). Also, since all methods live in one trait, there it's not unclear anymore where a method should live.
But:
- Implementors still might need to add a bunch of
unreachable!()
implementations as by the last solution. - It is more noisy to bound for certain capabilities. One could add
trait ListMut: List<SupportsMut = True>
(the same for foo and bar) as trait alias (with blanket impl) to make this a bit better, though.
Something else?
The three solutions so far are what I can think of. One could combine them somehow, of course. Or maybe there is a completely different solution even?
Are there clear advantages of one solution over the other solutions? Have they important semantic differences? Is one of these considered more idiomatic by the community? Have there been previous discussions about this? Which one should be preferred?