Table of contents for this series of posts: “What is ReasonML?”
In this blog post, we look at two ReasonML data structures – lists and arrays:
The following table compares the two data structures.
Lists | Arrays | |
---|---|---|
Size | small–medium | small–large |
Resizable? | flexible | fixed |
Mutability | immutable | mutable |
Elem types | same | same |
Access via | destructuring | index |
Fastest | prepend/remove first | read/write elems |
In this blog post, you’ll see many type signatures such as this one:
let map: ('a => 'b, list('a)) => list('b);
Type signatures may still seem cryptic to you. But you’ll profit from learning how to read them. Let’s explore what the type signature tells us about map
– without us knowing what map
is or does (which will be explained later). Insights:
let map: («parameters») => «result»;
map
is a function.'a => 'b
map
’s first parameter is a function from type 'a
to type 'b
. The leading apostrophe indicates that 'a
and 'b
are type variables: They accept any types. But once a variable has accepted a particular type, it henceforth only accepts that type.list('a)
map
’s second parameter is a list whose elements are of type 'a
.list('b)
map
’s result is a list whose elements are of type 'b
.ReasonML currently has two standard modules for lists and arrays:
List
with functions such as:let map: ('a => 'b, list('a)) => list('b);
Array
with functions such as:let map: ('a => 'b, array('a)) => array('b);
Each function in those modules is also available in a labeled version, via the following two modules:
ListLabels
with functions such as:let map: (~f: 'a => 'b, list('a)) => list('b);
ArrayLabels
with functions such as:let map: (~f: 'a => 'b, array('a)) => array('b);
You can use a trick and “replace” the normal modules with their labeled versions by opening the module StdLabels
:
open StdLabels;
This module has the submodules Array
, Bytes
, List
and String
which are aliases for the global ArrayLabels
etc. Therefore, if you open it, you override the current implementations of Array
etc.
Lists in ReasonML are a typically functional data structure in that their type is defined recursively and that they are immutable. Let us explore what that means.
list
is a self-recursive parameterized variant. If you were to define it yourself, this is what it would look like:
type mylist('a) =
| Nil;
| Cons('a, mylist('a))
The names Nil
and Cons
(“construct”) are historical and originated with the Lisp programming language. You nest Cons
to create lists:
# let abc = Cons("a", Cons("b", Cons("c", Nil)));
let abc: mylist(string) = Cons("a", Cons("b", Cons("c", Nil)));
mylist
has the type parameter 'a
. It is passed on to Cons
and its recursive use of mylist
. That means two things: First, the elements of mylist
can have any type. Second, they must all have the same type. In the previous interaction with ReasonML, you can see that it automatically inferred that for abc
, 'a
is string
.
abc
is a singly-linked list. In memory, it may look like this:
The two parts of a cons pair are called:
ReasonML has special syntax for lists. The two constructors are:
[]
.[head, ...tail]
.Therefore, pattern matching works like this:
switch myList {
| [] => ···
| [head, ...tail] => ···
}
This is one way of recreating the list abc
from the previous section:
# let abc = ["a", ...["b", ...["c", ...[]]]];
let abc: list(string) = ["a", "b", "c"];
You can see that rtop
suggests the following, more compact, syntax, which is equivalent:
# let abc = ["a", "b", "c"];
let abc: list(string) = ["a", "b", "c"];
Let’s use pattern matching to compute the length of any list myList
:
let rec len = (myList: list('a)) =>
switch myList {
| [] => 0
| [_, ...tail] => 1 + len(tail)
};
We recurse over the two constructors:
The type parameter 'a
makes function len
generic. But we are never interested in the type of the elements, only in the structure of the list. The following interaction uses len
with various lists:
# len([]);
- : int = 0
# len(["a", "b"]);
- : int = 2
# len([1, 2, 3, 4]);
- : int = 4
ReasonML has no built-in support for printing complex data structures. But BuckleScript lets you use JavaScript’s console.log()
. It’s best to use this function like this:
Js.log(Array.of_list(myList));
Before we print the list myList
, we convert it to an array. But why? It leads to nicer output, because ReasonML lists are represented as nested 2-element arrays in JavaScript (an encoding of cons pairs).
The triple dot constructor is also called spread operator. This operator lets you prepend zero or more elements before an existing list:
# [...["a", "b"]];
- : list(string) = ["a", "b"]
# ["a", ...["b", "c"]];
- : list(string) = ["a", "b", "c"]
# ["a", "b", ...["c", "d"]];
- : list(string) = ["a", "b", "c", "d"]
Alas, this operator only works at the end. In JavaScript, you can use it anywhere, but in ReasonML, you can’t:
# [...["a", "b"], ...["c", "d"]];
Error: Syntax error
ReasonML has its own operator for concatenating lists:
# ["a", "b"] @ ["c", "d"];
- : list(string) = ["a", "b", "c", "d"]
Note that concatenating lists is comparatively slow, because you must prepend each element of the first operand to the second operand:
let rec append = (l1: list('a), l2: list('a)) =>
switch l1 {
| [] => l2
| [head, ...tail] => [head, ...append(tail, l2)]
};
(This implementation can be improved. We’ll see how in an upcoming blog post.)
This is how you use append
:
# append([1,2,3], [4,5]);
- : list(int) = [1, 2, 3, 4, 5]
This is, once again, type inference at work:
1
etc. to be int
.list(int)
:# [1,2,3];
- : list(int) = [1, 2, 3]
l1
and l2
had the same value for the type parameter 'a
.l1
and l2
to infer the type list(int)
of the result of append
.range()
creates a list of ints:
/**
* Compute a list of integers starting with `start`,
* up to and excluding `end_`.
*/
let rec range = (start: int, end_: int) =>
if (start >= end_) {
[];
} else {
[start, ...range(start + 1, end_)];
};
end
is a keyword in ReasonML and therefore not a legal variable name. That’s why the parameter end_
has the underscore in its name. Let’s try out range()
:
# range(0, 0);
- : list(int) = []
# range(0, 1);
- : list(int) = [0]
# range(0, 5);
- : list(int) = [0, 1, 2, 3, 4]
fill()
creates a list filled with the value ~element
:
/**
* Create a list of length `~length` where each
* element is `~element`.
*/
let rec fill = (~element: 'a, ~length: int) =>
if (length <= 0) {
[];
} else {
[element, ...fill(~element, ~length=length-1)];
};
ReasonML uses the type of ~element
to infer the type of the result:
# fill("x", 4);
- : list(string) = ["x", "x", "x", "x"]
# fill(0, 3);
- : list(int) = [0, 0, 0]
summarize()
computes the total of all the ints in a list:
/**
* Compute the sum of all the ints in the list `l`.
*/
let rec summarize = (l: list(int)) =>
switch l {
| [] => 0
| [head, ...tail] => head + summarize(tail)
};
summarize([]); /* 0 */
summarize([3]); /* 3 */
summarize([1, 2, 3]); /* 6 */
getElementAt()
retrieves a list element by index:
/**
* Get the list element at index `~index`.
* The head of a list has the index 0,
* the head of its tail the index 1, etc.
*/
let rec getElementAt = (~index: int, l: list('a)) =>
switch l {
| [] => None
| [head, ...tail] =>
if (index <= 0) {
Some(head);
} else {
getElementAt(~index=index-1, tail);
}
};
We can eliminate the if-then-else
expression if we use a when
clause and an additional case for switch
. The resulting flat structure is slightly easier to read:
let rec getElementAt = (~index: int, l: list('a)) =>
switch l {
| [] => None
| [head, ..._] when index <= 0 => Some(head)
| [head, ...tail] => getElementAt(~index=index-1, tail)
};
A few things are noteworthy in this code:
option
:
None
means we have failed.Some(x)
means we have succeeded, with the result x
.[]
, we have failed. This first case of switch
is triggered if either l
is empty or if we reach its end before ~index
is 0.The standard library has ListLabels.nth()
that works like getElementAt()
, but it throws an exception for illegal indices, it does not use option
.
Given that lists are immutable – how do you change them? To find an answer, consider that, so far, we have seen two kinds of algorithms:
len()
, summarize()
, etc.range()
, fill()
, etc.To change a list, we combine both approaches: We create a completely new list and include data from the existing list or derive data from it, as needed.
removeAll()
The following is a first example of a function that changes an existing list.
/**
* Remove all elements from the list `l` that are
* equal to `~value`.
*/
let rec removeAll = (~value: 'a, l: list('a)) =>
switch l {
| [] => []
| [head, ...tail] when head == value => removeAll(~value, tail)
| [head, ...tail] => [head, ...removeAll(~value, tail)]
};
The first case means that we are done. The third case makes an exact copy of the existing list. The second case removes elements that are equal to ~value
.
This is removeAll()
in action:
# removeAll(~value=9, [1,9,2,9,3]);
- : list(int) = [1, 2, 3]
replaceAll()
replaceAll()
replaces values:
/**
* Inside the list `l`, remove all occurrences of the value `~value`
* with the value `~with_`.
*/
let rec replaceAll = (~value: 'a, ~with_: 'a, l: list('a)) =>
switch l {
| [] => []
| [head, ...tail] when head == value =>
[with_, ...replaceAll(~value, ~with_, tail)]
| [head, ...tail] =>
[head, ...replaceAll(~value, ~with_, tail)]
};
The first case means we are finished. The third case makes an exact copy. The second case makes a replacement.
We can make replaceAll()
more compact via an internal helper function replaceOne()
:
let rec replaceAll = (~value: 'a, ~with_: 'a, l: list('a)) => {
let replaceOne = (x) => if (x == value) with_ else x;
switch l {
| [] => []
| [head, ...tail] =>
[replaceOne(head), ...replaceAll(~value, ~with_, tail)]
};
};
This is replaceAll()
in action:
# replaceAll(~value=1, ~with_=11, [1, 2, 1, 3]);
- : list(int) = [11, 2, 11, 3]
drop()
drop()
removes list elements:
/**
* Remove the first `~count` elements of `theList`.
*/
let rec drop = (~count, theList: list('a)) =>
switch theList {
| [] => []
| l when count <= 0 => l
| [_, ...tail] => drop(~count=count-1, tail)
};
Let’s use drop()
:
# drop(~count=0, ["a", "b", "c", "d"]);
- : list(string) = ["a", "b", "c", "d"]
# drop(~count=2, ["a", "b", "c", "d"]);
- : list(string) = ["c", "d"]
# drop(~count=2, ["a", "b"]);
- : list(string) = []
# drop(~count=2, ["a"]);
- : list(string) = []
# drop(~count=2, []);
- : list('a) = []
For the last result of drop()
, ReasonML can’t infer the element type and leaves the type parameter 'a
unbound.
ReasonML’s standard library is still in flux. Therefore, We are only looking at a few highlights here. You can read up on the rest (as it currently exists) via the documentation for ListLabels
.
ListLabels.map()
Signature:
let map: (~f: 'a => 'b, list('a)) => list('b);
map()
takes a list with elements of type 'a
, applies the function ~f
to each element and returns the results in another list.
# ListLabels.map(~f=x => int_of_string(x), ["7", "15", "6"]);
- : list(int) = [7, 15, 6]
This function is a classic tool for processing lists of data.
mapi()
is a version of map()
that passes both the current element and the element’s index to the callback ~f
. We can use mapi()
to non-destructively update lists:
/**
* Create a copy of `theList` whose element at index `~index`
* is `~value`.
*/
let setElementAt = (~index: int, ~value: 'a, theList: list('a)) =>
ListLabels.mapi(
~f=(i,x) => if (i==index) value else x,
theList
);
The parameter ~f
passes on all elements unchanged, except for the element at index ~index
.
This is setElementAt()
in use:
# setElementAt(~index=1, ~value="|", ["a", "b", "c"]);
- : list(string) = ["a", "|", "c"]
ListLabels.filter()
This is the function’s signature:
let filter: (~f: 'a => bool, list('a)) => list('a);
filter()
applies the function ~f
to each element of its positional parameter. If it returns true
, the element is included in the result. If it returns false
, it isn’t. It is used as follows.
# ListLabels.filter(~f=x=>x>5, [8, 4, 9, 7, 2]);
- : list(int) = [8, 9, 7]
ListLabels.for_all()
Signature:
let for_all: (~f: 'a => bool, list('a)) => bool;
for_all()
returns true
if ~f
returns true for each element of the list. For example:
# ListLabels.for_all(~f=x=>x>3, [4,5,6]);
- : bool = true
# ListLabels.for_all(~f=x=>x>3, [3,4,5,6]);
- : bool = false
for_all
stops processing as soon as ~f
returns false
. Then the result is guaranteed to be false
. for_all
is named after the mathematical operator ∀.
ListLabels.exists()
is related to for_all()
: It returns true
if its callbacks returns true
for at least one of the elements of its list. exists
is named after the mathematical operator ∃.
ListLabels.flatten()
Signature:
let flatten: list(list('a)) => list('a);
flatten()
converts a list of lists l
, to a list, by concatenating the elements of l
. That is, the following three expressions are equivalent:
flatten([l1, l2, l3])
ListLabels.append(l1, ListLabels.append(l2, l3))
l1 @ l2 @ l3
This is how flatten()
is used:
# ListLabels.flatten([[1,2], [], [3,4,5]]);
- : list(int) = [1, 2, 3, 4, 5]
If you are wondering about arbitrarily nested lists, recall that, in ReasonML, all elements must have the same type. Therefore, if one list element is itself a list then all elements must be lists:
# ListLabels.flatten([[1,[2]], [], [3]]);
Error: This expression has type list('a)
but an expression was expected of type int
# ListLabels.flatten([[[1],[2]], [], [[3]]]);
- : list(list(int)) = [[1], [2], [3]]
Let’s continue by looking at use cases for flatten()
.
flatten()
lets you filter and map at the same time. As an example, consider the trying to extract the first elements of several lists stored in a list. You could:
ListLabels.filter()
.ListLabels.map()
.Or you could use flatten
and do both at the same time:
module L = ListLabels;
let listFromHead = (l: list('a)) =>
switch (l) {
| [] => []
| [head, ..._] => [head]
};
let heads = (l: list(list('a))) =>
L.flatten(L.map(~f=listFromHead, l));
First, we map each non-empty list to a list with its head and each empty list to an empty list. Then we flatten the result. This looks as follows:
# let l0 = [[1, 2], [], [3,4,5]];
let l0: list(list(int)) = [[1, 2], [], [3, 4, 5]];
# L.map(~f=listFromHead, l0);
- : list(list(int)) = [[1], [], [3]]
# let l1 = L.map(~f=listFromHead, l0);
let l1: list(list(int)) = [[1], [], [3]];
# L.flatten(l1);
- : list(int) = [1, 3]
These steps are equivalent to:
# heads([[1, 2], [], [3,4,5]]);
- : list(int) = [1, 3]
It is instructive to compare listFromHead
to a function getHead
that uses option
to express failure:
let getHead = (l: list('a)) =>
switch (l) {
| [] => None
| [head, ..._] => Some(head)
};
That is, None
expresses “l
does not have a head”:
# getHead(["a", "b"]);
- : option(string) = Some("a")
# getHead([1, 2, 3]);
- : option(int) = Some(1)
# getHead([]);
- : option('a) = None
With listFromHead
, we used the empty list instead of None
and a one-element list instead of Some
.
Let’s assume that we have created a list of people and their children:
type person = Person(string, list(string));
let persons = [
Person("Daisy", []),
Person("Della", ["Huey", "Dewey", "Louie"]),
Person("Marcus", ["Minnie"])
];
If we want to collect the children in a list, ListLabels.map()
almost gives us what we want, but not quite:
# ListLabels.map(~f=(Person(_, ch)) => ch, persons);
- : list(list(string)) = [[], ["Huey", "Dewey", "Louie"], ["Minnie"]]
Alas, this is a list of lists of strings, not a list of strings. We can fix this by applying ListLabels.flatten()
to this nested list:
let collectChildren = (persons: list(person)) =>
ListLabels.flatten(
ListLabels.map(
~f=(Person(_, children)) => children,
persons));
collectChildren(persons);
/* ["Huey", "Dewey", "Louie", "Minnie"] */
Now we get the desired result:
# collectChildren(persons);
- : list(string) = ["Huey", "Dewey", "Louie", "Minnie"]
Sometimes, you create lists where some elements are added or omitted depending on a condition (cond
in the following example):
let func = (cond: bool) => ListLabels.flatten([
if (cond) ["a"] else [],
[
"b",
"c"
]
]);
This is how func()
is used:
# func(true);
- : list(string) = ["a", "b", "c"]
# func(false);
- : list(string) = ["b", "c"]
ListLabels.fold_left()
Signature:
let fold_left: (~f: ('a, 'b) => 'a, ~init: 'a, list('b)) => 'a;
fold_left()
works as follows:
list('b)
(last parameter)'a
To compute the result, fold_left()
depends on its parameter, the function ~f
. It calls ~f
for each element elem
of its input list:
let nextIntermediateResult = f(intermediateResult, elem);
intermediateResult
is what has already been computed. The first intermediate result is ~init
. The last nextIntermediateResult
is the result of fold_left()
.
Let’s look at a concrete example.
fold_left()
by example: summarize()
We have already encountered the function summarize()
which computes the total of a list of ints. Let’s implement that function via fold_left()
:
let rec summarize = (l: list(int)) =>
ListLabels.fold_left(~f=(r, elem) => r + elem, ~init=0, l);
To understand how summarize()
works, consider the following expression:
summarize([1,2,3]) /* 6 */
To compute the result 6
, the following steps are taken:
/* Parameter */
let f = (r, elem) => r + elem;
let init = 0;
/* Steps */
let result0 = f(init, 1); /* 1 */
let result1 = f(result0, 2); /* 3 */
let result2 = f(result1, 3); /* 6 */
result2
is the result of fold_left()
.
fold_left()
Another way of looking at fold_left()
is that takes a binary operator ~f
and turns it into an n-ary operator for lists. An example in mathematics for going from binary to n-ary is the binary operator +
also existing as an n-ary version (the operator Σ). summarize()
went from +
to Σ. It could also be written like this:
# ListLabels.fold_left(~f=(+), ~init=0, [1, 2, 3]);
- : int = 6
I find fold_left
easiest to understand if it works in this mode – with an ~f
that is commutative (order of parameters doesn’t matter). But there is much you can do with it – read on for an example.
The function contains()
uses it to find a value in a list:
let contains = (~value: 'a, theList: list('a)) => {
let f = (found, elem) => found || elem == value;
fold_left(~f, ~init=false, theList);
};
iteri()
Signature:
let iteri: (~f: (int, 'a) => unit, list('a)) => unit;
iteri()
call ~f
for every element of its list. The arguments are the index of the element and the element. It returns unit
, which means that anything useful that iteri
does, it does via side effects.
The following function uses iteri()
to fill an array. It does so as a side effect, by writing to an array arr
:
let arrayFromList = (~init: 'a, l: list('a)) => {
let arr = ArrayLabels.make(ListLabels.length(l), init);
ListLabels.iteri(~f=(i, x) => arr[i]=x, l);
arr;
};
~init
is a required parameter, because make()
needs it (why is explained later).
arrayFromList()
in action:
# arrayFromList(~init=0, [1,2,3]);
- : array(int) = [|1, 2, 3|]
Arrays are much like lists: all of their elements have the same type and they are accessed by position. But they are also different:
The following subsections explain three common ways of creating arrays.
# [| "a", "b", "c" |];
- : array(string) = [|"a", "b", "c"|]
ArrayLabels.make()
Signature:
let make: (int, 'a) => array('a);
The first parameter specifies the length of the result. The second parameter specifies the value it is to be filled with. Why is the second parameter mandatory? The result of make()
must only contain values of type 'a
. ReasonML has no null
, so you must pick a member of type 'a
, manually.
This is how make()
works:
# ArrayLabels.make(3, "x");
- : array(string) = [|"x", "x", "x"|]
# ArrayLabels.make(3, true);
- : array(bool) = [|true, true, true|]
ArrayLabels.init()
Signature:
let init: (int, ~f: int => 'a) => array('a);
The first parameter specifies the length of the result. The function ~f
maps an index to an initial value at that index. For example:
# ArrayLabels.init(~f=i=>i, 3);
- : array(int) = [|0, 1, 2|]
# ArrayLabels.init(~f=i=>"abc".[i], 3);
- : array(char) = [|'a', 'b', 'c'|]
ListLabels.length()
returns the length of an array:
# ArrayLabels.length([| "a", "b", "c" |]);
- : int = 3
This is how you read and write array elements:
# let arr = [| "a", "b", "c" |];
let arr: array(string) = [|"a", "b", "c"|];
# arr[1]; /* read */
- : string = "b"
# arr[1] = "x"; /* write */
- : unit = ()
# arr;
- : array(string) = [|"a", "x", "c"|]
Pattern-matching arrays is similar to matching tuples, not to matching lists. Let’s start with tuples and lists (we can ignore the exhaustiveness warnings, because we are working with fixed data):
# let (a, b) = (1, 2);
let a: int = 1;
let b: int = 2;
# let [a, ...b] = [1, 2, 3];
Warning: this pattern-matching is not exhaustive.
let a: int = 1;
let b: list(int) = [2, 3];
We’ll destructure an array next:
# let [| a, b |] = [| 1, 2 |];
Warning: this pattern-matching is not exhaustive.
let a: int = 1;
let b: int = 2;
Similar to tuples, the pattern must have the same length as the data (that’s what the exception is about):
# let [| a, b |] = [| 1, 2, 3 |];
Warning: this pattern-matching is not exhaustive.
Exception: Match_failure
This is how you convert between lists and arrays:
ArrayLabels
):let to_list: array('a) => list('a);
ArrayLabels
):let of_list: list('a) => array('a);
Sometimes you have data in an array that would be easier to process in a list. Then you can convert it to a list (and convert it back to an array afterwards, should that be needed).
The standard library is still in flux. Therefore, I’ll only demonstrate a few highlights for now.
ArrayLabels.map()
map()
for arrays works similar to the same function for lists:
# ArrayLabels.map(s => s ++ "x", [| "a", "b" |]);
- : array(string) = [|"ax", "bx"|]
ArrayLabels.fold_left()
fold_left()
is also similar to its list version:
let maxOfArray = (arr) =>
ArrayLabels.fold_left(~f=max, ~init=min_int, arr);
This is how maxOfArray()
is used:
# maxOfArray([||]);
- : int = -4611686018427387904
# maxOfArray([|3, -1, 5|]);
- : int = 5
Once again, we have used fold
to go from a binary operation (max()
) to an n-ary operation (maxOfArray
). In addition to max()
, we also use the integer constant min_int
. Both are part of module Pervasives
and therefore available without qualification.
max
is a binary function that works for most types:
# max(1.0, 1.1);
- : float = 1.1
# max(None, Some(1));
- : option(int) = Some(1)
# max("a", "b");
- : string = "b"
# max(4, -3);
- : int = 4
min_int
is the lowest possible int value (its exact value depends on the platform that you are using):
# min_int;
- : int = -4611686018427387904
fold_right()
fold_right()
works like fold_left()
, but it starts with the last element. Its type signature is:
let fold_right: (~f: ('b, 'a) => 'a, array('b), ~init: 'a) => 'a;
One use case for this function is converting an array to a list. That list has to be constructed as follows (i.e., you have to start with the last array element):
[··· [x_2nd_last, ...[x_last, ...[]]]]
The function looks like this:
let listFromArray = (arr: array('a)) =>
ArrayLabels.fold_right(~f=(ele, l) => [ele, ...l], arr, ~init=[]);
This is listFromArray()
in action:
# listFromArray([||]);
- : list('a) = []
# listFromArray([| 1, 2, 3 |]);
- : list(int) = [1, 2, 3]
# listFromArray([| "a", "b", "c" |]);
- : list(string) = ["a", "b", "c"]
All array functions return arrays that have the same length as the input arrays. Therefore, if you want to remove elements, you have to take a detour via lists:
let filterArray = (~f, arr) =>
arr
|> ArrayLabels.to_list
|> ListLabels.filter(~f)
|> ArrayLabels.of_list;
filterArray()
in use:
# filterArray(~f=x=>x>0, [|-2, 3, -4, 1|]);
- : array(int) = [|3, 1|]