Monads are one of those things that people love to talk about but which remain elusive and mysterious. If you’ve done any reading on functional programming, you will have come across the term.
Despite all the literature, the subject is often not well understood, partly because monads come from the abstract mathematics field of category theory and partly because, in programming languages, Haskell dominates the literature. Neither Haskell nor category theory are particularly relevant to the mainstream developer and both bring with them concepts and terminology that can be challenging to get your head around.
The good news is that you don’t have to worry about any of that stuff. You don’t need to understand category theory for functional programming. You don’t need to understand Haskell to program with Scala.
A layman’s definition of a monad might be:
Something that has
map
andflatMap
functions.
This isn’t the full story, but it will serve us as a starting point.
We’ve already seen that collections in Scala are all monads. It’s useful to transform these with map
and flatten one-to-many transformations with flatMap
. But map
and flatMap
do different things on different types of monads.
Let’s take the Option
class. You can use Option
as a way of avoiding nulls, but just how does it avoid nulls and what has it got to do with monads? There are two parts to the answer:
null
by returning a subtype of Option
to represent no value (None
) or a wrapper around a value (Some
). As both “no value” and “some value” are of type Option
, you can treat them consistently. You should never need to say “if not null”.Option
consistently is to use the monadic methods map
and flatMap
. So Option
is a monad.We know what map
and flatMap
do for collections, but what do they do for an Option
?
map
FunctionThe map
function still transforms an object, but it’s an optional transformation. It will apply the mapping function to the value of an option, if it has a value. The value and no value options are implemented as subclasses of Option
: Some
and None
respectively.
A mapping only applies if the option is an instance of Some
. If it has no value (i.e., it’s an instance of None
), it will simply return another None
.
This is useful when you want to transform something but not worry about checking if it’s null. For example, we might have a Customers
trait with repository methods add
and find
. What should we do in implementations of find
when a customer doesn’t exist?
trait
Customers
extends
Iterable
[
Customer
]
{
def
add
(
Customer
:
Customer
)
def
find
(
name
:
String
)
:
Customer
}
A typical Java implementation would likely return null
or throw some kind of NotFoundException
. For example, the following Set
-based implementation returns a null
if the customer cannot be found:
class
CustomerSet
extends
Customers
{
private
val
customers
=
mutable
.
Set
[
Customer
]()
def
add
(
customer
:
Customer
)
=
customers
.
add
(
customer
)
def
find
(
name
:
String
)
:
Customer
=
{
for
(
customer
<-
customers
)
{
if
(
customer
.
name
==
name
)
return
customer
}
null
}
def
iterator
:
Iterator
[
Customer
]
=
customers
.
iterator
}
Returning null
and throwing exceptions both have similar drawbacks.
Neither communicate intent very well. If you return null
, clients need to know that’s a possibility so they can avoid a NullPointerException
. But what’s the best way to communicate that to clients? ScalaDoc? Ask them to look at the source? Both are easy for clients to miss. Exceptions may be somewhat clearer but as Scala exceptions are unchecked, they’re just as easy for clients to miss.
You also force unhappy path handling to your clients. Assuming that consumers do know to check for a null, you’re asking multiple clients to implement defensive strategies for the unhappy path. You’re forcing null checks on people and can’t ensure consistency, or even that people will bother.
Defining the find
method to return an Option
improves the situation. Below, if we find a match, we return Some
customer or None
otherwise. This communicates at an API level that the return type is optional. The type system forces a consistent way of dealing with the unhappy path.
trait
Customers
extends
Iterable
[
Customer
]
{
def
add
(
Customer
:
Customer
)
def
find
(
name
:
String
)
:
Option
[
Customer
]
}
Our implementation of find
can then return either a Some
or a None
.
def
find
(
name
:
String
)
:
Option
[
Customer
]
=
{
for
(
customer
<-
customers
)
{
if
(
customer
.
name
==
name
)
return
Some
(
customer
)
}
None
}
Let’s say that we’d like to find a customer and get their total shopping basket value. Using a method that can return null
, clients would have to do something like the following, as Albert may not be in the repository.
val
albert
=
customers
.
find
(
"Albert"
)
// can return null
val
basket
=
if
(
albert
!=
null
)
albert
.
total
else
0
D
If we use Option
, we can use map
to transform from an option of a Customer
to an option of their basket value.
val
basketValue
:
Option
[
Double
]
=
customers
.
find
(
"A"
).
map
(
customer
=>
customer
.
total
)
Notice that the return type here is an Option[Double]
. If Albert isn’t found, map
will return a None
to represent no basket value. Remember that the map
on Option
is a optional transformation.
When you want to actually get hold of the value, you need to get it out of the Option
wrapper. The API of Option
will only allow you call get
, getOrElse
or continue processing monadically using map
and flatMap
.
Option.get
To get the raw value, you can use the get
method but it will throw an exception if you call it against no value. Calling it is a bit of a smell as it’s roughly equivalent to ignoring the possibility of a NullPointerException
. You should only call it when you know the option is a Some
.
// could throw an exception
val
basketValue
=
customers
.
find
(
"A"
).
map
(
customer
=>
customer
.
total
).
get
To ensure the value is a Some
, you could pattern match like the following, but again, it’s really just an elaborate null check.
val
basketValue
:
Double
=
customers
.
find
(
"Missing"
)
match
{
case
Some
(
customer
)
=>
customer
.
total
// avoids the exception
case
None
=>
0
D
}
Option.getOrElse
Calling getOrElse
is often a better choice as it forces you to provide a default value. It has the same effect as the pattern match version, but with less code.
val
basketValue
=
customers
.
find
(
"A"
).
map
(
customer
=>
customer
.
total
).
getOrElse
(
0
D
)
Option
If you want to avoid using get
or getOrElse
, you can use the monadic methods on Option
. To demonstrate this, we need a slightly more elaborate example. Let’s say we want to sum the basket value of a subset of customers. We could create the list of names of customers we’re interested in and find each of these by transforming (mapping) the customer names into a collection of Customer
objects.
In the example below, we create a customer database, adding some sample data before mapping.
val
database
=
Customers
()
val
address1
=
Some
(
Address
(
"1a Bridge St"
,
None
))
val
address2
=
Some
(
new
Address
(
"2 Short Road"
,
Some
(
"AL1 2PY"
)))
val
address3
=
Some
(
new
Address
(
"221b Baker St"
,
Some
(
"NW1"
)))
database
.
add
(
new
Customer
(
"Albert"
,
address1
))
database
.
add
(
new
Customer
(
"Beatriz"
,
None
))
database
.
add
(
new
Customer
(
"Carol"
,
address2
))
database
.
add
(
new
Customer
(
"Sherlock"
,
address3
))
val
customers
=
Set
(
"Albert"
,
"Beatriz"
,
"Carol"
,
"Dave"
,
"Erin"
)
customers
.
map
(
database
.
find
(
_
))
We can then transform the customers again to a collection of their basket totals.
customers
.
map
(
database
.
find
(
_
).
map
(
_
.
total
))
Now here’s the interesting bit. If this transformation were against a value that could be null, and not an Option
, we’d have to do a null check before carrying on. However, as it is an option, if the customer wasn’t found, the map would just not do the transformation and return another “no value” Option
.
When finally we want to sum all the basket values and get a grand total, we can use the built-in function sum
.
customers
.
map
(
database
.
find
(
_
).
map
(
_
.
total
)).
sum
// wrong!
However, this isn’t quite right. Chaining the two map functions returns a Set[Option[Double]]
, and we can’t sum that. We need to flatten this down to a sequence of doubles before summing.
customers
.
map
(
database
.
find
(
_
).
map
(
_
.
total
)).
flatten
.
sum
^
notice
the
position
here
,
we
map
immediately
on
Option
The flattening will discard any None
s, so afterwards the collection size will be 3. Only Albert, Carol, and Beatriz’s baskets get summed.
Option.flatMap
FunctionAbove, we replicated flatMap
behaviour by mapping and then flattening, but we could have used flatMap
on Option
directly.
The first step is to call flatMap
on the names instead of map
. As flatMap
does the mapping and then flattens, we immediately get a collection of Customer
.
val
r
:
Set
[
Customer
]
=
customers
.
flatMap
(
name
=>
database
.
find
(
name
))
The flatten part drops all the None
s, so the result is guaranteed to contain only customers that exist in our repository. We can then simply transform those customers to their basket total, before summing.
customers
.
flatMap
(
name
=>
database
.
find
(
name
))
.
map
(
customer
=>
customer
.
total
)
.
sum
Dropping the no value options is a key behaviour for flatMap
here. For example, compare the flatten on a list of lists:
scala
>
val
x
=
List
(
List
(
1
,
2
),
List
(
3
),
List
(
4
,
5
))
x
:
List
[
List
[
Int
]]
=
List
(
List
(
1
),
List
(
2
),
List
(
3
))
scala
>
x
.
flatten
res0
:
List
[
Int
]
=
List
(
1
,
2
,
3
,
4
,
5
)
…to a list of options.
scala
>
val
y
=
List
(
Some
(
"A"
),
None
,
Some
(
"B"
))
y
:
List
[
Option
[
String
]]
=
List
(
Some
(
A
),
None
,
Some
(
B
))
scala
>
y
.
flatten
res1
:
List
[
String
]
=
List
(
A
,
B
)
As a more formal definition, a monad must:
flatMap
operation (sometimes called bind).Option
and List
both meet these criteria.
Option | List | |
---|---|---|
Parameterised (type constructor) | Option[A] | List[T] |
Construction (unit) | Option.apply(x) | List(x, y, z) |
Some(x) | ||
None | ||
flatMap (bind) | def flatMap[B](f: A => Option[B]): Option[B] | def flatMap[B](f: A => List[B]): List[B] |
The definition doesn’t mention map
, though, and our layman’s definition for monad was:
Something that has
map
andflatMap
functions.
I wanted to introduce flatMap
in terms of map
because it always applies a mapping function before flattening. It’s true that to be a monad you only have provide flatMap
but in practice monads also supply a map
function. This is because all monads are also functors; it’s functors that more formally have to provide maps.
So the technical answer is that providing flatMap
, a parameterised type, and the unit function makes something a monad. But all monads are functors and map
comes from functor.
In this chapter, I explained that when people talk about monadic behaviour, they’re really just talking about the map
and flatMap
functions. The semantics of map
and flatMap
can differ depending on the type of monad but they share a formal, albeit abstract, definition.
We looked at some concrete examples of the monadic functions on List
and Option
, and how we can use these with Option
to avoid null checks. The real power of monads, though, is in “chaining” these functions to compose behaviour into a sequence of simple steps. To really see this, we’re going to look at some more elaborate examples in the next chapter, and see how for comprehensions work under the covers.