Gentle Intro to Type-level Recursion in Rust: From Zero to HList Sculpting.
Getting the type signature right was 99% of the work in implementing
pluck
andsculpt
forHList
s in Frunk.Here’s what I’ve learnt along the way: what works, and what doesn’t work (and why).
As you may already know, Rust eschews the now-mainstream object-oriented model of programming (e.g. in Java, where behaviour for a type is added to the type/interface definition directly) in favour of a typeclass-like approach (e.g. in Haskell where you can ad-hoc add behaviour to a type separate from the type definition itself). Both approaches have their merits, and indeed, some languages, such as Scala, allow for a mix of both.
For those coming from the OOP school of programming, Rust’s system of adding behaviour to types might be daunting to come to grips with. At a glance, it might not be obvious how to get things done, especially when what you want to build goes beyond implementing Debug
or Eq
. If your abstraction has a certain degree of type-level recursiveness, it might be even harder to see the light at the end of the tunnel, and the lack of online material covering that sort of thing doesn’t help.
As a Scala guy with Haskell knowledge, I’m no stranger to typeclasses, but it took me a while and several failed attempts to figure out how to implement the following:
- Plucking out a value by type from an HList and getting back the remainder **
- Sculpting an HList into another shape, and getting back the remainder (in the case where we only want a smaller subset than the original) **
Of course, the type signature of the finished product can be intimidating !
In this post, I’ll briefly introduce Rust’s trait system and present my mental model for writing trait implementations that deal with type-level recursion. To do so, I will go through how pluck()
and sculpt()
were written in Frunk, as well as recount some of my failed approaches so you can learn from my mistakes.
Hopefully, by the end of it, you’ll be able to look at signatures like the one above and not go “WTF”, but rather, “FTW”.
“Type-level recursion”?
Ok, I may be butchering/making up a term, but by “type-level recursion”, I’m referring to recursive expansions/evaluations of types at compile-time, particularly for the purpose of proving that a certain typeclass instance exists at a function call site. This is distinct from runtime “value”-level recursion that occurs when you call a function that calls itself.
If you’re having trouble understanding the difference:
- Value-level recursion: If it can’t find an exit condition, your program is stuck running forever.
- Type-level recursion: If it can’t expand/find the exit-type, your compiler will either give up or never finish compiling; you won’t even have a program to run.
Outline
- Basic Gist of Rust typeclasses (traits)
- What the Frunk?
- Plucking from HLists
- Sculpting HLists
- Conclusion
Basic Gist of Rust typeclasses (traits)
In Rust, typeclass is spelt trait
, and although that word is somewhat ambiguous and overloaded with different meanings depending on context (e.g. in Scala), I’ll try to stick with it throughout this article. Subsequently, a typeclass instance is called an “implementation” (impl
in code) in Rust.
Here is a basic example of a simple trait and implementation for a type Circle
, taken from the official Rust book.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
For comparison, here is the Haskell equivalent
1 2 3 4 5 6 7 8 9 10 |
|
In both of these cases, what we see is
- There is a trait,
HasArea
, which describes behaviour (must implement anarea
function that takes as its first argument the implementing type) for types that want to belong to, or join it. - Next, we have a type,
Circle
, which has one purpose: hold data. - Then, we add
Circle
to theHasArea
trait by implementing an instance of the trait, fulfilling the contract by writing thearea
function.
The key difference between this approach and the OOP approach is that adding behaviour to an existing type does not require us to edit the original type declaration, nor does it require us to create a wrapper type. This allows us to add behaviour to types that do not belong to us (e.g. we don’t have access to its source)! This flexibility is a key advantage of the typeclass/trait approach. For a much more detailed comparison between OOP and typeclasses (traits), checkout this wiki entry on haskell.org.
Dependent trait implementations
Sometimes, you’ll want to write trait implementations for data types that have one or more type parameters. In these cases, your trait implementation will likely require that implementations of the trait exist for each of those type parameters.
For example
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
|
Making Cup
part of the Add
typeclass will allow us to call cup_a + cup_b
, which is kind of neat. One thing to take note of here is the Output
associated type. Pay attention to the fact that in our implementation of Add
for Cup
, the type of Output
is Cup<< A as Add<A> >::Output>
, which means that ultimately, the output of Add
ing of 2 Cup<A>
s will depend on what the Output
of Add<A>
is. The < A as Add<A> >
part can be read as “summon the Add<A>
implementation for the type A” (the compiler will do the actual lookup work here; if one doesn’t exist, your code will fail to compile), and the ::Output
following it means “retrieve the associated type, Output, from that implementation”. Let this sink in, because it’s important in order for us to move towards the concept of type-level recursion for traits.
Here is another way to write the same thing: using where clause syntax, so that the restriction goes at the end of the initial type signature in our implementation declaration. This is useful when you have more than 2 or 3 type parameters for your typeclass instance and you have a complex set of restraints. Using where
can help cut down on initial noise.
1 2 3 4 5 6 7 8 9 10 |
|
Here’s another, more general implementation of Add
for Cup
. It’s more general because it lets us add Cup
s of different content types, provided that there exists an Add<B>
implementation for whatever concrete type is bound to A
in any given Cup<A>
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
Mental model for type-level recursion
By this point, we have covered most of the basic understanding required to write more complex traits and implementations. To recap, they are:
- The differences between a trait, a type, and a trait implementation
- How to use bounds (
A: Add<A>
orwhere
clauses) when writing implementations for generic types - How to summon an implementation for a given type (
<A as Display>
) - How to write and use associated types (see
Output
in the above examples)
For a more thorough introduction to Rust’s trait system, by all means refer to the official Rust docs on traits.
Before going any further, I’d like to provide you with my mental model of how to think about recursion on the type level.
Recursion on the value level
You write a function that keeps calling itself until an exit condition is met, then returns a value.
Recursion on the type level
You write implementations of your trait for exit-types and work-to-be-done types. In order to prove an implementation of your trait exists for a concrete type at a function call site, the compiler will try to lookup and expand/expand types recursively until it can figure out a concrete implementation to use, or gives up with an error.
This may not make much sense at the moment, but hopefully it will soon.
What the Frunk?
Much of this post will make use of Frunk’s types (e.g. HCons
, HNil
), methods, macros (esp. for describing macro types via the Hlist!
type macro), and terminology.
It might be easier to follow along if you add Frunk to your project and play around with it. Frunk is published to Crates.io, so to add it your list of dependencies, simply put this in your Cargo.toml
:
1 2 |
|
Alternatively, take a look at the published Rustdocs.
Plucking from HLists
Given an HList, how can we write a function that allows us to pluck out a value by type (if the HList
does not contain this type, the compiler should let us know), and also return the rest of the HList
?
Suppose we call this function pluck()
, it should behave like so:
1 2 3 4 5 6 7 8 |
|
Implementation intuition
Our basic logic is fairly simple, given an HList
and a Target
type:
- If the head of the Hlist matches the
Target
type, return the head of the Hlist and the tail of the Hlist as the remainder in a pair (2 element tuple) - Otherwise,
- Store the head in
current_head
- Call
pluck()
again on the tail of the current Hlist with the sameTarget
type (i.e. recursively call 1. with the tail), and store the result in(tail_target, tail_remainder)
pair. - Return the target plucked from the tail, and prepend
current_head
to the remainder from the tail. Return both in a tuple like so:(tail_target, HCons { head: current_head, tail: tail_remainder} )
.
- Store the head in
First attempt
First, let’s assume we’ll be working with a trait; call it Plucker
. For now, let’s also assume that it will be parameterised with 1 type, the target type, and will also have an associated type, Remainder
. There isn’t really a hard and fast rule for when you should use type parameters vs associated types, but if you’re interested, you can take a look at this Stackoverflow question because Matthieu offers some great advice.
Personally, I always try use an associated type when I need to refer to the type from somewhere else (espescially recursively; more on this later). However, going with a type parameter is useful when you need to have different implementations of a trait for the same type in different circumstances. We saw this with Add
, where the right hand side was a type parameter, RHS
, allowing you to declare different Add
implementations for the same left-hand-side type and letting the compiler find the correct implementation to use at +
call sites depending on the type of thing being added with.
1 2 3 4 5 6 7 8 |
|
The “exit-type” implementation is for when the current head of the HList
contains the target type, so let’s jot that down that:
1 2 3 4 5 6 7 8 9 |
|
Now let’s implement the second piece; the non-trivial part where the target type is not in Head
, but in the Tail
of our HList. I’ll sometimes refer to this as the “work-to-be-done” type.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
Looks good right? But if you send that to the compiler, you’ll be hit with this message:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
What the Rust compiler is helpfully is telling us, is that it can’t distinguish between our two implementations, and if we look closely at the types, that is indeed true:
1 2 3 4 5 |
|
The Plucker<Target>
part is exactly the same, and sure, we’ve used Target
instead of Head
in the for HCons<..>
part in the first case, but simply using different type parameters isn’t enough to distinguish between the two.
Furthermore, note that you can’t use the lack of constraints (or where
clauses) to distinguish between implementations either. This is because the current lack of an implementation for a given type parameter doesn’t mean that it can’t be added later (see this Stackoverflow questions for more details).
Welp, back to the drawing board.
Second attempt
What we’ve learnt is that we need to have another type parameter in order to distinguish the exit-type and the work-to-be-done-type implementations, so let’s add one to Plucker
. Intuitively, we know that we want to have a way to distinguish between “the target is here in the HList” (exit) and “the target is over there in the HList” (recursion), so let’s call our type parameter Index
.
1 2 3 4 5 6 |
|
Then, let’s add a type to identify the index
for the exit-type implementation. We’ll use an empty enum
because we just want to have a type, and we don’t want it to be available at runtime (ensuring zero runtime cost for our type).
1 2 3 4 5 6 7 8 9 10 11 12 |
|
What about the work-to-be-done-type? Let’s imagine a scenario where we want to pluck a Target
of type MagicType
(let’s assume it’s declared as struct MagicType
, so a type with a single element in it), and we have the following HList
s to pluck()
from; what would the Index
be?
-
HNil
Trick question, there is no
Index
because our target ofMagicType
isn’t here. The compiler should fail to find an instance/implementation of our trait. -
hlist[ MagicType ]
(this is syntactic sugar forHCons<MagicType, HNil>
)Index
would clearly be ourHere
enum type -
hlist![ Foo, MagicType ]
(this is syntactic sugar forHCons<Foo, HCons<MagicType, HNil>>
)Index
can’t beHere
, but we know that in order for the compiler to be satisfied that it can reach our end-type in 1.Here
needs to be somewhere inside the type, but we can’t just use it as is, otherwise we’ll run into the same “conflicting implementation” error as before. So, let’s introduce new typeThere<A>
, that has one type parameter. In this case, theIndex
should resolve toThere<Here>
because the target type is in the head of the tail. -
hlist![ Foo, Foo, MagicType ]
Following from 3.
Index
would have to beThere<There<Here>>
-
hlist![ Foo, Foo, Foo, MagicType ]
What else could
Index
be butThere<There<There<Here>>>
That Looks alright, so let’s give it a go. Since the new type has a type parameter but no real data to associate it with, we’ll need use the PhantomData
trick (discussed in the last post).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
And that’s it, we’ve written implementations of Plucker
for HList
. The implementation for work-to-be-done type is type-recursive in its Index
type as well as its Remainder
associated type. The cool thing is that the compiler is in charge of figuring out what the concrete types should be at any given pluck()
call-site. In fact, you can see from this example in Frunk that the compiler will also happily infer the remainder for us too.
Type-level walkthrough
Let’s take a step back and work through what we’ve done.
We’ve declared an implementation of Plucker
for the trivial exit-type (Target
is in the head).
We’ve also declared an implementation for the work-to-be-done type (Target
is in the tail). This implementation, however, is dependent on its recursive types of Tail
and TailIndex
(hint: look at the where
clause). Intuitively speaking, an implementation of this type only exists if the current HList’s Tail
has either:
- An implementation for the exit-type; the
Target
type is in the head - Another work-to-be-done implementation of
Plucker
. This ultimately means that eventually there has to be a 1. in the tail somewhere.
Let’s try to walk through a mental model of how pluck()
gets associated to the proper implementation.
1 2 3 4 5 6 7 |
|
We’re ignoring the remainder and its type (Rust will figure it out if we use the underscore binding _
), because it isn’t relevant for what we’re about to do.
In the following steps, we’ll substitute concrete types into our implementations where possible; similar to how functions get bound to values during the substitution model of evaluation (normally used for evaluating runtime values). We’ll do this in steps, so it’s possible that in the earlier stages, we don’t quite know the concrete type yet, but we’ll go down the “stack”, and come back up and fill those types in, too, once we know them.
-
pluck()
onHlist![ &str, bool, f32, i32 ]
Since our
Target
type (f32
) is not in the head, it doesn’t match with theHere
case, so we will try to use the work-to-be-done case (Index
isThere<TailIndex>
) and fill in as many types as we can for now. Let’s replace some type parameters with their concrete types where possible.Concrete types:
Head
→&str
Tail
→Hlist![bool, f32, i32 ]
(remember, this is syntactic sugar forHCons<bool, HCons<f32, HCons<i32, HNil>
)Target
→f32
(this doesn’t change)Remainder
→ Don’t know yet, but we already know that the currentHead
will be in it, since it isn’t thetarget
type. And we know the tail ofRemainder
will be the remainder frompluck()
ingf32
from the tail, so we can reference it asHCons< &str, < Hlist![bool, f32, i32] as Plucker<f32, There<Here>> >::Remainder >
for now.TailIndex
→ Don’t know yet, but we’ll find out. Let’s call reference it asTailIndex1
for now.
-
pluck()
onHlist![bool, f32, i32]
(Tail
from 1.)Again,
f32
is not in the head of our type, so we know we aren’t going to be working with the exit-type typeclass implementation (e.g.,Index
is notHere
yet.)Concrete types:
Head
→bool
Tail
→Hlist![ f32, i32 ]
Target
→f32
(again, this doesn’t change)Remainder
→ Still don’t know yet, but we do know thatbool
will be in it since it isn’t our target. Similar to the previous step, we’ll tentatively call itHCons< bool, < Hlist![ f32, i32] as Plucker<f32, Here> >::Remainder >
TailIndex
→ Don’t know yet, but let’s rename itTailIndex2
for now and fill it in later.
-
pluck()
onHlist![ f32, i32 ]
(Tail
from 2.)The head has type
f32
and the target type isf32
, so we’ve arrived at the exit-type implementation.Concrete types:
Head
→f32
Tail
→Hlist![ i32 ]
Target
→f32
!Remainder
→ Since we’ve found our target, we know thatRemainder
must be the tail, and thusHlist![ i32 ]
, or its equivalentHCons< i32, HNil >
Index
→Here
!
Now, that we’ve finally resolved a concrete type for Index
, we can go backwards up the type-level stack and fill in our unknowns:
- Step 2:
TailIndex2
→Here
, which means thatIndex
isThere<Here>>
Remainder
→HList![ boo, i32 ]
- Step 1:
TailIndex1
→There<Here>
, which means thatIndex
isThere<There<Here>>>
Remainder
→HList![ &str, boo, i32 ]
The compiler is thus able to find a trait implementation to pluck()
a f32
out of an Hlist![ &str, bool, f32, i32 ]
that looks like this (with all the type parameters bound to a concrete type):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
Whew! That took a while, but I hope it helps illustrate how you can use a mental model similar to the substitution model of evaluation, but with types, in order to prove the existence of implementations for a given type.
By the way, by default, the compiler has a limit on how many levels of recursion/expansion this search for a typeclass instance goes. In my testing, I found this to be 64 levels and verified it to be so by looking at Rust’s source code. If you hit the limit, the compiler blow up, but will helpfully offer you a solution:
1 2 3 4 5 6 7 8 |
|
So, simply add #![recursion_limit="128"]
to your crate. If you hit the limit again, the compiler will tell you to double the limit again. Ad infinitum.
Sculpting HLists
Great ! Now that we’ve finished with Plucker
, let’s go one level deeper: making use of Plucker
to do something even more interesting; sculpting HList
s !
Here is the basic idea of what we want to be able to do:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
Implementation intuition
Let’s call our trait Sculptor
. We should be able to re-use our Plucker
trait, which which means we’ll work with Target
s and Index
s, but there’s more than one of each!
Intuitively, this is the kind of logic that we want:
Given TargetHList
(target HList) and SourceHList
(source HList), and assuming the types in the former is a subset (not necessarily in order though) of the latter:
- Pluck value with the head type of
TargetHList
fromSourceHList
:- Store the result in a
(plucked, remainder)
tuple
- Store the result in a
- Call
sculpt
onremainder
, passing the tail type of the currentTargetHList
as the newTargetHList
type.- Store the result in a
(sculpted_tail, sculpted_remainder)
tuple
- Store the result in a
- Return
(HCons { head: plucked, tail: sculpted_tail }, sculpted_remainder)
Note that in 1. we are making use of pluck()
, and there is a recursive call to sculpt()
in 2. Since there is a recursive call to sculpt()
, it means that we need an exit-type as well. Intuitively, we’ll pencil one in:
When the target HList is empty (HNil), return a tuple
(HNil, SourceHList)
First attempt
Given our logic, let’s assume we want 4 type parameters in our trait. Our trait is a bit more complicated than our Pluck
trait, but not by much. We make use of the same associated-type trick to hold the type of Remainder
to be returned as the 2nd element in our type that will be filled-in when we write instances of the trait.
1 2 3 4 5 6 |
|
The instance of Sculptor
for the exit-type should be simple, right?:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Ooops; that didn’t work; our type signature for the trait can’t be fulfilled when implementing our instance! We simply have too many type parameters in our trait, even for the exit-type implementation (try implementing for the recursion case…it’ll become more apparent)
Back to the drawing board.
Second attempt
Let’s collapse our target-related type parameters into a single Target
type parameter and our indices-related type parameters into a single Indices
type parameter in our Sculptor
trait declaration, and rely on the implementations to dictate (specialise) what types they should be (similar to how the Plucker
trait had no mention of There
or Here
).
1 2 3 4 5 6 |
|
The exit-type implementation will still be when we have HNil
as the target. Thinking it through further, in the case that we don’t have a HNil
as the target, it’s obvious that Source
can then be literally anything, so we’ll rename its type parameter Source
. Since our intention for Sculptor
is for Indices
to be an HList of Here
or There<A>
(one for each type in our Target
HList), the exit Indices
must therefore be a valid Hlist. Since we don’t need an index to find an empty target, let’s make Indices
HNil
for simplicity.
1 2 3 4 5 6 7 8 |
|
To figure out the type parameters needed for our work-to-be-done type, let’s work through the logic we laid out earlier.
At minimum, we know we’re writing an instance of Sculptor
for a Source of type HList, and our Target type is also an HList, so we’ll use SHead
and STail
to describe the “Source” HList (so HCons<SHead, STail>
), and THead
and TTail
to denote the “Target” HList (similarly, HCons<THead, TTail>
).
- Pluck value with the head type of
TargetHList
fromSourceHList
:
- Store the result in a
(plucked, remainder)
tuple
Since we need to pluck()
a THead
from our Source HList, we’ll need a type parameter for the first index, so let’s name it IndexHead
. In addition, in order to pluck()
, we need a Plucker
too, so this constraint is needed somewhere in our implementation declaration:
1
|
|
- Call
sculpt()
onremainder
, passing the tail type of the currentTargetHList
as the newTargetHList
type.
- Store the result in a
(sculpted_tail, sculpted_remainder)
tuple
Since we want to sculpt the remainder of calling pluck()
in step 1. into type TTail
(tail of TargetHList
), we’ll need to have an HList of indices for that purpose too, so let’s call it IndexTail
. Note that we don’t need a separate type parameter for the remainder from 1 because we can take advantage of the associated type on Plucker
.
1 2 3 4 5 |
|
- Return
(HCons { head: plucked, tail: sculpted_tail }, sculpted_remainder)
What will the Remainder
type be? It should be the remainder of sculpting the remainder from plucking the head type (THead
) out of the current source HList into TTail
(yeah…)
1
|
|
Putting all these types together with the logic, we have
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
As you can see, our implementations of Sculptor
is type-recursive in an interesting way, and there are quite a few dependencies that need to be worked out between all the type parameters and the Plucker
trait as well as the Sculptor
trait itself (it appears in the where
after all). Fortunately, the Rust compiler will do that for us (and if need be, tell you to raise the #![recursion_limit]
in your crate).
If you’re not convinced this works, please by all means check out the hlist
module in Frunk, in particular the Sculptor trait.
Conclusion
One last thing: the Plucker
and Sculptor
things aren’t just cute exercises; Plucker
has already paid dividends when modeling Sculptor
, and Sculptor
, well, it’s instrumental in letting us do cool stuff like convert between structs with different LabelledGeneric implementations (to an extent, anyways), and other, even cooler generic functions. We’ll talk more about this in another post.
If you do a search, you’ll find a number of articles on the Interwebs that introduce Rust’s trait system, but not many that go deep into how to use it when you need to do non-trivial type-level recursion in your trait implementations (though how often this need arises is … another topic altogether). I also find that people generally don’t talk about what they did wrong, so I wanted to share my failed approaches as well.
The goal of this post is to hopefully help others who are curious, or have a need to do something similar, as well as to leave notes for myself in case I ever need to revisit this in the future. The mental models for breaking down the problem, defining types, and building up to an implementation might not work for everyone, but they’ve helped me.
Personally, I think it’s awesome that a close-to-the-metal systems programming language like Rust has a powerful enough compiler and type-system to allow for these kinds of techniques. As you can see, we’ve managed to build powerful, reusable abstractions without doing anything unsafe, and we’ve exposed an API that requires just the bare minimum of type annotations; Rust infers the rest :) In any case, I hope this post was useful, and as usual, please chime in with questions and suggestions.
Credit
- The
Here
andThere<A>
design was largely gleaned from this code. I stand on the shoulders of giants :)
** It goes without saying that these operations need to be type-safe. That is, they are verified by the compiler without using any unsafe tricks that could blow up at runtime.