Unit 36: Monad
Learning Objectives
After this unit, students should:
- understand what are functors and monads
- understand the laws that a functor and monad must obey and be able to verify them
Generalizing Loggable<T>
We have just created a class Loggable<T>
with a flatMap
method that allows us to operate on the value encapsulated inside, along with some "side information". Loggable<T>
follows a pattern that we have seen many times before. We have seen this in Maybe<T>
and Lazy<T>
, and InfiniteList<T>
. Each of these classes has:
- an
of
method to initialize the value and side information. - have a
flatMap
method to update the value and side information.
Different classes above have different side information that is initialized, stored, and updated when we use the of
and flatMap
operations. The class may also have other methods besides the two above. Additionally, the methods may have different names.
Container | Side-Information |
---|---|
Maybe<T> |
The value might be there (i.e., Some<T> ) or might not be there (i.e., None ) |
Lazy<T> |
The value has been evaluated or not |
Loggable<T> |
The log describing the operations done on the value |
These classes that we wrote follow certain patterns that make them well-behaved when we create them with of
and chain them with flatMap
. Such classes that are "well behaved" are examples of a programming construct called monads. A monad must follow three laws, to behave well. Let's examine the laws below.
Identity Laws
Before we list down the first and second laws formally, let's try to get some intuition over the desired behavior first.
The of
method in a monad should behave like an identity. It creates a new monad by initializing our monad with a value and its side information. For instance, in our Loggable<T>
,
1 2 3 |
|
The logger is initialized with empty side information (e.g., empty string as a log message).
Now, let's consider the lambda that we wish to pass into flatMap
— such a lambda takes in a value, computes it, and wraps it in a "new" monad, together with the corresponding side information. For instance,
1 2 3 |
|
What should we expect when we take a fresh new monad Loggable.of(4)
and call flatMap
with a function incrWithLog
? Since Loggable.of(4)
is new with no operation performed on it yet, calling
1 |
|
should just result in the same value exactly as calling incrWithLog(4)
. So, we expect that, after calling the above, we have a Loggable
with a value of 5 and a log message of "incr 4"
.
Our of
method should not do anything extra to the value and side information — it should simply wrap the value 4 into the Loggable
. Our flatMap
method should not do anything extra to the value and the side information, it should simply apply the given lambda expression to the value.
Now, suppose we take an instance of Loggable
, called logger
, that has already been operated on one or more times with flatMap
, and contains some side information. What should we expect when we call:
1 |
|
Since of
should behave like an identity, it should not change the value or add extra side information. The flatMap
above should do nothing and the expression above should be the same as logger
.
What we have just described above is called the left identity law and the right identity law of monads. To be more general, let Monad
be a type that is a monad and monad
be an instance of it.
The left identity law says:
Monad.of(x).flatMap(x -> f(x))
must be the same asf(x)
The right identity law says:
monad.flatMap(x -> Monad.of(x))
must be the same asmonad
Associative Law
Let's now go back to the original incr
and abs
functions for a moment. To compose the functions, we can write abs(incr(x))
, explicitly one function after another. Or we can compose them as another function:
1 2 3 |
|
and call it absIncr(x)
. The effects should be exactly the same. It does not matter if we group the functions together into another function before applying it to a value x.
Recall that after we built our Loggable
class, we were able to compose the functions incr
and abs
by chaining the flatMap
:
1 2 3 |
|
We should get the resulting value as abs(incr(4))
, along with the appropriate log messages.
Another way to call incr
and then abs
is to write something like this:
1 2 3 |
|
We have composed the methods incrWithLog
and absWithLog
and grouped them under another method. Now, if we call:
1 2 |
|
The two expressions must have exactly the same effect on the value and its log message.
This example leads us to the third law of monads: regardless of how we group those calls to `flatMap``, their behavior must be the same. This law is called the associative law. More formally, it says:
monad.flatMap(x -> f(x)).flatMap(x -> g(x))
must be the same asmonad.flatMap(x -> f(x).flatMap(y -> g(y)))
A Counter Example
If our monads follow the laws above, we can safely write methods that receive a monad from others, operate on it, and return it to others. We can also safely create a monad and pass it to the clients to operate on. Our clients can then call our methods in any order and operate on the monads that we create, and the effect on its value and side information is as expected.
Let's try to make our Loggable
misbehave a little. Suppose we change our Loggable<T>
to be as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
Our of
adds a little initialization message. Our flatMap
adds a little new line before appending with the given log message. Now, our Loggable<T>
is not that well-behaved anymore.
Suppose we have two methods foo
and bar
, both take in an x
and perform a series of operations on x
. Both return us a Loggable
instance on the final value and its log.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
Now, we want to perform the sequence of operations done in foo
, followed by the sequence of operations done in bar
. So we called:
1 |
|
We will find that the string "Logging starts"
appears twice in our logs and there is now an extra blank line in the log file!
Functors
We will end this unit with a brief discussion on functors, another common abstraction in functional-style programming. A functor is a simpler construction than a monad in that it only ensures lambdas can be applied sequentially to the value, without worrying about side information.
Recall that when we build our Loggable<T>
abstraction, we add a map
that only updates the value but changes nothing to the side information. One can think of a functor as an abstraction that supports map
.
A functor needs to adhere to two laws:
- preserving identity:
functor.map(x -> x)
is the same asfunctor
- preserving composition:
functor.map(x -> f(x)).map(x -> g(x))
is the same asfunctor.map(x -> g(f(x))
.
Our classes from cs2030s.fp
, Lazy<T>
, Maybe<T>
, and InfiniteList<T>
are functors as well.
Monads and Functors in Other Languages
Such abstractions are common in other languages. In Scala, for instance, the collections (list, set, map, etc.) are monads. In pure functional languages like Haskell, monads are one of the fundamental building blocks.