Generic recursive types
Implementing a domain in three ways
Last updated
Was this helpful?
Implementing a domain in three ways
Last updated
Was this helpful?
This post is the fifth in a series.
In the , we spent some time understanding folds for specific domain types.
In this post, we'll broaden our horizons and look at how to use generic recursive types.
Here's the contents of this series:
Part 1: Introduction to recursive types and catamorphisms
Part 2: Catamorphism examples
Part 3: Introducing folds
Part 4: Understanding folds
Part 5: Generic recursive types
Part 6: Trees in the real world
The answer is, of course, recursion!
Let's start with the most basic recursive type: the list.
I'm going to call my version LinkedList
, but it is basically the same as the list
type in F#.
So, how do you define a list in a recursive way?
Well, it's either empty, or it consists of an element plus another list. In other words we can define it as a choice type ("discriminated union") like this:
The Empty
case represents an empty list. The Cons
case has a tuple: the head element, and the tail, which is another list.
We can then define a particular LinkedList
value like this:
Using the native F# list type, the equivalent definition would be:
which is just [1; 2; 3]
cata
for LinkedListNote: We will be putting all the functions associated with LinkedList<'a>
in a module called LinkedList
. One nice thing about using generic types is that the type name does not clash with a similar module name!
As always, the signatures of the case handling functions are parallel to the signatures of the type constructors, with LinkedList
replaced by 'r
.
fold
for LinkedListThis foldWithEmpty
function is not quite the same as the standard List.fold
function, because it has an extra function parameter for the empty case (fEmpty
). However, if we eliminate that parameter and just return the accumulator we get this variant:
Let's test that fold
works by doing a small sum:
foldBack
for LinkedListFinally we can create a foldBack
function, using the "function accumulator" approach described in the previous post:
foldBack
to convert between list typesLet's demonstrate that now by creating some functions that convert from LinkedList
to the native list
type and back again.
To convert a LinkedList
to a native list
all we need to do is replace Cons
with ::
and Empty
with []
:
To convert the other way, we need to replace ::
with Cons
and []
with Empty
:
Simple! Let's test toList
:
and ofList
:
Both work as expected.
foldBack
to implement other functionsI said earlier that a catamorphism function (for linear lists, foldBack
) is the most basic function available for a recursive type, and in fact is the only function you need!
Let's see for ourselves by implementing some other common functions using foldBack
.
Here's map
defined in terms of foldBack
:
And here's a test:
Here's filter
defined in terms of foldBack
:
And here's a test:
Finally, here's rev
defined in terms of fold
:
And here's a test:
So, I hope you're convinced!
I mentioned earlier that there was an alternative and (sometimes) more efficient way to implement foldBack
without using generators or continuations.
As we have seen, foldBack
is reverse iteration, which means that it is the same as fold
applied to a reversed list!
So we could implement it like this:
It involves making an extra copy of the list, but on the other hand there is no longer a large set of pending continuations. It might be worth comparing the profile of the two versions in your environment if performance is an issue.
In the rest of this post, we'll look at the Gift
type and see if we can make it more generic.
As a reminder, here is the original design:
Three of the cases are recursive and two are non-recursive.
Now, the focus of this particular design was on modelling the domain, which is why there are so many separate cases.
But if we want to focus on reusability instead of domain modelling, then we should simplify the design to the essentials, and all these special cases now become a hindrance.
To make this ready for reuse, then, let's collapse all the non-recursive cases into one case, say GiftContents
, and all the recursive cases into another case, say GiftDecoration
, like this:
The main Gift
type has only two cases now: the non-recursive one and the recursive one.
Now that the type is simplified, we can "genericize" it by allowing any kind of contents and any kind of decoration.
And as before, we can mechanically create a cata
and fold
and foldBack
for it, using the standard process:
Let's convert the gift type to this generic Container type:
Now we need some helper methods to construct values while hiding the "real" cases of the generic type:
Finally we can create some test values:
totalCost
function using the Container typeThe "total cost" function can be written using fold
, since it doesn't need any inner data.
Unlike the earlier implementations, we only have two function parameters, fContents
and fDecoration
, so each of these will need some pattern matching to get at the "real" data.
Here's the code:
And the code works as expected:
description
function using the Container typeThe "description" function needs to be written using foldBack
, since it does need the inner data. As with the code above, we need some pattern matching to get at the "real" data for each case.
And again the code works as we want:
That all looks quite nice, doesn't it?
But I have to confess that I have been holding something back.
None of that code above was strictly necessary, because it turns out that there is yet another way to model a Gift
, without creating any new generic types at all!
The Gift
type is basically a linear sequence of decorations, with some content as the final step. We can just model this as a pair -- a Content
and a list of Decoration
. Or to make it a little friendlier, a record with two fields: one for the content and one for the decorations.
That's it! No other new types needed!
As before, let's create some helpers to construct values using this type:
With these helper functions, the way the values are constructed is identical to the previous version. This is why it is good to hide your raw constructors, folks!
totalCost
function using the record typeThe totalCost
function is even easier to write now.
description
function using the record typeSimilarly, the description
function is also easy to write.
If you are confused by this plethora of designs, I don't blame you!
But as it happens, the three different definitions are actually interchangable:
The original version
The generic container version
The record version
So which design is best? The answer is, as always, "it depends".
For modelling and documenting a domain, I like the first design with the five explicit cases. Being easy for other people to understand is more important to me than introducing abstraction for the sake of reusability.
If I wanted a reusable model that was applicable in many situations, I'd probably choose the second ("Container") design. It seems to me that this type does represent a commonly encountered situation, where the contents are one kind of thing and the wrappers are another kind of thing. This abstraction is therefore likely to get some use.
The final "pair" model is fine, but by separating the two components, we've over-abstracted the design for this scenario. In other situations, this design might be a great fit (e.g. the decorator pattern), but not here, in my opinion.
There is one further choice which gives you the best of all worlds.
As I noted above, all the designs are logically equivalent, which means there are "lossless" mappings between them. In that case, your "public" design can be the domain-oriented one, like the first one, but behind the scenes you can map it to a more efficient and reusable "private" type.
Even the F# list implementation itself does this. For example, some of the functions in the List
module, such foldBack
and sort
, convert the list into an array, do the operations, and then convert it back to a list again.
In this post we looked at some ways of modelling the Gift
as a generic type, and the pros and cons of each approach.
Here's a question: if you only have algebraic types, and you can only combine them as products (, ) or sums (), then how can you make a list type just by using these operations?
Following the rules in the , we can mechanically create a cata
function by replacing Empty
and Cons
with fEmpty
and fCons
:
We can also create a top-down iterative fold
function using the rules in the .
If we compare the signature with the we can see that they are equivalent, with 'State
replaced by 'r
and 'T list
replaced by LinkedList<'a>
:
Again, if we compare the signature with the , they are also equivalent, with 'State
replaced by 'r
and 'T list
replaced by LinkedList<'a>
:
In the we noted that catamorphisms could be used for converting between types of similar structure.
If this is not obvious, it might be helpful to read my post on . It explains how two types can be "equivalent", even though they appear to be completely different at first glance.
In the we'll look at real-world examples of using a generic recursive type.
The source code for this post is available at .