As for 2023 GADTs are not officially supported in Rust. However, I wonder how much of their power I can get using other utilities, such as traits and type-members.
To throw in some context and make the question less vague, here are some examples of what I consider "getting closer":
- Conditional invalidation of
enumconstructors - Enforcing trait constraints for type parameters only for selected
enumconstructors - Learning constraints upon destructing an
enumand learning its constructor
By "conditional invalidation" I mean that
- The typechecker will not consider a particular constructor if however defined conditions are met
- There will be no complaints when I skip such a constructor in pattern matching
So for example
enum AlmostGADT<T>
where /* magic? */
{
Flexi,
NotSoFlexi( /* something here maybe? */) : AlmostGADT< /* or here? */ >
}
fn main() {
let x: AlmostGADT<condition_true!()> = AlmostGADT::NotSoFlexi(...); // Works!
match x { AlmostGADT::Flexi => todo!() } // Patterns not exhaustive
let y: AlmostGADT<condition_false!()> = AlmostGADT::NotSoFlexi(...); // Type error
let y: AlmostGADT<condition_false!()> = AlmostGADT::Flexi(...); // Works !
match y {
AlmostGADT::Flexi => todo!(), // Works!
AlmostGADT::NotSoFlexi(_) => todo!() // Type error
}
match y { AlmostGADT::Flexi => todo!() } // Works!
}
The closest I can get is to carry a member with an impossible value, for example
enum AlmostGADT<T> {
Flexi,
NotSoFlexi(T)
}
enum Void {}
fn main() {
let full: AlmostGADT<()> = todo!();
let partial: AlmostGADT<Void> = todo!();
}
Here it is impossible to create a value of type Void, so NotSoFlexi(_) of type AlmostGADT<Void> cannot be constructed either. Unfortunately, the typechecker will not consider that, because it is still possible to create an expression of that type (eg. todo!()). It will therefore demand considering the impossible case of NotSoFlexi in pattern matching, which requires polluting the code with dodgy and dirty unreachable!().
How would you approach it in different setups?
I know the question is broad, but that's due to my limited knowledge of the Rust's type system. If you think it's too broad, please consider my AlmostGADT example, just don't feel limited to it.
I don't have any particular expectations on how exactly this would be tackled. I don't mind using weird trait patterns, nightly/experimental features, lifetime hacks, etc. I just wonder how much I can stretch the system to achieve something comparable to GADTs.
I will focus on these two:
the first one is much simpler than the second, but not very useful on its own, so we'll try to encode both. Let's start with a simple Haskell definition that we'll then implement in Rust:
Here, the
SomeFooconstructor of theGADTtype requires that the argument has aFooinstance, and in return allows us to usefoowhen pattern matching on it (indoThing). The Haskell runtime implements this by storing a reference to the type class dictionary (https://dl.acm.org/doi/10.1145/75277.75283) in the constructor, in other words via dynamic dispatch.Rust supports a form of dynamic dispatch via the
dyn Traitconstruct (https://doc.rust-lang.org/std/keyword.dyn.html), but it's severely limited (requires the trait to be "object safe"), so it's a non-solution if you're looking for reasonable parity.Rust version
The definitions will looks like this:
The main thing to note is the additional
CanFoo<T>field in theSomeFooconstructor. It's a simple newtype wrapper:Now we turn our attention to the two requirements in order.
Construct
We can simply define a smart constructor and leave the
CanFoostruct's field private, so the only way to construct it outside of the module is by instantiating with a type that implementsFoo:Match
So far this is not sufficient:
The key issue is that, unlike GHC, Rust does not support local assumptions under pattern matches, so it's not possible to introduce a trait bound into a local scope within a match arm. When resolving a trait bound, the compiler will always look at the current function's context, then the global context.
Since it's not possible to introduce local assumptions, we'll have to make do with global definitions.
The trick is to create a wrapper trait which I'll call
MaybeFoo:this is identical to
Foo, except that it now has an associated type calledImplemented. This will either beTrueorFalseWe first define a catch-all implementation for all types
Notice the
defaultkeyword on the two members. This requires#, and indeed makes fundamental use of specialisation semantics, which I'll come back to later. This function won't be callable externally because, again,CanFoo<T>can only be created for types that implementFoo.We provide another implementation for all types that implement
Foo:(coming from Haskell, this might look like a duplicate instance, but Rust actually is much more liberal with duplicate instance heads as it will happily consider the trait bounds when building the specialisation graph, so the specificity checker is not as syntactic).
Now we can rewrite
do_thing:Why does this work?
Coming from Haskell,
do_thing_2doing the right thing (instead of panicking) might be surprising. That's because in GHC, type class resolution is coupled with evidence generation, so when the compiler decides whether a type is an instance of a type class, it also finds the exact implementation and inserts a reference to it. That algorithm would pick up the default catch-all instance ina.maybe_foo(witness)(since that's the only one that matches in a fully generic context), and insert the panicking implementation into the call site.However, rust specialisation works differently. In the first pass, the type checker simply decides whether the type satisfies the trait bounds, but this pass is proof-irrelevant, i.e. the actual derivation doesn't matter. The concrete implementation will be picked out at the end during specialisation, where all type variables are monomorphised, and the most specific implementation candidate is selected. So when the typechecker sees
do_thing_2, it accepts the definition based on theImplemented = Falseinstance, but when the specialiser sees it again withT = usize, it will pick up theImplemented = Trueinstance. This means that the rust solution relies only on static dispatch. This technique would not work with existential types, but Rust doesn't support them anyway.