Syntactic sugar for monad composition in Scala
APACHE-2.0 License
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:
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._
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: => A): 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
Future
s)Monad
s)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.