Skip to content

Syntactic sugar for monad composition in Scala

License

Notifications You must be signed in to change notification settings

utgheith/monadless

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

monadless

Syntactic sugar for monad composition (or: "async/await" generalized)

Build Status Codacy Badge Join the chat at https://gitter.im/monadless/monadless-Dependency Status Maven Central Javadocs

Problem

Dealing with monad compositions involves considerable syntax noise. For instance, this code using the Future monad:

callServiceA().flatMap { a =>
  callServiceB(a).flatMap { b =>
    callServiceC(b).map { c =>
      (a, c)
    }
  }
}

would be much easier to follow using synchronous operations, without a monad:

  val a = callServiceA()
  val b = callServiceB(a)
  val c = callServiceC(b)
  (a, c)

This issue affects the usability of any monadic interface (Future, Option, Try, etc.). As an alternative, Scala provides for-comprehensions to reduce the noise:

  for {
    a <- callServiceA()
    b <- callServiceB(a)
    c <- callServiceC(b)
  } yield {
    (a, c)
  }

They are useful to express sequential compositions and make it easy to access the results of each for-comprehension step from the following ones, but they don't provide syntax sugar for Scala constructs other than assignment (<-, =) and mapping (yield).

Solution

Most mainstream languages have support for asynchronous programming using the async/await idiom or are implementing it (e.g. F#, C#/VB, Javascript, Python, Swift). Although useful, async/await is usually tied to a particular monad that represents asynchronous computations (Task, Future, etc.).

This library implements a solution similar to async/await but generalized to any monad type. This generalization is a major factor considering that some codebases use other monads like Task in addition to Future for asynchronous computations.

Given a monad M, the generalization uses the concept of lifting regular values to a monad (T => M[T]) and unlifting values from a monad instance (M[T] => T). Example usage:

lift {
  val a = unlift(callServiceA())
  val b = unlift(callServiceB(a))
  val c = unlift(callServiceC(b))
  (a, c)
}

Note that lift corresponds to async and unlift to await.

Getting started

The lift and unlift methods are provided by an instance of io.monadless.Monadless. The library is generic and can be used with any monad type, but sub-modules with pre-defined Monadless instances are provided for convenience:

monadless-stdlib

SBT configuration:

// scala
libraryDependencies += "io.monadless" %% "monadless-stdlib" % "0.0.13"

// scala.js
libraryDependencies += "io.monadless" %%% "monadless-stdlib" % "0.0.13"

Imports:

// for `scala.concurrent.Future`
import io.monadless.stdlib.MonadlessFuture._

// for `scala.Option`
// note: doesn't support `try`/`catch`/`finally`
import io.monadless.stdlib.MonadlessOption._

// for `scala.util.Try`
import io.monadless.stdlib.MonadlessTry._

monadless-monix

SBT configuration:

// scala
libraryDependencies += "io.monadless" %% "monadless-monix" % "0.0.13"

// scala.js
libraryDependencies += "io.monadless" %%% "monadless-monix" % "0.0.13"

Usage:

// for `monix.eval.Task`
import io.monadless.monix.MonadlessTask._

monadless-cats

SBT configuration:

// scala
libraryDependencies += "io.monadless" %% "monadless-cats" % "0.0.13"

// scala.js
libraryDependencies += "io.monadless" %%% "monadless-cats" % "0.0.13"

Usage:

// for `cats.Applicative`
// note: doesn't support `try`/`catch`/`finally`
val myApplicativeMonadless = io.monadless.cats.MonadlessApplicative[MyApplicative]()
import myApplicativeMonadless._

// for `cats.Monad`
// note: doesn't support `try`/`catch`/`finally`
val myMonadMonadless = io.monadless.cats.MonadlessMonad[MyMonad]()
import myMonadMonadless._

monadless-algebird

SBT configuration:

libraryDependencies += "io.monadless" %% "monadless-algebird" % "0.0.13"

Usage:

// for `com.twitter.algebird.Applicative`
// note: doesn't support `try`/`catch`/`finally`
val myApplicativeMonadless = io.monadless.algebird.MonadlessApplicative[MyApplicative]()
import myApplicativeMonadless._

// for `com.twitter.algebird.Monad`
// note: doesn't support `try`/`catch`/`finally`
val myMonadMonadless = io.monadless.algebird.MonadlessMonad[MyMonad]()
import monadless._

Twitter monads

SBT configuration:

libraryDependencies += "io.monadless" %% "monadless-core" % "0.0.13"

The default method resolution uses the naming conventions adopted by Twitter, so it's possible to use the default Monadless for them:

val futureMonadless = io.monadless.Monadless[com.twitter.util.Future]()
import futureMonadless._

val tryMonadless = io.monadless.Monadless[com.twitter.util.Try]()
import tryMonadless

Other monads

SBT configuration:

// scala
libraryDependencies += "io.monadless" %% "monadless-core" % "0.0.13"

// scala.js
libraryDependencies += "io.monadless" %% "monadless-core" % "0.0.13"

See "How does it work?" for information on how to define a Monadless instance for other monads.

Supported constructs

vals:

lift {
  val i = unlift(a)
  i + 1
}

nested blocks of code:

lift {
  val i = {
     val j = unlift(a)
     j * 3
  }
  i + 1
}

val pattern matching:

lift {
  val (i, j) = (unlift(a), unlift(b))
}

if conditions:

lift {
  if(unlift(a) == 1) unlift(c)
  else 0
}

boolean operations (including short-circuiting):

lift {
  unlift(a) == 1 || (unlift(b) == 2 && unlift(c) == 3)
}

def:

lift {
  def m(j: Int) = unlift(a) + j
  m(unlift(b))
}

recursive defs:

lift {
  def m(j: Int) = if(j == 0) unlift(a) else m(j - 1)
  m(10)
}

traits, classes, and objects:

lift {
  trait A {
    def i = unlift(a)
  }
  class B extends A {
    def j = i + 1
  }
  object C {
    val k = unlift(c)
  }
  (new B).j + C.k
}

pattern matching:

lift {
  unlift(a) match {
    case 1 => unlift(b)
    case _ => unlift(c)
  }
}

try/catch/finally:

lift {
  try unlift(a)
  catch {
    case e => unlift(b)
  } finally {
    println("done")
  }
}

while loops:

lift {
  var i = 0
  while(i < 10)
    i += unlift(a)
}

The UnsupportedSpec lists the constructs that are known to be unsupported. Please report if you find a construct that can't be translated and is not classified by the spec class.

How does it work?

The unlift method is only a marker that indicates that the lift macro transformation needs to treat a value as monad instance. For example, it never blocks threads using Await.result if it's dealing with a Future.

The code generated by the macro uses an approach similar to for-comprehensions, resolving at compile time the methods that are required for the composition and not requiring a particular monad interface. We call these "ghost" methods: they aren't defined by an interface and only need to be source-compatible with the generated macro tree. To elucidate, let's take map as an example:

// Option `map` signature
def map[B](f: A => B): Option[B]

// Future `map` signature
def map[B](f: A => B)(implicit ec: ExecutionContext)

Future and Option are supported by for-comprehensions and lift even though they don't share the same method signature since Future requires an ExecutionContext. They are only required to be source-compatible with the transformed tree. Example lift transformation:

def a: Future[Int] = ???

// this transformation
lift {
  unlift(a) + 1
}

// generates the tree
a.map(_ + 1)

// that triggers scala's implicit resolution after the
// macro transformation and becomes:
a.map(_ + 1)(theExecutionContext)

For-comprehensions use only two "ghost" methods: map and flatMap. To support more Scala constructs, Monadless requires additional methods. This is the definition of the "ghost" interface that Monadless expects:

trait M[A] {
  
  // Applies the map function
  def map[B](f: A => B): M[B]

  // Applies `f` and then flattens the result
  def flatMap[B](f: A => M[B]): M[B]
  
  // Recovers from a failure if the partial function 
  // is defined for the failure. Used to translate `catch` clauses.
  def rescue(pf: PartialFunction[Throwable, M[A]]): M[A]

  // Executes `f` regarless of the outcome (success/failure).
  // Used to translate `finally` clauses.
  def ensure(f: => Unit): M[A]
}

object M {

  // Creates a monad instance with the result of `f`
  def apply[A](f: => T): M[A]

  // Transforms multiple monad instances into one.
  def collect[A](l: List[M[A]]): M[List[A]]
}

As an alternative to using the monad type methods directly since not all existing monads implement them, Monadless allows the user to define them separately:

object CustomMonadless extends Monadless[M] {

  // these are also "ghost" methods
  def apply[A](f: => A): M[A] = ???
  def collect[A](l: List[M[A]]): M[List[A]] = ???
  def map[A, B](m: M[A])(f: A => B): M[B] = ???
  def flatMap[A, B](m: M[A])(f: A => M[B]): M[B] = ???
  def rescue[A](m: M[A])(pf: PartialFunction[Throwable, M[A]]): M[A] = ??
  def ensure[A](m: M[A])(f: => Unit): M[A] = ???
}

The methods defined by the Monadless instance have precedence over the ones specified by the monad instance and its companion object

Related projects

Code of Conduct

Please note that this project is released with a Contributor Code of Conduct. By participating in this project, you agree to abide by its terms. See CODE_OF_CONDUCT.md for details.

License

See the LICENSE file for details.

Maintainers

  • @fwbrasil
  • @sameerparekh

About

Syntactic sugar for monad composition in Scala

Resources

License

Code of conduct

Stars

Watchers

Forks

Packages

No packages published

Languages

  • Scala 98.3%
  • Shell 1.7%