In the last chapter, we defined the List[A]
type and several generic list-
processing functions, including map
. Here is the definition of map
from the last chapter. The map
function applies f
to every element
in a list and produces the list of results:
def map(f: Int => Int, lst: List[Int]): List[Int] = lst match {
case Empty() => Empty()
case Cons(head, tail) => Cons(f(head), map(f, tail))
}
In this chapter, we’ll see how to define map
for an arbitrary container
data structure.
Let’s define a type for binary trees called Tree[A]
. We are not trying to
define binary search trees, so we don’t have to think about keys and values
or orderings. We simply need a way to represent tree-shaped data.
There are several ways to represent binary search trees. For this example,
let’s consider trees that have values at interior nodes and no values
at the leaves:
sealed trait Tree[A]
case class Leaf[A]() extends Tree[A]
case class Node[A](lhs: Tree[A], value: A, rhs: Tree[A]) extends Tree[A]
For example, the following tree, where .
represents a leaf:
5 / \ 7 . / \ 4 . / \ . .
Can be represented as like this:
val tree1 = Tree(Tree(Tree(Leaf(), 4, Leaf()), 7, Leaf()), 5, Leaf())
map
for Binary TreesWhat does it mean to define a mapTree
function for binary tree?. Just like
map
over lists, mapTree
will apply a function f
to every value in
the tree. mapTree
will also preserve the structure of the tree, just
like map
did for lists. We can write mapTree
by simply recurring into every sub-tree,
just as we did for map:
def mapTree[A, B](f: A => B, tr: Tree[A]): Tree[B] = tr match {
case Leaf() => Leaf()
case Node(l, v, r) => Node(mapTree(f, l), f(v), mapTree(f, r))
}
For example, suppose we want to add 1
to every value in a tree of integers,
such as tree1
. We could use mapTree
to do this very easily:
def add1(n: Int): Int = n + 1
def incrTree(tr: Tree[Int]): Tree[Int] = mapTree(add1, tr)
Option[A]
is a containerWe can define a map
-like function for any container type that is parameterized
over the type of value it stores. In fact, we even treat the Option[A]
type
as a container. Previously, we’ve treated Option[A]
as an alternative to
exceptions. However, we can also think of it as an container. Unlike
List[A]
and BinTree[A]
, which can hold several values, an Option[A]
can only hold zero values or one value.
Here is mapOption
:
def mapOption[A, B](f: A => B, opt: Option[A]): Option[B] = opt match {
case None() => None()
case Some(x) => Some(f(x))
}
Notice that it has exactly the same structure as map
and mapTree
: it
applies f
to the value of type A
. However, it does not need to
recur, since Option[A]
is not a recursively-defined type.
There is nothing special about the types List[A]
, BinTree[A]
, and
Option[A]
. We could define map
for arrays, hash-tables, or anything else.
We can also define some strange containers. For example, here is a container that holds one or two values:
trait OneTwo[A]
case class One[A](x: A) extends OneTwo[A]
case class Two[A](x: A, y: A) extends OneTwo[A]
Can you define a map
-like function for this container? It will look a lot like
mapOption
, since you don’t need to recur.
Here is another strange container that only stores an even number of elements:
trait EvenList[A]
case class EmptyEvenList[A]() extends EvenList[A]
case class ConsEvenList[A](x: A, y: A, rest: EvenList[A]) extends EvenList[A]
Can you define a mapEvenList
function?
map
-like really mean?We’ve seen several map
-like functions for different data structures, but what
does it really mean for a function to be map
-like?
To help us answer this question, we’ll have to use the best generic function in the world:
def id[A](x: A) = x
In the definition above, id
is short for the identity function, but you
already knew that.
We have used map
with several functions: the increment function, which adds 1
to an integer, the double function, the string-length function, etc. But,
what happens if we map the identity function over a list?
map(id, lst)
Unsurprisingly, the following identity holds map(id, lst) == lst
(no pun
intended).
This identity holds for the other map
-like functions too:
mapTree(id, tree) == tree
mapOption(id, opt) == opt
This identity should hold for the map
-like functions over EvenList
and
OneTwo
types too.
Here is another property of map
. Consider the following code:
def double(x: Int): Int = x + x
def add1(x: Int): Int = x + 1
val lst = List(1, 2, 3)
map(add1(map(double, lst)))
This program first doubles every value in lst
, producing the list
List(2, 4, 6)
and then adds 1 to every value in this intermediate list, producing
List(3, 5, 7)
.
Instead of producing the intermediate list, we could simply write a composite function that both doubles and increments:
def doubleThenAdd1(x: Int): Int = (x + x) + 1
map(add1(map(double, lst))) == map(DoubleThenAdd1, lst)
We can generalize this observation if we write a function to compose two functions:
def compose[A,B,C](f: A => B, g: B => C) : A => C = {
def h(a: A): C = { g(f(a)) }
h
}
map(g, map(f, lst)) == map(compose(f, g), lst)
This identity also holds for the other map
-like functions:
mapTree(g, mapTree(f, tree)) == mapTree(compose(f, g), tree)
mapOption(g, mapOption(f, opt)) == mapOption(compose(f, g), opt)
It is quite easy to think of other simple properties of map
. For example,
if we have a function to append two lists:
def append[A](lst1: List[A], lst2: List[A]): List[A]
Then, this identity holds:
append(map(f, lst1), map(f, lst2)) == map(f, append(lst1, lst2))
However, it is not clear what it means to append
two binary trees. So, perhaps
this identity only makes sense for lists.
However, the two earlier identities seem to hold for any type of container:
map(g, map(f, container)) == map(compose(f, g), container)
map(id, container) == id
Perhaps what means for a function to be map
-like is that these two identities
must hold.
We won’t answer this question in this class, since that would take us into
the realm of category theory. But, you should realize that we can define
map
for any sort of container.