Generics and Type Level Abstractions
Learning Objectives
- You understand what generics are and how to use them with Gleam.
- You know how a generic list data type can be defined and used.
- Additionally, you know what makes
map
a very special function…
Generic Algebraic Data Types
So far we’ve only taken a look at ADTs based on concrete types. However, one might notice that dealing with concrete types can become cumbersome.
Consider, for instance, the very common Option
type.
Implementing it with only concrete types would require defining
a new type for each type that needs to be wrapped in an Option
.
type OptionInt {
Some(Int)
None
}
type OptionFloat {
Some(Float)
None
}
// ...
Basic functionality for OptionInt
and OptionFloat
such as is_some
and map
would share the same code, so it makes sense to define these functions more generally.
Luckily, Gleam, as well as many other programming languages, provides a construction for declaring types and functions in a way that they accept any type of input.
This construction is generally referred to as generics and it allows the programmer to instruct the compiler to automatically generate code for different types and requires less typing from us programmers.
pub type Option(a) {
Some(a)
None
}
Take a look at the generic implementation of Option
in the listing above.
The type constructor Option
accepts a type as an argument and returns a type, meaning it’s like a function between types.
Using the generic Option
in type signature, requires passing
it the type parameter a
, which can be a concrete type such as Int
or another generic one.
fn to_option(n: Int) -> Option(Int) {
Some(n)
}
fn map(self: Option(a), f: fn(a) -> b) -> Option(b) {
case self {
Some(val) -> Some(f(val))
None -> None
}
}
As mentioned, a type can also be generic over multiple other types.
For example, a generic implementation of Result
would look
like this:
pub type Result(a, b) {
Ok(a)
Error(b)
}
Type Constructors Are Not Types
The following code won’t work because Option
(or any other type constructor) is not a type, this is unlike functions, which are values.
Only once fully applied with type arguments, does Option(a)
become a valid type.
- Read more about the kind of type constructors.
A generic List
In the previous chapter, we implemented various methods
for a List
consisting of Int
s.
Now, we are ready to take that to the next level with the help of generics.
pub type List(a) {
Cons(a, List(a))
Nil
}
With the generic definition of a List
, we’re
ready to tackle implementing generic methods for it.
In these methods, pay close attention to the type signature of the functions.
Starting off with the simple ones, below is a generic
implementation of head
that returns an Option(a)
.
// Import Option type and data constructors
// from the Gleam standard library
import gleam/option.{type Option, Some, None}
pub fn head(l: List(a)) -> Option(a) {
case l {
Cons(h, _) -> Some(h)
Nil -> None
}
}
We can write the type signature of head
as
In the previous chapter we had a map
function for integer lists.
Reminder: The
map
function had this function declaration:fn map(f: fn(Int) -> Int, l: List) -> List
The map
function can also be implemented for a generic List
.
In this case, the function applied doesn’t necessarily have
to output the same type it takes in, and therefore it’s type
signature is a -> b
. Based on this, the type of
the generic map function is
This means that the map
function takes in arguments of type and and returns a .
The implementation is identical to what we had in the previous chapter, except that we are actually working with generic types rather than integers:
pub fn map(f: fn(a) -> b, l: List(a)) -> List(b) {
case l {
Cons(h, t) -> Cons(f(h), map(f, t))
Nil -> Nil
}
}
Map and Functoriality
Notice that the type signature for can be interpreted as taking in a function and returning a function .
The map
function could be expressed using the following commutative diagram.
We can think of List
’s map
as a transformation of all functions from for any types , to structure-preserving mappings between lists.
What it means to preserve the structure of Lists, is that these two conditions, collectively known as functoriality, hold:
-
For every type , mapping an identity function equals1 the identity function for lists:
-
For every type and functions , the composition of their mappings is the mapping of their composition:
These conditions can be summed up in the following commutative diagram.
Observing the following Gleam code we can see that the condition 1 (mapping ) holds:
case l {
Cons(h, t) -> Cons(id(h), map(id, t))
Nil -> Nil
}
id(h)
becomes just h
and map(id, t)
can be shown to be t
using an induction proof (see below for an example of this for composition).
The code simplifies into:
case l {
Cons(h, t) -> Cons(h, t)
Nil -> Nil
}
Which clearly does nothing to l
and returns it instead.
Proof of Condition 2 (Composition)
Base Case for Induction (Nil
)
For , observe that their composition (or fn(x) { g(f(x)) }
in Gleam) when mapped, operates on an empty list map(fn(x) { g(f(x)) }, [])
producing an empty list, which is the value of the expression map(g, map(f, []))
.
Inductive Case (Cons(h, t)
)
By induction, we can assume that, for any list t
, map(fn(x) { g(f(x)) }, t)
= map(g, map(f, t))
(with = meaning equality of lists).
To complete the proof, we need to show that map(fn(x) { g(f(x)) }, Cons(h, t))
= map(g, map(f, (Cons(h, t))))
.
Unfolding the definition of map
and (simplifying case Cons(h, t)
accordingly) in the left hand side of the equality, map(fn(x) { g(f(x)) }, Cons(h, t))
turns into Cons(g(f(h)), map(fn(x) { g(f(x)) }, t))
.
Now using the induction hypothesis, we can convert the sub expression map(fn(x) { g(f(x)) }, t)
to map(g, map(f, t))
and therefore the left hand side is Cons(g(f(h)), map(g, map(f, t)))
.
Unfolding again the definition of map
in the right hand side of the equality, map(g, map(f, (Cons(h, t))))
turns into Cons(g(f(h)), map(g, map(f, t)))
.
Now both sides of the equation are the same expression, so by functionality, they must be the same list.
Folding
Now that we’ve learned about generics, we can introduce ourselves to
another important List
processing function.
Let’s do this by implementing a more generic version of the sum
function
introduced in the previous chapter. The function is called fold
.
Fold “folds” a list to a single value using a function of type fn(b, a) -> b
.
The value of type b
, which is eventually returned is commonly referred
to as the “accumulator”, because it is accumulated throughout the list.
Implementations of fold
require the user to specify a starting value for acc
.
fn fold(acc: b, f: fn(b, a) -> b, l: List(a)) -> b {
case l {
Cons(h, t) -> fold(f(acc, h), f, t)
Nil -> acc
}
}
// sum() is a special case of fold
fn sum(l: List(Int)) -> Int {
fold(0, fn(a, b) { a + b }, l)
}
In addition to the sum
function, fold can be used to
implement many of the common List
processing functions.
For example, let’s try implementing the filter
function
using fold
.
fn filter(pred: fn(a) -> Bool, l: List(a)) -> List(a) {
fold(Nil, fn(acc, e) {
case pred(e) {
True -> Cons(e, acc)
False -> acc
}
}, l)
}
import gleam/io
pub fn main() {
let list = Cons(1, Cons(2, Cons(3, Cons(4, Nil))))
io.debug(filter(fn(a) { a % 2 == 0 }, list))
// -> Cons(4, Cons(2, Nil))
}
Our attempt seems to work correctly, except for the
fact that it produces the result in reverse order.
This makes sense if we think about it,
the acc
starts as an empty list, and then if an element
matches the predicate, we add it to the front of the acc
therefore producing the result in reverse order.
For this very reason, there often exist two variants of the
fold
function, fold_left
and fold_right
(sometimes abbreviated to foldl
and foldr
respectively).
The fold
function we have implemented is a left-fold, because it starts
folding from the left side of the list.
A right-fold would look like this instead:
pub fn foldr(acc: b, f: fn(b, a) -> b, l: List(a)) -> b {
case l {
Cons(h, t) -> f(foldr(acc, f, t), h)
Nil -> acc
}
}
Now if we substitute the fold
in our filter
implementation with the foldr
we get the desired results.
Footnotes
Footnotes
-
What it means for two functions to be equal, is that they have the same domain and codomain and agree on every element, i.e. “each input maps to the same output.” ↩