Книга: Learn Scala for Java Developers
Назад: Map and FlatMap
Дальше: For Comprehensions

Monads

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.

Basic Definition

A layman’s definition of a monad might be:

Something that has map and flatMap 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.

Option

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:

  1. You avoid returning 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”.
  2. How you actually go about treating Option consistently is to use the monadic methods map and flatMap. So Option is a monad.

Null Object Pattern

If you’ve ever seen the Null Object pattern, you’ll notice it’s a similar idea. The Null Object pattern allows you to replace a type with a subtype to represent no value. You can call methods on the instance as if it were a real value but it essentially does nothing. It’s substitutable for a real value but usually has no side effects.

The main difference is that the methods you can call, defined by the instance’s super-type, are usually business methods. The common methods of a monad are map and flatMap and are lower level, functional programming abstractions.

We know what map and flatMap do for collections, but what do they do for an Option?

The map Function

The 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.

Fig. 3.3. The `Option` classes.

Fig. 3.3. The Option classes.

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 0D 

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 => 0D   } 

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(0D) 

Monadically Processing 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 Nones, so afterwards the collection size will be 3. Only Albert, Carol, and Beatriz’s baskets get summed.

The Option.flatMap Function

Above, 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 Nones, 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) 

More Formal Definition

As a more formal definition, a monad must:

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 and flatMap 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.

Fig. 3.4. The `Functor` and `Monad` behaviours.

Fig. 3.4. The Functor and Monad behaviours.

Summary

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.

Назад: Map and FlatMap
Дальше: For Comprehensions