We’ve seen that structural recursion involved a few common components. Our goal, for the moment, is to abstract these components away and provide a general structural recursion function.
mkString
We’ll start from mkString
, and refactor it step-by-step until we reach the desired result:
def mkString(
values: List
): String =
values match {
case Cons(head, tail) => head + " :: " + mkString(tail)
case Nil => "nil"
}
Our first step is going to be to rename mkString
. Let’s call it recurse
- it’s a pretty bad name, but now is the worst possible time to try and find a good one: we do not yet know what we’ll end up with and how it will be used. I find it useful to not worry overmuch about names until after I have a much better understanding of what it is I’m trying to name.
def recurse(
values: List
): String =
values match {
case Cons(head, tail) => head + " :: " + recurse(tail)
case Nil => "nil"
}
recurse
can be represented using the following diagram:
We start from a List
, follow its structure to turn into into a head and a tail (Cons
) or nothing (Nil
), apply recurse
to the tail, then some hard-coded magic to get our final result.
The hard-coded magic is what we want to get rid of.
The easiest thing to tackle is providing a solution to the smallest possible problem - known as the base case. This is currently hard-coded to "nil"
:
Instead of using a hard-coded string, let’s extract this into a constant:
val base = "nil"
And we can now rework recurse
to take base
as a parameter:
def recurse(
base : String,
values: List
): String =
values match {
case Cons(head, tail) => head + " :: " + recurse(base, tail)
case Nil => base
}
This is an improvement; at least some of the hard-coded magic is gone now:
We still have a little bit left, though:
This takes the head
, the solution to the tail
, and returns the complete solution to our problem. It can be represented as a simple function, traditionally known as step
:
def step(head: Int, tailResult: String) =
head + " :: " + tailResult
As before, now that we have a step
implementation for mkString
, we can remove the hard-coded one from recurse
and take it as a parameter:
def recurse(
base : String,
step : (Int, String) => String,
values: List
): String =
values match {
case Cons(head, tail) => step(head, recurse(base, step, tail))
case Nil => base
}
And with that, we appear to have gotten rid of all the hard-coded values:
There is, however, still a little bit of hard-coding going on, just not at the value level: the return type of recurse
.
It’s currently String
, but we know we’ll need more than that: product
returns an Int
, for example.
This is fortunately straightforward to generalise: we never actually use the fact that this return type is String
- just that base
and step
take matching types. This allows us to turn it into a type parameter:
def recurse[A](
base : A,
step : (Int, A) => A,
values: List
): A =
values match {
case Cons(head, tail) => step(head, recurse(base, step, tail))
case Nil => base
}
And now, we’ve gotten rid of everything that was specific to mkString
:
We can touch up the code a little bit before going further though. Short as it is, I find recurse
slightly unpleasant to read.
Specifically, I don’t like the recursive call to recurse
and the fact that it takes so many parameters. It makes it harder than necessary to understand what’s going on, where I really wish it could just say get the solution to the tail
, without added noise.
One thing I like to do in these circumstances is create internal helper functions. Here, we can take the entire pattern match and extract it into a helper that’s traditionally called loop
or go
:
def recurse[A](
base : A,
step : (Int, A) => A,
values: List
): A = {
def loop(state: List): A =
state match {
case Cons(head, tail) => step(head, loop(tail))
case Nil => base
}
loop(values)
}
This is admittedly more code than we had, but the structural recursion components are now more clearly visible in loop
. We can see:
Nil
), with a solution of base
.Cons
), composed of a smaller problem (tail
) and additional data (head
).loop
).step
.This gives us an updated diagram for recurse
:
Finally, we can see that the values
parameter of recurse
is just passed to loop
- the former is really just a proxy for the later.
Let’s make that more concrete by having recurse
return loop
instead:
def recurse[A](
base: A,
step: (Int, A) => A
): List => A = {
def loop(state: List): A =
state match {
case Cons(head, tail) => step(head, loop(tail))
case Nil => base
}
loop
}
One might argue that this isn’t much of an improvement - the only real change for callers is that recurse
now has two parameter lists, which, while not horrible, is certainly less straightforward than a single one.
But it is an improvement when you consider the most common use case: declaring combinators that rely on recurse
. Take the by now familiar mkString
:
def mkString(ints: List): String =
recurse(base, step)(ints)
We can conveniently refactor it to:
val mkString: List => String =
recurse(base, step)
Which I’d argue is more explicit: mkString
is just a specific application of recurse
.
Now that we’re all but done with recurse
and we have a better understanding of what it does, it’s time to give it a proper, functional programming name.
You might have already encountered it, it’s a very common combinator: fold
.
The name is a little bit poetic: it describes the way you fold the list onto itself to get your result.
def fold[A](
base: A,
step: (Int, A) => A
): List => A = {
def loop(state: List): A =
state match {
case Cons(head, tail) => step(head, loop(tail))
case Nil => base
}
loop
}
We’ve done all this work of generalising mkString
to be able to use it to write other combinators, it’d be a shame not to end this section by showing that product
can easily be written as a specific fold
:
val product: List => Int =
fold[Int](
base = 1,
step = (head, tailProduct) => head * tailProduct
)
We’ve seen that generalising structural recursion was a relatively straightforward task: simply make all key components parameters, and everything just sort of works out.
Well. Provided you don’t mind hard-coding your implementation to the structure of a specific type, that is. Our fold
works for lists, but that’s it. We should try to do something about that.