Yet Another Article on Monads
Does the world need another article on monads? I think it does! There are lots of excellent (and a few not-so-good) articles about monads on the internet. Here, I’m going to give my own take on the subject, with particular reference to Scala.
Let’s start with a wrapper W[X]. It might contain zero, one, or many values of type X. Think of it as being like Option or List, but let’s not be too specific about it for now. It might be declared as a trait or a (case) class and it will come with ways to wrap value(s), i.e., a constructor, and ways to unwrap (get) the value(s). Again, let’s not be too fussy just yet about these details.
Now, we’ll introduce a couple of types that might be used when processing the Internet movie database (IMDB) which you can find on Kaggle:
case class Rating(code: String, age: Option[Int]) {
override def toString: String = code + age.map("-" + _).getOrElse("")
}
case class Reviews(imdbScore: Double, contentRating: Rating)
Note that there should also be other reviews in the Reviews class but for now, we’ll ignore them.
We’ll need to be able to parse the various fields from the CSV file that lists the movie data. The result of parsing a string will be wrapped in W. Why a wrapper? Because something might go wrong with the parsing.
def parseRating(s: String): W[Rating] = ??? // no need for detail here.
def parseDouble(s: String): W[Double] = s.toIntW // this is all as expected if W happens to be Option.
Now, we’d like to construct a Reviews object:
val ratingW = parseRating("PG-13")
val scoreW = parseDouble("78.9")
val reviews = Reviews(scoreW.get, ratingW.get) // No--this undoes all the good work we did by wrapping.
What’s a better way to get a Reviews object? Let’s assume that, since the rating and the score are wrapped inside W, our reviews object should be, too. In the same way that the map method will take an instance of a wrapper and return a new instance but with the contents transformed by a function, we will need a map2 method to do the same thing with two objects. We can change the map method from an instance method (with its implied value of this) into the following map1 method:
def map1[X,Z](xw: W[X])(f: X => Z): W[Z] = ??? // equivalent to xw map f
At the same time, we can declare other methods in the series:
def map0[Z]()(f: () => Z): W[Z]
def map2[X,Y,Z](xw: W[X], yw: W[Y])(f: (X,Y) => Z): W[Z]
def map3[V,X,Y,Z](vw: W[V], xw: W[X], yw: W[Y])(f: (V,X,Y) => Z): W[Z]
// etc.
We can get the same functionality for map0 by declaring z as call-by-name and giving the method a more familiar name:
def pure[Z](z: => Z): W[Z] // Note: might also be called "unit"
But the one we’re interested in for our Reviews use case is map2:
val reviewsW = map2(scoreW, ratingW)(Reviews) // Note: Reviews is short for Reviews.apply
We simply pass Reviews.apply as the function and we get just what we need. The apply method is automagically declared in the companion object of a case class.
How can we implement map2? For the moment, we will replace W with Option because we’re going to use pattern matching.
def map2[X,Y,Z](xw: Option[X], yw: Option[Y])(f: (X,Y) => Z): Option[Z] =
// This expression is equivalent to xw.flatMap(x => yw.map(f(x,_)))
xw match {
case Some(x) =>
// This expression for matching Some(x) is equivalent to yw.map(f(x,_))
yw match {
case Some(y) => Some(f(x,y))
case None => None
}
case None => None
}
This does exactly what we want it to do. Of course, map3, map4, etc. will all look very similar to this. Is there anything we can do to save having to write them all out explicitly? Of course there is. First, let’s note that the inner code, i.e., the expression for Some(x), is the equivalent of the map method for Option (see description at Option.map) where the (partial) function argument is f(x,_). And, as you can see from the comments, the outer code, i.e., the expression for the entire method, is the equivalent of flatMap (see Option.flatMap) where the function argument is x => yw.map(f(x,_))).
While we’re on the subject, the Scala name “flatMap” is fine but it can also be confusing. The notion of “flattening a list” tends to suggest that flatMap only applies to collections. I suggest to you that you forget about flattening anything. Just think of flatMap as the method we need to make these mapN methods work.
def flatMap[X, Z](xw: W[X])(f: X => W[Z]): W[Z]
In other words, we can write our mapN methods as follows, providing only that our wrapper W implements flatMap, and pure (even map can be defined in terms of these two methods):
def map[X,Z](xw: W[X])(f: X => Z): W[Z] = xw flatMap (x => pure(f(x)))
def map0[Z]()(f: () => Z): W[Z] = pure(f())
def map1[X,Z](xw: W[X])(f: X => Z): E[Z] = xw map f
def map2[X,Y,Z](xw: W[X], yw: W[Y])(f: (X,Y) => Z): W[Z] = xw flatMap (x => yw.map(f(x,_)))
def map3[V,X,Y,Z](vw: W[V], xw: W[X], yw: W[Y])(f: (V,X,Y) => Z): W[Z] =
vw flatMap (v => xw flatMap (x => yw map (f(v,x,_))))
// etc.
But Scala gives us an even easier way such that we don’t have to define all these methods at all. And no doubt you’re already familiar with it. It’s called a for comprehension (with the yield keyword, of course). Our use case above can be written simply as:
val reviewsW = for {
score <- scoreW
rating <- ratingW
} yield Reviews(score, rating)
If you take the trouble to desugar this expression, you will see that the desugared code is just what we’d expect:
scoreW flatMap (score => ratingW map (rating => Reviews(score, rating)))
This is the reason that Scala doesn’t provide those mapN methods. We don’t need them.
But, to get back to the original point of this article, all that we require of our container W, other than ways to unwrap the value, is that it implements pure and flatMap. In other words, it’s a monad. We get the map method for free, so to speak.
Monad, therefore, can be thought of simply as a generic term for any container, wrapper, collection, whatever, that supports pure, flatMap, and therefore for comprehensions. [Yes, I know that there’s more to it, really!] With a monadic wrapper, we have for our use the equivalent of all those mapN methods we discussed above. In other words, we can easily compose values that are wrapped in a monadic wrapper — simply by using a for comprehension.