Recursion and Recursive Thinking
Learning Objectives
- You understand why recursion is essential in functional programming
- You know what a recursive data type is
- You understand how the list algebraic data type work from first principles
- You know how to implement basic list processing functions in Gleam
Recursion
A key difference between functional and imperative programming languages is the concept of immutability. This usually forces us to rethink problems in a more mathematical, recursive fashion. For example, consider the simple problem of calculating the sum of integers from 1 to n.
In an imperative programming language, an idiomatic solution could look like this:
int sum = 0;
for (int i = 1; i <= n; ++i) {
sum += i;
}
However, if we attempt to translate this directly to Gleam, we quickly run into issues.
First of all, there is no for
or while
expression in Gleam, and if there
were, we still wouldn’t have a way to update the values of i
and sum
as we can’t mutate variables in Gleam.
An idiomatic implementation in Gleam could look something like this:
fn sum(n: Int) -> Int {
case n {
0 -> 0 // (1)
_ -> n + sum(n - 1) // (2)
}
}
In this code, instead of using a loop, we use the function in its definition, which is called recursion. Substituting loops with recursion is very common when translating imperative code to functional code.
Recursive (or inductive) thinking means “assuming that the recursive function works correctly” and using it by that assumption.
For example, on line (2)
, we use the sum
function recursively with the assumption that it returns the correct result for numbers smaller than n
.
Using that assumption, we know that the “correct thing to do” is to add our n
to the previous result.
The reason recursion works, is mathematical induction (or specifically induction over natural numbers in this case):
- We show the base case
(1)
is correct, i.e.sum(0)
evaluates to 0. - Inductive/recursive case
(2)
, where assumingsum(n - 1)
is correct, we produce the result ofn + sum(n - 1)
.
Think why calling
sum
with negative numbers “breaks the function”. One reason is that negative numbers are not natural numbers so the same induction principle doesn’t work for them. Gleam unfortunately doesn’t have a type for natural numbers…
The Linked List
pub type List {
Cons(Int, List)
Nil
}
Consider the algebraic data type defined above. Notice anything peculiar?
What you might have noticed is that this ADT is recursive,
meaning that at least one of its variants contains the type itself.
The above data type describes a linked list where each node
is either Cons(head, tail)
or Nil
.
fn is_empty(l: List) -> Bool {
case l {
Cons(_) -> False
Nil -> True
}
}
The above function uses the case
expression from the previous
chapter to determine if the given List
is empty.
We can implement the head
and tail
functions for our List
as follows:
Iterating Over a Recursive ADT
As one might expect, traversing a list (or any recursive ADT)
is also achieved with recursion. For instance, consider
the following function that calculates the sum of a given List
:
fn sum(l: List) -> Int {
case l {
Cons(h, t) -> h + sum(t)
Nil -> 0
}
}
The function traverses through the List
one element at a time,
by destructuring it into a head and a tail on each “iteration” until finally reaching the Nil
list.
Higher-Order Functions and Mapping Over Recursive Structures
As we know, functions are not restricted to taking regular values1 as arguments. They can also take functions as arguments. Higher-order functions allow programmers to, for example, “customize the behavior” of a function.
Perhaps one of the most common functions in functional style
code is the map
function, which applies a function to
each element in the collection returning the mapped version of the collection.
Below is an example of the map
function for our List
of integers.
fn map(f: fn(Int) -> Int, l: List) -> List {
case l {
Cons(h, t) -> Cons(f(h), map(f, t))
Nil -> Nil
}
}
This map
function takes in a function with type signature , but it could theoretically also take in or (or really a function of any return type).
While map “preserves” the structure (i.e. the order and shape) of the collection,
there exist also a plethora of useful functions
that “alter” the structure, for instance, the filter
function that takes in a predicate
and returns a collection that consists only of
elements in the original collection for which
the predicate returns True
.
Footnotes
Footnotes
-
Functions are also values, but here we are referring to values of base types such as integers or custom data types. ↩