Fun and Functions with Gleam

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.

Run the program to see the output
  • Read more about the kind of type constructors.
Loading Exercise...
Loading Exercise...
Loading Exercise...
Loading Exercise...

A generic List

In the previous chapter, we implemented various methods for a List consisting of Ints. 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

head:List(𝑎)Option(𝑎).

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

map:(𝑎𝑏)List(𝑎)List(𝑏).

This means that the map function takes in arguments of type 𝑎𝑏 and List(𝑎) and returns a List(𝑎). 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 map:(𝑎𝑏)List(𝑎)List(𝑏) can be interpreted as taking in a function 𝑓:𝑎𝑏 and returning a function map𝑓:List(𝑎)List(𝑏).

The map function could be expressed using the following commutative diagram.

𝑓map𝑓𝑎𝑏List(𝑎)List(𝑏)

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:

  1. For every type 𝑎, mapping an identity function id𝑎:𝑎𝑎 equals1 the identity function for lists:

    mapid𝑎=idList(𝑎):List(𝑎)List(𝑎)
  2. For every type 𝑎,𝑏,𝑐 and functions 𝑓:𝑎𝑏,𝑔:𝑏𝑐, the composition of their mappings is the mapping of their composition:

    map(𝑔𝑓)=(map𝑔)(map𝑓)

These conditions can be summed up in the following commutative diagram.

𝑓𝑔id𝑎𝑔𝑓map𝑓map𝑔mapid𝑎=idList(𝑎)map(𝑔𝑓)=map𝑔map𝑓𝑎𝑏𝑐List(𝑎)List(𝑏)List(𝑐)

Observing the following Gleam code we can see that the condition 1 (mapping id𝑎) 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.

Loading Exercise...

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.

Loading Exercise...

Footnotes

Footnotes

  1. 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.”