Skip to content

Instantly share code, notes, and snippets.

@SebastianAas
Created August 6, 2020 14:50
Show Gist options
  • Save SebastianAas/875b39f5f592d296bd8d58a5915d9609 to your computer and use it in GitHub Desktop.
Save SebastianAas/875b39f5f592d296bd8d58a5915d9609 to your computer and use it in GitHub Desktop.

The power of monads and for comprehensions

If you have ever worked with Scala and its Future type, you may have seen that combining these with for-comprehensions isn’t very pretty (To learn more about Futures, check out The Neophyte's Guide to Scala. This blog post will show you how you can use monads in combination with for comprehensions to write better and cleaner code. Let’s get right into it.

For comprehensions, which are made to make your chains of map, flatMap and withFilter more readable. However, when working with collections inside futures, it can get quite messy. With types wrapped around in Futures, like Future[Set[Int]], it can get as ugly as this.

    for {
    a1 <- Future(Set(1, 2, 3))
    b1 <- Future(Set(1, 2, 3))
    } yield {
        for {
            x <- a1
            y <- b1 if y > 2
        } yield {
            x + y
        }
    }
    //Result: Future[Set[4,5,6]]

The reason that we need two for comprehensions is that we need each type in the for comprehension to be able to map and flatMap on the succeeding type. Since no such method exists from Future to Set, we are forced to create two distinct for comprehensions.

Let's look at what a for comprehension consists of by looking at the inner for comprehension in the above example. Since for comprehensions are nothing more than syntactic sugar for chains of map, flatMap and filter, we can decompose it to these operations. Intellij also has a function which can do this automatically called Desugar Scala Code....

def example() = {
    val a1 = Set(1, 2, 3)
    val b2 = Set(1, 2, 3)
    for {
        x <- a1
        y <- b2 if y > 2
    } yield {
        x + y
    }
}

When you desugar the for comprehension above, it becomes:

def example() = {
    val a1 = Set(1, 2, 3)
    val b2 = Set(1, 2, 3)
    a1
    .flatMap(x =>
        b2
        .withFilter(y => y > 2)
        .map(y => x + y)
    )
}

As we see, there are only three methods being used in a for comprehension, map, flatMap and withFilter. This means that every datatype that supports these operations (with the proper types), can be used in for comprehensions. The withFilter method works as a filter, and let’s through only the elements which meet the requirement. In the for comprehension, it is written using the if condition. Knowing what a for comprehension consists of, let’s look at how we can clean up the previous example using for comprehension in combination with a nifty concept called Monads.

Monads

Monads is a design pattern in functional programming. A Monad works as a wrapper around types, such as List, Set, Option. Option is in fact a monad itself, but we can also wrap monads around monads. If you want a better understanding of monads, you can check out the blogpost Exploring monads in Scala Collections. To clean up our example, we need a monad for Future[Set[A]] which we can call SetF[A]. So how can we implement the SetF monads for our for comprehension? All we need is to implement the operations map, flatMap and withFilter to make it usable in a for comprehensions.

final case class SetF[A](_future: Future[Iterable[A]]) extends AnyVal {

  def future: Future[Set[A]] = _future.map(_.toSet)

  def flatMap[B](f: A => SetF[B]): SetF[B] = {
    val newFuture: Future[Set[B]] = for {
      list: Set[A]        <- future
      result: Set[Set[B]] <- Future.sequence(list.map(f(_).future))
    } yield {
      result.flatten
    }
    SetF(newFuture)
  }

  def withFilter(f: A => Boolean): SetF[A] = SetF(future.map(_.filter(f)))

  def map[B](f: A => B): SetF[B] = SetF(future.map(option => option.map(f)))

Let's revisit our previous example, but now using SetF.

def addAllCombinations() = {
    val a = Future(Set(1, 2, 3))
    val b = Future(Set(1, 2, 3))
    for {
        x <- SetF(a)
        y <- SetF(b) if y > 2
    } yield {
        x + y
    }
}

As you see, it's quite the improvement. There is not that much code to implement SetF, but it can be a huge improvement in the code you write everyday.

To make the monad more flexible, we can create apply methods for it, so it can wrap around the types you want. Additionally, we'll implement a method called lift, which will make it possible to bind the Set within the future to a variable (just as if we hadn't used a monad)

object SetF {

  def lift[A](f: Future[A]): SetF[A] = SetF(f.map(Set(_)))

  def apply[A](o: Iterable[A]): SetF[A] =
    SetF(Future.successful(o match {
      case o: Set[_] => o.asInstanceOf[Set[A]]
      case o         => o.toSet
  })

Here we have implemented apply method for Future[A], Set[A], Future[Set[A]], which mean you can wrap it SetF around all these types. Let's look at an example with lift.

def example2() = {
    val a = Future(0.5)
    val b = Future(Set(1, 2, 3))
    for {
        x <- SetF.lift(a)
        y <- SetF(b)
    } yield {
        x * y
    }
}

If you only want to multiply the Future[Set[Int]] with a Future[Double] you can use lift to get the value out from the future.

Monads are incredibly versatile, and can be used in many different ways. I hope this post helped you get a better grasp of how they can be used.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment