I very often run into situations like this.
The first problem:
Repeatedly defining generic parameters.
trait CoordinationStrategy<'a: 'b, 'b, T: Token, D: Direction> {
type Runner: RunStrategy<'a, 'b, T, D>;
fn run(runner: Self::Runner) -> <Self::Runner as RunStrategy<'a, 'b, T, D>>::Result;
}
trait RunStrategy<'a: 'b, 'b, T: Token, D: Direction> {
type Result: RunResult;
}
trait RunResult {}
...
struct CoordinatorA<'a: 'b, 'b, T: Token, D: Direction> {
...
}
impl<'a: 'b, 'b, T: Token, D: Direction> CoordinationStrategy<'a, 'b, T, D> for CoordinatorA<'a, 'b, T, D> {
type Runner = RunnerA<'a, 'b, T, D>;
...
}
struct RunnerA<'a: 'b, 'b, T: Token, D: Direction> {
...
}
impl<'a: 'b, 'b, T: Token, D: Direction> RunStrategy<'a, 'b, T, D> for RunnerA<'a, 'b, T, D> {
}
Code like this is probably 80% of what I spend my time on at this point, because it is so difficult to change (and because the remaining parts of Rust are mostly very concise). I want to use generics because without them I would have even more duplicate code, but in my opinion they are not developer friendly yet.
There are a few problems with this syntax, one is that we have to redefine the generic parameters we are using in each impl we want to use them. This doesn't scale well as we can see here. It may be fine for one or two type parameters, but at some point you are just repeating these same characters everywhere.
<'a: 'b, 'b, T: Token, D: Direction>
So whenever the parameters change, we need to go to all places using them and change them, which may be arbitrarily many.
(Search and replace sounds like the solution, and it helps, but you still have to step through each occurrence and decide if the change is actually correct depending on the context, because you may be using the same parameter signature with different meanings. It is not a good solution. If you are still not convinced, then why do we use functions at all? we could just copy/paste the code everywhere and search and replace changes.)
The second problem:
repeatedly referencing type parameters. A similar problem to the first one occurs when passing type parameters to a type or function:
RunStrategy<'a, 'b, T, D> for RunnerA<'a, 'b, T, D>
Again, having to change this twice for each implementation using these parameters takes a lot of time where your brain is basically stalling the whole time.
The third problem:
referencing associated types of traits.
<Self::Runner as RunnerStrategy<'a, 'b, T, D>>::Result
There is no way to shorten this, and it also scales with the depth of the reference, so you could imagine something like
<<Self::Runner as RunnerStrategy<'a, 'b, T, D>>::Result as RunResult<'a, 'b, T, D>>::OkType
Combined with the repeated generic parameters this becomes a major part of the code which is very difficult to maintain.
Ideas for solutions:
The first problem would be solved if you could have an identifier for a type parameter definition, kind of like a compile time type parameter structure:
(I didn't think about the syntax too much, open to suggestions)
params CoordRun = <'a: 'b, 'b, T: Token, D: Direction>;
now we could replace all generic parameter definitions with its name.
To solve the second problem, we could reference the type parameters of the current implementation with a keyword:
trait CoordinationStrategy<CoordRun> {
type Runner: RunStrategy<Self.params>;
fn run_ok(runner: Self::Runner) -> <<Self::Runner as RunnerStrategy<Self.params>>::Result as RunResult<Self.params>::OkType;
}
I.e. here we reference the type parameters to the trait with Self.params
.
In an implementation of the trait, where Self
would not refer to the trait but the type implementing it, this could be replaced with CoordinationStrategy.params
:
impl<CoordRun> CoordinationStrategy<CoordRun> for CoordinatorA<CoordRun> {
type Runner = RunnerA<CoordinationStrategy.params>; // CoordRun also works here
...
}
So here, CoordinatorA
could also take other parameters but we can still reference the parameters to CoordinationStrategy
independently. Also using CoordRun
in this case would be sufficient, because they are equal to the params of CoordinationStrategy
.
The point of having a name for referencing the current trait's parameters or the current type's parameters is to have one place less to change when the parameters change. In a trait where the trait's type parameters are used many times and you end up changing their name eventually, it is nice not having to change each reference to them too.
Earlier I mentioned that CoordinatorA
could also take other parameters next to CoordRun
. This raises the question, how do we combine type parameter structs with "atomic" type parameters? I imagine a type parameter struct could be used just as an atomic type parameter:
impl<CoordRun, T> CoordinationStrategy<CoordRun> for CoordinatorA<CoordRun, T> {
type Runner = RunnerA<CoordRun>;
...
}
Notice also that even though CoordRun
contains a parameter named T
, we can still use that name because we reference the T
in CoordRun
with CoordRun.T
.
So it is also possible to use parts of a type parameter struct in an implementation. Nesting type parameter structs also comes naturally:
params Time = <CoordRun.D, S: Step>; // equivalent to <D: Direction, S: Step>
params Exec = <CoordRun, Time, Log>;
Basically anything possible with atomic type parameters should be possible with type parameters referenced inside a type param struct, so also extending their trait bounds:
params Time = <CoordRun.D: StepDirection, S: Step>; // = <D: Direction + StepDirection, S: Step>
Also the evaluation of this syntax should basically simply expand the names to the actual inner parameters, so as far as I can tell there would not be anything new possible with this, it is just that the same thing can be achieved with less code complexity.
Moving on to the third problem, referencing associated types of traits. Here the problem is, we can't use an associated type of a trait directly, because a type could implement multiple traits which each use the same name for an associated type. But we can define a scoped alias referring to an associated type:
trait CoordinationStrategy<CoordRun> {
type Runner: RunStrategy<Self.params>;
alias Result = <Self::Runner as RunnerStrategy<Self.params>>::Result;
alias OkType = <Self::Result as RunResult<Self.params>::OkType;
fn run(runner: Self::Runner) -> Self::Result;
fn run_ok(runner: Self::Runner) -> Self::OkType;
...
}
Now it is very easy to use these associated types somewhere in our trait structure as often as we like and changing them all is very quick.
With all of this the code from the beginning becomes this:
params CoordRun = <'a: 'b, 'b, T: Token, D: Direction>;
trait CoordinationStrategy<CoordRun> {
type Runner: RunStrategy<Self.params>;
alias Result = <Self::Runner as RunStrategy<Self.params>>::Result;
fn run(runner: Self::Runner) -> Self::Result;
}
trait RunStrategy<CoordRun> {
type Result: RunResult;
}
trait RunResult {}
...
struct CoordinatorA<CoordRun> {
...
}
impl<CoordRun> CoordinationStrategy<CoordRun> for CoordinatorA<CoordRun> {
type Runner = RunnerA<CoordRun>;
...
}
struct RunnerA<CoordRun> {
...
}
impl<CoordRun> RunStrategy<CoordRun> for RunnerA<CoordRun> {
}
Which does not only scale better but is also more readable.
I think a system similar to this would make generics much easier to use in Rust. Hopefully this little write-up helps.