Monads

In the preceding section, we defined functors. With their map methods, the standard Scala collections seem to be good examples of functors. We should, however, emphasize again that a functor doesn't mean a collection—it can be a container and any custom-defined class. Based on an abstract map method and the rules it follows, we can define other functions that will help us reduce code duplication. However, there are not many exciting things we can do based on a mapping only. In our programs, we will have different operations, some of which not only transform a collection or an object, but also modify it in some way.

Monads are another one of those scary terms that come from category theory, which we will try to explain in a way that you will be able to easily understand, identify, and use in your daily routine as a developer.

What is a monad?

We already talked about laws earlier in this chapter. The monoid is defined based on some laws it follows, and these laws allow us to implement generic functionality with certainty, just because we expect certain conditions to hold. If a law is broken, then there is no way for us to know for sure what to expect in terms of how something will behave. In such cases, things would most probably end up returning wrong results.

Similar to the other concepts we already saw in this chapter, monads are defined in terms of the laws they follow. In order for a structure to be considered a monad, it must satisfy all the rules. Let's start with a short definition, which we will expand later on:

Note

Monads are functors that have the unit and flatMap methods and follow the monad rules.

So what does the preceding definition mean? First of all, it means that monads follow all the rules we previously defined about functors. Additionally, they take things further and add support for two more methods.

The flatMap method

Before we formally define the rules, let's have a brief discussion about flatMap. We assume that you are familiar with Scala collections and are aware of the existence of the flatten method. So just the name of flatMap tells us that it maps and then flattens, as shown here:

def flatMap[T](f: Y => Monad[T]) : Monad[T] = flatten(map(f))

We don't have the Monad definition we referred to in the preceding code yet, but that's fine. We will get there. For now, let's just look at it as another generic parameter. You should also know that flatten has the following declaration:

def flatten[T](x: F[F[T]]): M[T]

For example, if F is actually a List, flatten will convert a list of lists in to a simple list of whatever the type of the internal one is. If F is an Option, then the ones with the None value in the nested option will disappear and the rest will remain. These two examples show us that the flatten result actually depends on the specifics of the type being flattened, but in any case, it is clear how it transforms our data.

The unit method

The other method we mentioned previously is unit. It actually doesn't matter how this method is called and it could be different for different languages based on their standards. What is important is its functionality. The signature of unit can be written in the following way:

def unit[T](value: T): Monad[T]

What does the preceding line mean? It is pretty simple—it takes a value of the T type and turns it into a monad of the T type. This is nothing more than a single argument constructor or just a factory method. In Scala, this can be expressed using a companion object with an apply method as well. As long as it does the right thing, the implementation doesn't really matter. In Scala, we have many of the collection types as examples—List, Array, Seq—they all have an apply method that supports the following:

List(x)
Array(x)
Seq(x)

For the Option class, we have Some(x).

The connection between map, flatMap, and unit

In the preceding section, we showed how flatMap can be defined using map and flatten. We can, however, take a different approach and define map using flatMap. Here is how the definition would look like in our pseudo code:

def map[T](f: Y => T): Monad[T] = flatMap { x => unit(f(x)) }

The preceding definition is important because it draws the relationship between all the map, flatMap, and unit methods.

Depending on what kind of monads we implement, it could be sometimes easier to implement map first (usually, if we build collection-like monads) and then flatMap based on it and flatten, while other times it could be easier to first implement flatMap instead. As long as the monad laws are satisfied, it shouldn't matter which approach we take.

The names of the methods

In the preceding section, we mentioned that it doesn't actually matter how the unit method is called. While it is true for unit and it could be propagated to any of the other method, it is recommended that map and flatMap actually remain this way. It doesn't mean that it is not possible to make things work, but following common conventions would make things much simpler. Moreover, map and flatMap give us something extra—the possibility of using our classes in for comprehensions. Consider the following example, which is only here to illustrate how having methods with such names help:

case class ListWrapper(list: List[Int]) {

  // just wrap
  def map[B](f: Int => B): List[B] = list.map(f)
  
  // just wrap
  def flatMap[B](f: Int => GenTraversableOnce[B]): List[B] = list.flatMap(f)
}

In the preceding example, we just wrap a list in an object and define the map and flatMap methods. If we didn't have the preceding object, we could have written something like this:

object ForComprehensionWithLists {
  def main(args: Array[String]): Unit = {
    val l1 = List(1, 2, 3, 4)
    val l2 = List(5, 6, 7, 8)
    val result = for {
      x <- l1
      y <- l2
    } yield x * y
    // same as
    // val result  = l1.flatMap(i => l2.map(_ * i))
    System.out.println(s"The result is: ${result}")
  }
}

With our wrapper object, we could do the same as follows:

object ForComprehensionWithObjects {
  def main(args: Array[String]): Unit = {
    val wrapper1 = ListWrapper(List(1, 2, 3, 4))
    val wrapper2 = ListWrapper(List(5, 6, 7, 8))
    val result = for {
      x <- wrapper1
      y <- wrapper2
    } yield x * y
    System.out.println(s"The result is: ${result}")
  }
}

Both applications do the same and will have exactly the same output:

The names of the methods

What the second application uses, however, is the fact that our wrapper class contains methods specifically with the names such as map and flatMap. If we rename any of them, we would get a compilation error—we could still manage to write the same code but it will not be able to use syntactic sugar in Scala. Another point here is that the for comprehension would work correctly in the case where both the methods actually follow the rules for map and flatMap.

The monad laws

After going a bit through the methods a monad is supposed to support, now we can formally define the monad laws. You already saw that monads are functors and they follow the functor laws. Being explicit is always better, so here we will mix the laws together:

  • Identity law: Doing map over the identity function doesn't change the data: map(x)(i => i) == x. Flat mapping over the unit function also keeps the data the same: x.flatMap(i => unit(i)) == x. The latter basically says that flatMap undoes unit. Using the connection between map, flatMap, and unit we defined earlier, we can derive one of these two rules from the other and vice versa. The unit method can be thought of as the zero element in monoids.
  • The unit law: From the definition of unit, we can also say this: unit(x).flatMap { y => f(y) } == f(x). From this, we will get unit(x).map { y => f(x) } == unit(f(x)). This gives us some interesting connections between all the methods.
  • Composition: Multiple maps must be composed together. It should make no difference if we do x.map(i => y(i)).map(i => z(i)) or x.map(i => z(y(i))). Moreover, multiple flatMap calls must also compose, making the following true: x.flatMap(i => y(i)).flatMap(i => z(i)) == x.flatMap(i => y(i).flatMap(j => z(j))).

Monads, similarly to monoids, also have a zero element. Some real-world examples of monadic zeros are Nil in the Scala List and the None option. However, here we can also have multiple zero elements, which are represented by an algebraic data type with a constructor parameter to which we can pass different values. In order to be complete, we might not have zeros at all if there is no such concept for the monads we are modeling. In any case, the zero monad represents some kind of emptiness and follows some extra laws:

  • Zero identity: This one is pretty straightforward. It says that no matter what function we apply to a zero monad, it is still going to be zero: zero.flatMap(i => f(i)) == zero and zero.map(i => f(i)) == zero. Zero shouldn't be confused with unit, as they are different and the latter doesn't represent emptiness.
  • Reverse zero: This is straightforward as well. Basically, if we replace everything with zero, our final result will also be zero: x.flatMap(i => zero) == zero.
  • Commutativity : Monads can have a concept of addition, whether it is concatenation or something else. In any case, this kind of operation when done with the zero monad will be commutative, for example, x plus zero == zero plus x == x.

    Tip

    Monads and side effects

    When presenting the composition law, we kind of assumed that an operation has no side effects. We said the following: x.map(i => y(i)).map(i => z(i)) == x.map(i => z(y(i))). However, let's now think about what would happen if y or z cause some side effects. On the left-hand side, we first run all y's and then all z's. On the right-hand side, however, we interleave them, doing y and z all the time. Now, if an operation causes a side effect, it would mean that the two might end up producing different results. That's why, developers should prefer using the left-hand side version, especially when there might be side effects such as IO.

We have discussed the monad laws. For those who have more experience with Scala, monads might seem pretty close to the collection classes, and the rules we defined previously might seem logical. However, we are pointing out once more that it is not necessary for a monad to be a collection, and these rules are important to be followed in order to be able to call an algebraic data structure a monad.

Monads in real life

After going through a lot of theory about monads, it would now be useful to go through some code that demonstrates how to implement and use the theoretical concepts, which real-world situations they are good for, and so on.

Let's now, similarly to what we did before, show how a monad trait will look like in Scala. Before doing this, however, let's slightly change our functor definition:

trait Functor[T] {
  def map[Y](f: T => Y): Functor[Y]
}

In the preceding code, instead of passing the element that will be mapped, we assume that the type that mixes Functor will have a way to pass it to the map implementation. We also changed the return type so that we can chain multiple functors using map. After we've done this, we can show our Monad trait:

trait Monad[T] extends Functor[T] {
  
  def unit[Y](value: Y): Monad[Y]
  
  def flatMap[Y](f: T => Monad[Y]): Monad[Y]
  
  override def map[Y](f: T => Y): Monad[Y] =
    flatMap(i => unit(f(i)))
}

The preceding code follows a convention similar to what we used for monoids. The methods the monad has are exactly the same as we have already mentioned earlier in the theoretical part of this chapter. The signatures might be slightly different, but mapping them to the theoretical code, which was made to be understood easily, shouldn't cause any issues.

As you can see, the monads extend functors. Now, whenever we want to write monads, we just need to extend the preceding trait and implement the methods.

Using monads

Simply having a monad trait puts us in a framework that we can follow. We already went through the theory of monads and the laws that they follow. However, in order to understand how monads work and what they are useful for, looking at an actual example is invaluable.

However, how are we supposed to even use monads if we don't know what their purpose is? Let's call them computation builders, as this is exactly what they are used for. This gives the ordinary developer much more understanding about when and where to use monad's computation builder chain operations in some way, which are then performed.

The option monad

We have already mentioned a few times that the standard Scala Option is a monad. In this subsection, we will provide our own monadical implementation of this standard class and show one of the many possible uses of monads.

In order to show how useful the option is, we will see what happens if we don't have it. Let's imagine that we have the following classes:

case class Doer() {
  def getAlgorithm(isFail: Boolean) =
    if (isFail) {
      null
    } else {
      Algorithm()
    }
}

case class Algorithm() {
  def getImplementation(isFail: Boolean, left: Int, right: Int): Implementation =
    if (isFail) {
      null
    } else {
      Implementation(left, right)
    }
}

case class Implementation(left: Int, right: Int) {
  def compute: Int = left + right
}

In order to test, we have added a Boolean flag that will or will not fail to get the required objects. In reality, this could be some complicated function that, depending on parameters or something else, could return null in some specific cases. The following piece of code shows how the preceding classes should be used in order to be completely protected from failure:

object NoMonadExample {
  def main(args: Array[String]): Unit = {
    System.out.println(s"The result is: ${compute(Doer(), 10, 16)}")
  }
  
  def compute(doer: Doer, left: Int, right: Int): Int = 
    if (doer != null) {
      val algorithm = doer.getAlgorithm(false)
      if (algorithm != null) {
        val implementation = algorithm.getImplementation(false, left, right)
        if (implementation !=  null) {
          implementation.compute
        } else {
          -1
        }
      } else {
        -1
      }
    } else {
      -1
    }
}

The compute method in the NoMonadExample object looks really bad and hard to read. We shouldn't write code like that.

Looking at what's happening in the preceding code, we can see that we are actually trying to build a chain of operations, which can individually fail. Monads can help us and abstract this protective logic. Let's now show a much better solution.

First of all, let's define our own Option monad:

sealed trait Option[A] extends Monad[A]

case class Some[A](a: A) extends Option[A] {
  override def unit[Y](value: Y): Monad[Y] = Some(value)
  override def flatMap[Y](f: (A) => Monad[Y]): Monad[Y] = f(a)
}

case class None[A]() extends Option[A] {
  override def unit[Y](value: Y): Monad[Y] = None()
  override def flatMap[Y](f: (A) => Monad[Y]): Monad[Y] = None()
}

We have two concrete cases in the preceding code—one where we can get a value and the other where the result will be empty. Let's now rewrite our computation classes so that they use the new monad we just created:

case class Doer_v2() {
  def getAlgorithm(isFail: Boolean): Option[Algorithm_v2] =
    if (isFail) {
      None()
    } else {
      Some(Algorithm_v2())
    }
}

case class Algorithm_v2() {
  def getImplementation(isFail: Boolean, left: Int, right: Int): Option[Implementation] =
    if (isFail) {
      None()
    } else {
      Some(Implementation(left, right))
    }
}

Finally, we can use them in the following way:

object MonadExample {
  def main(args: Array[String]): Unit = {
    System.out.println(s"The result is: ${compute(Some(Doer_v2()), 10, 16)}")
  }
  
  def compute(doer: Option[Doer_v2], left: Int, right: Int) =
    for {
      d <- doer
      a <- d.getAlgorithm(false)
      i <- a.getImplementation(false, left, right)
    } yield i.compute
// OR THIS WAY:
//    doer.flatMap {
//      d => d.getAlgorithm(false).flatMap {
//        a => a.getImplementation(false, left, right).map {
//          i => i.compute
//        }
//      }
//    }
}

In the preceding code, we've shown a for comprehension usage of our monad, but the port that is commented out is also valid. The first one is preferred because it makes things look really simple, and some completely different computations end up looking the same, which is good for understanding and changing code.

Of course, everything we showed in our example can be implemented using the standard Scala Option. It is almost certain that you have already seen and used this class before, which means that you have actually used monads before, maybe without realizing this was the case.

More advanced monad example

The previous example was pretty simple, and it showed a great use of monads. We made our code much more straightforward, and we abstracted some logic inside the monads. Also, our code became much more readable than it was before.

In this subsection, let's see another use of monads, which is much more advanced this time. All the software we write becomes much more challenging and interesting whenever we add I/O to it. This can be reading and writing data to and from files, communicating with a user, making web requests, and so on. Monads can be used in order to write I/O applications in a purely functional way. There is a really important feature here: I/O has to deal with side effects, operations are usually performed in a sequence and the result depends on a state. This state can be anything—if we ask the user what cars they like, the response would vary depending on the user, and if we ask them what they ate for breakfast, or what the weather is like, the responses to these question will also depend on the user. Even if we try and read the same file twice, there might be differences—we might fail, the file could be changed, and so on. Everything we have described so far is a state. Monads help us hide this state from the user and just expose the important parts as well as abstract the way we deal with errors, and so on.

There are a few important aspects about the state we will be using:

  • The state changes between different I/O operations.
  • The state is only one and we can't just create a new one whenever we want.
  • At any moment in time, there can be only one state.

All of the previous statements are quite logical but they will actually guide the way we implement our state and our monads.

We will write an example, which will read the lines from a file and then go through them and write them in a new file with all the letters capitalized. This can be written in a really easy and straightforward way with Scala, but as soon as some of the operations become more complex or we try to handle errors properly, it can become pretty difficult.

Throughout the example, we will try to show what steps we have taken in order to make sure the previous statements about our state are correct.

Let's start with the state. For the current example, we don't really need a special state but we have used one anyway. It is just to show how to handle cases when one is actually needed:

sealed trait State {
  def next: State
}

The preceding trait has a next method, which will return the next state when we move between different operations. Just by calling it when we pass a state, we make sure that different operations cause a change in state.

We need to make sure that our application has only one state and that nobody can create a state whenever they want. The fact that the trait is sealed helps us to make sure nobody can extend our state outside the file, where we have defined it. Being sealed is not enough though. We need to make sure all the implementations of the state are hidden:

abstract class FileIO {
  // this makes sure nobody can create a state
  private class FileIOState(id: Int) extends State {
    override def next: State = new FileIOState(id + 1)
  }
  
  def run(args: Array[String]): Unit = {
    val action = runIO(args(0), args(1))
    action(new FileIOState(0))
  }
  
  def runIO(readPath: String, writePath: String): IOAction[_]
}

The preceding class defines the state as a private class, and this means that nobody else will be able to create one. Let's ignore the other methods for now, as we will come back to them later.

The third rule we defined for our state earlier is much trickier to achieve. We have taken multiple steps in order to make sure the state behaves correctly. First of all, as can be seen from the previous listing, there is no clue of a state that the user can get to, except the private class that nobody can instantiate. Instead of loading the user with the burden of executing a task and passing a state, we expose only an IOAction to them, which is defined as follows:

sealed abstract class IOAction[T] extends ((State) => (State, T)) {

  // START: we don't have to extend. We could also do this...
  def unit[Y](value: Y): IOAction[Y] = IOAction(value)

  def flatMap[Y](f: (T) => IOAction[Y]): IOAction[Y] = {
    val self = this
    new IOAction[Y] {
      override def apply(state: State): (State, Y) = {
        val (state2, res) = self(state)
        val action2 = f(res)
        action2(state2)
      }
    }
  }

  def map[Y](f: T => Y): IOAction[Y] =
    flatMap(i => unit(f(i)))
  // END: we don't have to extend. We could also do this...
}

Let's first focus only on the IOAction signature. It extends a function from an old state to a tuple of the new state and the result of the operation. So it turns out that we are still exposing the state to our users in a way—it is just in the form of a class. However, we already saw that it is pretty straightforward to hide a state by creating a private class that nobody can instantiate. Our users will be working with the IOAction class, so we need to make sure they don't have to deal with states themselves. We have already defined the IOAction to be sealed. Additionally, we can create a factory object, which will help us create new instances:

object IOAction {
  
  def apply[T](result: => T): IOAction[T] =
    new SimpleAction[T](result)
  
  private class SimpleAction[T](result: => T) extends IOAction[T] {
    override def apply(state: State): (State, T) = 
      (state.next, result)
  }
}

The preceding code is quite important in terms of how things will get wired up later. First of all, we have a private implementation of IOAction. It only takes a by name parameter, which means that it will only be evaluated when the apply method is called— this is really important. Moreover, in the preceding code, we have an apply method for the IOAction object, which allows the users to instantiate actions. Again, here the value is passed by name.

The preceding code, basically, enables us to define actions and only execute them whenever we have a state available.

If we now have a think, you can see that we've managed to satisfy all three requirements for our state. Indeed, by hiding the state behind a class, whose instance creations are controlled by us, we have managed to protect the state so that we don't have more than one at the same time.

Now that we have everything in place, we can make sure our IOAction is a monad. It will need to satisfy the monad laws and define the required methods. We've already shown them, but let's have a closer look at the methods again:

// START: we don't have to extend. We could also do this...
def unit[Y](value: Y): IOAction[Y] = IOAction(value)

def flatMap[Y](f: (T) => IOAction[Y]): IOAction[Y] = {
  val self = this
  new IOAction[Y] {
    override def apply(state: State): (State, Y) = {
      val (state2, res) = self(state)
      val action2 = f(res)
      action2(state2)
    }
  }
}

def map[Y](f: T => Y): IOAction[Y] =
  flatMap(i => unit(f(i)))
// END: we don't have to extend. We could also do this...

We haven't specifically extended our Monad trait, but instead we have just defined the methods here. We already know that map can be defined using flatMap and unit. For the latter, we have used the factory method for the SimpleAction. Our implementation of the former is quite interesting—it performs the current operation first and then sequentially after that, based on the resulting state, the second operation. This allows us to chain multiple I/O operations together.

Let's again look at our IOAction class. Does it satisfy the monad rules? The answer is no, but there is a really easy fix. The problem is that our unit method, if we look into it, would change the state because it uses a SimpleAction. But it shouldn't. What we have to do is create another IOAction implementation that doesn't change the state, and we use it for unit:

private class EmptyAction[T](value: T) extends IOAction[T] {
  override def apply(state: State): (State, T) =
    (state, value)
}

Then our IOAction object will get an extra function:

def unit[T](value: T): IOAction[T] = new EmptyAction[T](value)

We will also have to change the unit method in the IOAction abstract class:

def unit[Y](value: Y): IOAction[Y] = IOAction.unit(value)

So far we defined our monad, made sure the state is handled properly, and that the actions can be created by a user in a controlled manner. What we need to do now is just add some useful methods and try them out:

package object io {
  def readFile(path: String) = 
    IOAction(Source.fromFile(path).getLines())

  def writeFile(path: String, lines: Iterator[String]) = 
    IOAction({
      val file = new File(path)
      printToFile(file) { p => lines.foreach(p.println) }
    })

  private def printToFile(file: File)(writeOp: PrintWriter => Unit): Unit = {
    val writer = new PrintWriter(file)
    try {
      writeOp(writer)
    } finally {
      writer.close()
    }
  }
}

The preceding is the code of a package object that reads and writes files and returns instances of IOAction (in the current case SimpleAction that is created using the IOAction apply method). Now that we have these methods and our monad, we can use the framework we have defined and wire everything up:

abstract class FileIO {
  // this makes sure nobody can create a state
  private class FileIOState(id: Int) extends State {
    override def next: State = new FileIOState(id + 1)
  }

  def run(args: Array[String]): Unit = {
    val action = runIO(args(0), args(1))
    action(new FileIOState(0))
  }

  def runIO(readPath: String, writePath: String): IOAction[_]
}

The preceding code defines a framework that the users of our library will follow; they will have to extend FileIO, implement runIO, and call the run method whenever they are ready to use our application. By now, you should be familiar enough with monads and see that the only thing the highlighted code will do is build a computation. It can be thought of as a graph of operations that have to be performed. It will not execute anything until the next line, where it actually gets the state passed to it.

object FileIOExample extends FileIO {
  
  def main(args: Array[String]): Unit = {
    run(args)
  }
  
  override def runIO(readPath: String, writePath: String): IOAction[_] =
    for {
      lines <- readFile(readPath)
      _ <- writeFile(writePath, lines.map(_.toUpperCase))
    } yield ()
}

The preceding code shows an example usage of the FileIO library that we created. We can now run it with the following input file:

this is a file, which
will be completely capitalized
in a monadic way.

Enjoy!

The command that we need to use is shown as follows:

More advanced monad example

As expected, the output file will contain the same text with all uppercase letters. You can, of course, try with different inputs and see how the code performs.

Monad intuition

In this section, we went through some theory and real-world examples with monads. Hopefully, we have managed to give an easy to understand explanation of what is what, how and why it works. Monads are not as scary as they initially seem to be and some time spent with them would give an even better understanding of how and why things work in a certain way.

Note

The last example could seem pretty complicated, but some extra time spent with it using an IDE will make it clear and easy for you to realize how exactly everything gets wired up. Then you will be able to easily spot and use monads on your own.

Of course, a developer can probably get away without monads, but using them can help with hiding details about exception handling, specific operations, and so on. Monads are actually good because of the extra work that happens inside them, and they can be used to implement some of the design patterns we saw earlier in this book. We can implement better states, rollbacks, and many many more. It is also worth mentioning that it is likely that many times we use monads without even realizing.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.138.36.38