Generalized Effect Composition
Otherwise known as "less confusing monad transformers".
Warning The monad produced by Emm
is not guaranteed to be a lawful monad! In fact, it isn't even guaranteed to be a lawful applicative. You can see an example of a violation of the applicative laws here. (much thanks to @TomasMikula!) I'm leaving this repository up for pedagogical reasons; it's still an interesting exploration of Scala's type system. But I do not recommend you use it for real work, given that the monad is not lawful.
The Emm
monad provides a syntactically lightweight, type-inference friendly data type for composing effects. The general motivation is very similar to monad transformers, but the end result is far more user friendly and also significantly more general. The main goals of the project are as follows:
- Simple and easy to understand
- Clean type inference
- Clean type errors (dear god, monad transformer compile errors...)
- Compatibility with pre-existing monads
These goals are very similar to those which motivated Oleg's Eff
, which is a really terrific data structure. There are some significant differences though. Most notably, Eff
requires effect implementations to be rewritten to be compatible with its internal calculus, and so it does not allow the composition of arbitrary "standalone" monads written in a conventional style. However, Eff
is able to provide much greater expressive power than Emm
(or monad transformers) in several key diminsions. Oleg goes into significant detail on the expressiveness gains of Eff
in his paper describing the construct. Emm
does not provide the same benefits.
SBT Setup
If you want to use Emm
in your project, adding the following SBT configuration will do the trick:
libraryDependencies += "com.codecommit" %% "emm-core" % EmmVersion
You will also need to bring in the appropriate upstream framework support for either Scalaz or Cats, depending on which one you're using.
libraryDependencies += "com.codecommit" %% "emm-scalaz-71" % EmmVersion // for scalaz 7.1
// or!
libraryDependencies += "com.codecommit" %% "emm-scalaz-72" % EmmVersion // for scalaz 7.2
// or!
libraryDependencies += "com.codecommit" %% "emm-cats" % EmmVersion // for cats 0.4.1
You will want to use either emm-scalaz
or emm-cats
. While there is no technical reason you would not be able to use both in the same project, doing so would be… weird. At present, Cats support is slightly more complete than Scalaz, but we aim to reach parity soon.
The most recent stable version of Emm is 0.2.1.
val EmmVersion = "0.2.1"
Snapshot builds are often published as versions derived from the git hash. For example, 0.2-a21c63a
. The version prefix indicates compatibility with a particular version line, not derivation or antecedence. Not all git hashes are published, but some are. When in doubt, try a few. Or just ask for one to be published. All artifacts are signed with public key fingerprint 2BAE 5960.
Example
import emm._
import emm.compat.scalaz._
import scalaz.concurrent.Task
import scalaz.std.option._
def readName: Task[String] = ???
def log(msg: String): Task[Unit] = ???
type E = Task |: Option |: Base
val effect: Emm[E, String] = for {
first <- readName.liftM[E]
last <- readName.liftM[E]
name <- (if ((first.length * last.length) < 20) Some(s"$first $last") else None).liftM[E]
_ <- log(s"successfully read in $name").liftM[E]
} yield name
The above is analogous to monad transformers in many ways. In fact, we can write the exact same code from above using OptionT
:
import scalaz._
import scalaz.concurrent.Task
import scalaz.syntax.monad._
def readName: Task[String] = ???
def log(msg: String): Task[Unit] = ???
val effect: OptionT[Task, String] = for {
first <- readName.liftM[OptionT]
last <- readName.liftM[OptionT]
name <- (if ((first.length * last.length) < 20) OptionT.some[Task, String](s"$first $last") else OptionT.none[Task, String])
_ <- log(s"successfully read in $name").liftM[OptionT]
} yield name
The advantages of Emm
become much more apparent when attempting to stack more than just two monads simultaneously. For example, one might imagine stacking Task
, Option
and right-biased Either
. Let's enrich our previous example with some error handling (note that I'm using kind projector to avoid type lambdas):
import emm._
import emm.compat.scalaz._
import scalaz._
import scalaz.concurrent.Task
import scalaz.std.option._
def readName: Task[String] = ???
def log(msg: String): Task[Unit] = ???
type E = Task |: (String \/ ?) |: Option |: Base
val effect: Emm[E, String] = for {
first <- readName.liftM[E]
last <- readName.liftM[E]
name <- (if ((first.length * last.length) < 20) Some(s"$first $last") else None).liftM[E]
_ <- (if (name == "Daniel Spiewak") -\/("your kind isn't welcome here") else \/-(())).liftM[E]
_ <- log(s"successfully read in $name").liftM[E]
} yield name
It works as expected, with all the same syntax as before. However, if we look at the same example using monad transformers, a rather distopian picture emerges:
import scalaz._
import scalaz.concurrent.Task
import scalaz.syntax.monad._
def readName: Task[String] = ???
def log(msg: String): Task[Unit] = ???
val effect: OptionT[EitherT[Task, String, ?], String] = for {
first <- readName.liftM[EitherT[?[_], String, ?]].liftM[OptionT]
last <- readName.liftM[(EitherT[?[_], String, ?]].liftM[OptionT]
name <- if ((first.length * last.length) < 20)
OptionT.some[EitherT[Task, String, ?], String](s"$first $last")
else
OptionT.none[EitherT[Task, String, ?], String]
_ <- (if (name == "Daniel Spiewak")
EitherT.fromDisjunction[Task](\/.left[String, Unit]("your kind isn't welcome here"))
else
EitherT.fromDisjunction[Task](\/.right[String, Unit](()))).liftM[OptionT]
_ <- log(s"successfully read in $name").liftM[EitherT[?[_], String, ?]].liftM[OptionT]
} yield name
That's a lot of very explicit lifting and special syntax. I had to ponder quite long and hard about the above, and I'm not even sure if I got it all right! Monad transformers are very ugly, very cumbersome, and when you get things wrong they explode in remarkably spectacular ways.
The Emm
monad is intended to change all of that. It is intended to be very straightforward to manage and extend complex stacks of effects, and to do so without any special wrappers or added complexity from the effect author. No need to write an OptionT
, just use Option
!
API
The following API is provided. For starters, the following pair of functions are implicitly provided to lift values into the effect stack:
pointM[C <: Effects]
– Points a value of typeA
into the monad,Emm[C, A]
. Requires anApplicative
for each component of the effect stackC
.liftM[C <: Effects]
– Given an effect which is of a type contained withinC
, lift the effect into the full effect stack represented byC
. For example:Option(42).liftM[Task |: Option |: Base]
wrapM[C <: Effects]
– Given a full stack of effects which matches the stackC
, wrap the stack in theEmm
monad. Note that theC
parameter can be inferred basically 100% of the time, but can be provided explicitly to assert correctness. Example:(Task now Option(42)).wrapM
. This is equivalent to calling theEmm(...)
constructor, but the type inference is much nicer.
These methods are exposed via implicit classes contained within the emm
package object. All of the above methods are aliased on the Emm
object as point
, lift
and wrap
, respectively. You'll notice, however, that they do require a bit of extra type annotation since the target type and the effect stack are in the same type block, rather than separate ones (as in the case of implicitly provided members). Thus, you should generally prefer the "M
versions" of each method wherever possible (i.e. when not importing scalaz.syntax.monad._
).
The Emm
monad itself provides the following (effective) API:
map[B](A => B): Emm[C, B]
– Conventional functor map. Transforms the value within the effectflatMap[B](A => Emm[C, B]): Emm[C, B]
– Monadic bind. Transforms the value within the effect and joins the two effect stacks. This function requires that all components ofC
define abind
function, and all components aside from the outer-most (left-most) must have aTraverse
instance.flatMapM[G[_], B](A => G[B]): Emm[C, B]
– Similar toflatMap
, except instead of transforming the value to an effect contained within the entire effect stack,C
, it transforms the value to a single component of that effect stack. Thus,G
must be inC
. The result is joined with the effect stack and returned withinEmm
.expand
– The inverse ofcollapse
. Converts anEmm
of the formEmm[... |: F |: Base, A]
intoEmm[... |: Base, F[A]]
. This is extremely useful when there are effect-specific functions (e.g.Option#getOrElse
) that you need to access on the inner-most (right-most) effect of the stack. Once you have expanded, you can usemap
orflatMap
to access these functions and manipulate the inner-most effect. Runs in constant time.collapse
– The inverse ofexpand
. Converts anEmm
of the formEmm[... |: Base, F[A]]
intoEmm[... |: F |: Base, A]
. This is generally most useful in conjunction withexpand
, where you have manipulated the inner-most effect and you need to "recombine" the results of that manipulation with the full effect stack. Runs in constant time.run
– Unwraps the effect stack (without modification) fromEmm
. Effectively, this takes a type of the formEmm[F |: G |: Base, A]
and produces a type of the formF[G[A]]
. Literally, it is the "contents" ofEmm
.
Requirements
Right now, this is sitting on top of the shims 0.2 typeclass hierarchy, which is to say that it supports Cats 0.3, Scalaz 7.2 and 7.1. Everything is implemented in terms of the following type classes (with minimal constraints for every function):
Applicative
FlatMap
Functor
Traverse
Invalid and Partially-Valid Stacks
Constraints which are not required to evaluate a given function are not assumed. For example, consider the following effect stack:
type E = Option |: Task |: Base
val effect = Option(42).liftM[E]
If you attempt to run flatMap
on this effect stack, you will run into problems:
effect flatMapM { i => if (i < 20) None else Some(i * 2) } // does not compile!
This will fail because Task
is not the outer-most effect, which is to say, it isn't the effect on the far left of the effect definition. The reason this is a problem becomes more clear if we look at things in terms of map
, flatten
and the raw stack, rather than simply flatMap
and the collapsed Emm
monad:
val effect2: Option[Task[Int]] = Some(Task now 42)
val mapped: Option[Task[Option[Task[Int]]]] = effect2 map { t =>
t map { i =>
if (i < 20) None else Some(Task now (i * 2))
}
}
val result: Option[Task[Int]] = mapped.flatten // ??????
Notice the problem here. We need to take the second Option
layer, which is within a Task
, and "flip" it outside of the Task
layer in order to flatten the Option
and Task
layers together. Basically, we want to do something like this:
Option[Task[Option[Task[Int]]]] => Option[Option[Task[Task[Int]]]] => Option[Task[Task[Int]]] => Option[Task[Int]]
Clearly, there are no problems with the last two stages, but that second stage is completely impossible. We can't take a value from inside Task
and "flip" it to the outside. Task
is basically a Future
, so the value "inside" of Task
doesn't even exist yet! So this effect stack is non-sensical as a monad; we cannot define flatMap
(or equivalently, flatMapM
) on it, and the compiler is very happy to tell us so.
Technically, the reason we can't do this is because there is no instance Traverse[Task]
, and in fact you cannot define such an instance without actually running the Task
. Our example from earlier though, where our stack was Task |: Option |: Base
was just fine, because there is an instance Traverse[Option]
.
Here's the cool bit though. Even though it doesn't make any sense to define flatMap
on Emm[Option |: Task |: Base, Int]
, there's no reason why we can't define map
!
type E = Option |: Task |: Base
val effect = Option(42).liftM[E]
val effect2 = effect map { _ * 2 } // no problemo!
Even though our effect stack is sort of bogus, it's only bogus if we attempt to treat it as a monad. It's a perfectly valid applicative functor, and we can treat it as such. In other words, flatMap
doesn't work (and shouldn't work!) on some effect stacks, but map
works on any effect stack where each component effect has a Functor
.
Limitations
Maybe this section should be nearer to the top... Oh well.
The most significant limitation of this approach is caused by everyone's favorite limitation of the scalac type checker, SI-2712. The good news is that this bug is not a complete show stopper; it's relatively easy to work around when you control the entire stack of type signatures (as I do here) and you're not trying to generalize over different type constructor arities. The bad news is that it makes my life very difficult, and it imposes some pretty hard limits (also related to how much boilerplate I'm willing to type out) on what sorts of type constructors do and do not work with Emm
.
Specifically, the following kinds of type constructors are accepted (i.e. will be fully functional in any position of an effect stack):
* -> *
– Examples:Option
,List
,Task
* x * -> *
– Examples:Either
,State
(with caveats),Writer
(more caveats),Reader
(sorry, still caveated)* x * x * -> *
– Examples: No idea(* -> *) x * -> *
– Examples:Free
,OptionT
,ListT
,StreamT
(* -> *) x * x * -> *
– Examples: uh...(* -> *) x * x * x * -> *
– Examples:IndexedStateT
(sort of)