Syntactic sugar for monad composition (or: "async/await" generalized)
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
).
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
.
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:
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._
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._
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._
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._
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
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.
val
s:
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 def
s:
lift {
def m(j: Int) = if(j == 0) unlift(a) else m(j - 1)
m(10)
}
trait
s, class
es, and object
s:
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.
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
- scala-async (for scala
Future
s) - effectful (for scalaz
Monad
s) - each (also for scalaz
Monad
s)
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.
See the LICENSE file for details.
- @fwbrasil
- @sameerparekh