More Type-Level Shenanigans
last time we looked at how we can (mis?)use Rust’s
type system to perform arbitrary computations. There was one problem though:
Restricting types with where
clauses could cause the compiler to do an
exponential-time search in the type space, in the worst case DoSing the
compiler.
While I knew of the issue, I could not offer a solution at the time. However, redditor ZRM2 found a possible solution, which I will try to describe here.
Taking a step back, the problem is that rustc cannot know which concrete type is behind the existential type (=trait bound), so it has to search all possible types until it comes up with a solution. Unfortunately our misuse of generics means that the solution can be quite complex, and reaching that solution with a search is costly – as in waiting a few seconds or until the heat death of the universe, depending on the type whose existence rustc tries to prove.
But what if we could tell rustc to use a specific concrete type? Then it could do its thing without searching, compile times would evaporate and we’d be so much happier. I know, it’s just a what if scenario…but can it be possible?
Turns out it can.
To do this, we need to take a concrete type and implement our trait for it,
then use the type coerced to our trait bound instead of a generic we pull out
of thin air (where the compiler will search for it). A good pick for our
concrete type would be unit (spelled ()
in Rust lingo), because it’s
zero-sized and already available.
So let’s say we have a trait Foo
that we want to impl a Blah
operation on:
trait Blah {
type Output;
}
impl<X: Foo> Blah for X {
type Output = ..; // here we trigger exponential search
}
we can instead
trait PrivateBlah<F> {
type Output;
}
impl PrivateBlah<Foo> for () {
type Output = ..; // same as before, but rustc is happy
}
trait Blah {
type Output = ..;
}
impl<F: Foo> Blah for F {
type Output = <() as PrivateBlah<F>>::Output;
}
Now rustc does a plain trait lookup on ()
instead of searching the type space
for a generic type. Trait lookup is a linear racket (proportional to the number
of trait implementations the type has in scope), and the number of trait impls
is usually manageable.
As a sort of bonus, the original solution had a lot of unreachable!()
s
whenever methods were implemented. However, this entails some generated code
that need not be there. We can get rid of those if we encode our atoms as empty
enums by relying on the fact that an empty match {}
matches all possible
values of an empty enum. So if we have
enum Foo {}
we can write
impl<B: Bar> Add<B> for Foo {
type Output = ...;
fn add(self, _: B) -> Self::Output { match self {} }
}
The compiler will (as of Rust 1.8 nightly) generate a core::panic
(though it
probably could just generate an empty function) instead of a more costly
unwind
(which will just bloat our code with landing pads needlessly).
What stuff do you misuse the type system for? Discuss on rust-users and/or r/rust!