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)