Scala: a recap of map, flatMap and Options
A (not so) quick intro/recap to map, flatMap and Options in Scala.
Map & For Comprehension
Map() evaluates a function over each element in the list, returning a list with the same number of elements.
Example: do nothing
val numbers = List(1,2,3,4,5) numbers map ((n: Int) => n) numbers map (n => n)
A for comprehension is a pretty way of saying the same: iterate through a list, and apply some function to every item.
for (n <- numbers) yield n
Example: double every odd element
numbers filter ((p: Int) => p % 2 == 1) map ((n : Int) => n * 2) numbers filter (p => p % 2 == 1) map (n => n * 2) numbers filter (_ % 2 == 1) map (_ * 2)
The for comprehension equivalent is
for (n <- numbers if n % 2 == 1) yield n * 2
which btw is almost the same as Python’s list comprehensions:
[ n * 2 for n in range(1,6) if n % 2 == 1 ]
flatMap
The signatures for map and flatMap are:
def map[B](f: (A) => B): Traversable[B] def flatMap[B](f: (A) => GenTraversableOnce[B]): Traversable[B]
the signature for flatMap tells us how it works, namely by applying a function that
- returns a sequence for each element in the list
- flattens the result into the original list
in fact if I try to feed to flatMap a function that does not return a GenTraversableOnce it won’t compile, since you can’t possibly flatten a flat list!
List(1,2,3) flatMap (x => x) // fails: expected Int => GenTraversableOnce[B], // actual Int => Int scala> List(1,2,3) flatMap (x => List(x)) scala> List(1,2,3) flatMap (x => Some(x)) // these are fine
Example: using flatMap to flatten a nested list
val nested = List(List(1,2), List(3,4)) nested flatMap((x: List[Int]) => x: List[Int]) // List(1,2,3,4)
This takes a list of int, returns a list of int, and flattens the result. The same can also be expressed by manually “unrolling” the nested list, and flattening the result:
nested map ((x: List[Int]) => x map ((n: Int) => n)) flatten nested map (x => x map (n => n)) flatten
To be fair, this last example is a bit weird because I could actually just flatten the nested list without “unrolling” it first; if I did the unrolling is because a) it makes for a good example, forcing me to reason about the inner structure of the object I’m dealing with; and b) it actually becomes necessary if I want to do something with the inner objects, e.g.
nested flatMap (inner => inner map (value => value * 2))
Syntactic sugar for map and flatMap
Chaining multiple map and flatMap calls can become hard to read, so I can spread it over multiple lines. I can write the following
nested flatMap (innerList => innerList map (value => value * 2))
using the curly brackets syntax for map/flatMap
nested flatMap { innerList =>
innerList map { value =>
value * 2
}
}
or using a for comprehension
for {
list <- nested
n <- list
} yield n * 2
Dealing with Options
Check the nice tutorial linked at the bottom of this post for a throughout explanation of Options. The TL;DR version is that Options can be seen as a kind of list that contains either zero elements or one; if I think of it as a list it makes sense to use functional combinators on it!
Say that I have a method like
def findById(id: Int): Option[User]
said method will return either Some(user) or None, and I want to avoid null pointer exceptions when I try to do something with the user but I get None instead.
There are three ways to deal with this scenario:
1) I can manually check if the user is defined
val user = UserRepository.findById(1)
if (user.isDefined) {
println(user.get.firstName)
}
which is not that nice.
2) I can use pattern matching:
UserRepository.findById(1) match {
case Some(user) => println(user.name)
case None => println("lookup failed")
}
This is useful especially when I want to be explicit about what to do if the lookup fails, e.g. I want to log a warning message and exit the app.
3) I can use functional combinators
UserRepository.findById(2).foreach(user => println(user.name)) UserRepository.findById(2).map(_.age) // Some(33)
The latter method is especially neat because I can chain multiple calls to functional combinators which will only be executed if some value is present. On the other hand, it means that the code will always succeed, and it’s going to be harder to handle the case when the lookup fails.
Mapping over Options
To conclude, here’s some mock classes to play with mostly as a boring exercise:
case class Country(name: String, capital: Option[City])
case class City(name: String, hasRiver: Option[Boolean])
val london = City("London", Some(true))
val milan = City("Milan", None)
val england = Country("England", Some(london))
val italy = Country("Italy", None)
Since london.hasRiver is a list, the signatures for map and flatMap are
map[B] (f: (Boolean => B)) // returns Option[B] flatMap[B] (f: (Boolean => Option[B])) // returns Option[B]
the signature for map is telling me that what map does here is:
- it takes a Boolean (remember I’m mapping through
Option[Boolean]) - it converts it to whatever type B
- it returns an Option of whatever type is B, because map preserves the shape of the monad I applied it to
following the same logic, flatMap returns Option[B] – and not Option[Option[B]] – because it flattens the list, so that:
london.hasRiver map(x => x) london.hasRiver flatMap (x => Some(x)) // they both returns Some(true)
Since capital is an Option, I have to treat it as if it were a list:
england.capital.hasRiver // doesn't compile england.capital.get.hasRiver // works
The problem with this is, if capital is None, I get a NullPointerException – and rightly so. Thus, is good practice to always pass Options to a functor, that will return None instead of blowing in my face:
england.capital.foreach(city => println(city.name)) // London italy.capital.foreach(city => println(city.name)) // nothing italy.capital.get.hasRiver // blows up bc no capital italy.capital.flatMap(city => city.hasRiver) // None italy.capital.flatMap(_.hasRiver) // same but prettier
What flatMap here is saying is pretty much “go through the list that contains either a city or nothing, and if you find any, return city.hasRiver“. As if it was a nested list, hasRiver, being a nested Option, would return an Option[Option[Boolean]] if called with a map instead of a flatMap.
Anyhow, you really don’t want to call get on a None – remember hasRiver is an Option, so you’re still getting either Some(boolean) or None – so is key to use getOrElse() instead:
italy.capital.flatMap(_.hasRiver).getOrElse("nope")
For anything more complicated than two levels of nested options (it happens all the time, for example when you model a Json response to a case class) the for comprehension is easier to read, even though you can achieve the same result chaining maps and flatMaps.
For instance, if we add Continents to the model:
case class Continent(name: String, countries: Option[List[Country]])
val europe = Continent("Europe", Some(List(england, italy)))
the following are equivalent:
for {
countries <- europe.countries
capital <- countries.head.capital
river <- capital.hasRiver
} yield river // Some(true)
and
europe.countries.flatMap { countries =>
countries.head.capital.flatMap { capital =>
capital.hasRiver
}
} // Some(true)
if we unroll the for comprehension:
- countries is an
Option[List[Country]] - countries.head is an
Option[Country]of the first country in the list, i.e.Some(Country(England,Some(City(London,Some(true)) - capital is an
Option[City], i.e.Some(City(London,Some(true))) - river is an
Option[Boolean], i.e.Some(true)