Coeval
Older versions: 2.x
Introduction #
Coeval is a data type for controlling synchronous, possibly lazy evaluation, useful for describing lazy expressions and for controlling side-effects. It is the sidekick of Task, being meant for computations that are guaranteed to execute immediately (synchronously).
Vocabulary definition:
1) Having the same age or date of origin; contemporary
2) Something of the same era
3) Synchronous
Yes, the name was chosen because it is sort of a synonym for synchronous, though it must be admitted it’s also because of a fascination of FP developers for co-things ♥◡♥
Sample:
import monix.eval.Coeval
val coeval = Coeval {
println("Effect!")
"Hello!"
}
// Coeval has lazy behavior, so nothing
// happens until being evaluated:
coeval.value
//=> Effect!
// res1: String = Hello!
// And we can handle errors explicitly:
import scala.util.{Success, Failure}
coeval.runTry match {
case Success(value) =>
println(value)
case Failure(ex) =>
System.err.println(ex)
}
Design Summary #
In summary the Monix Coeval:
- resembles Task, but works only for immediate, synchronous evaluation
- can be a replacement for
lazy valand by-name parameters - doesn’t trigger the execution, or any effects until
valueorrun - allows for controlling of side-effects
- handles errors
A visual representation of where Coeval sits in the design space:
| Eager | Lazy | |
|---|---|---|
| Synchronous | A | () => A |
| Coeval[A] | ||
| Asynchronous | (A => Unit) => Unit | (A => Unit) => Unit |
| Future[A] | Task[A] |
So what problems are we solving?
lazy valcannot be expressed by developers as a type, you cannot take alazy valparameter or return alazy valfrom a function- ditto for by-name parameters, being just syntactic sugar that the compiler understand, but a proper type isn’t properly exposed by Scala
- Scala has
@tailrecas a compiler workaround to the JVM not supporting tail-calls elimination, but it does not work for mutually tail recursive calls and thus limited - The
scala.util.Trytype is overlapping in scope, given theCoevalfocus on error handling, but doesn’t have lazy behavior
Coeval can replace both lazy val and by-name parameters, allowing
one to control evaluation and to do error handling. It’s also stack
safe and with it you can describe mutually tail-recursive algorithms,
which are incredibly important in FP.
Comparison with Cats Eval #
The whole Monix library stands on the shoulders of giants and Coeval
is definitely inspired by the Eval data-type in
Cats, hence credit should be given where
credit is due.
The Cats Eval is a very light type that’s concerned just with
controlling evaluation. It’s more limited and that’s not a bad
thing. People using Eval are not using it as a replacement for an
I/O Monad.
But the Monix Coeval works as a side-kick to Task, being for those
instances where you don’t want the asynchronous nature of Task. This
means that Coeval scales from delaying simple arithmetic operations
up to controlling side-effects, and if you want, it can also function
as a replacement for an I/O Monad. And because it’s the legitimate
sibling of Task, conversion back and forth is smooth
(within limits).
Or in more concrete terms, at the moment of writing this, the Monix
Coeval takes care of error handling, while the Cats Eval does not,
providing operations for recovery, thus also working well as a
replacement for Scala’s Try type.
Evaluation #
To evaluate a Coeval instance you can invoke its value command:
val coeval = Coeval {
println("Effect!")
1 + 1
}
// Nothing happens until this point:
coeval.value
//=> Effect!
// res: Int = 2
But value might trigger exceptions, if somewhere in the evaluation
chain exceptions have happened. Instead of value we can expose
errors by means of runTry:
import scala.util.{Failure, Success}
val coeval = Coeval[Int] {
throw new RuntimeException("Hello!")
}
coeval.runTry match {
case Success(value) =>
println(s"Success: $value")
case Failure(ex) =>
println(s"Error: $ex")
}
// Will print:
//=> Error: java.lang.RuntimeException: Hello!
Eager, the replacement for scala.util.Try #
The runTry method returns a scala.util.Try, but if you looked at
the source code, the implementation of Coeval uses two states called
Now(value) and Error(ex), inheriting from Coeval and that are
perfect equivalents for the scala.util.Try states called Success
and Failure. And in fact an Eager sub-type of Coeval is
exposed as an ADT that you can use instead of scala.util.Try:
import monix.eval.Coeval
import monix.eval.Coeval.{Eager, Now, Error}
val coeval1 = Coeval(1 + 1)
val result1: Eager[Int] = coeval1.run
// result1 = Now(2)
val coeval2 = Coeval.raiseError[Int](new RuntimeException("Hello!"))
val result2: Eager[Int] = coeval2.run
// result = Error(java.lang.RuntimeException: Hello!)
Hence the Coeval type, or more precisely Coeval.Eager, can work
as a replacement for scala.util.Try, although note that even if the
values boxed by Now and Error are already evaluated, when invoking
operators on them, like flatMap, the behavior is still lazy, which
is the main difference between Eager and Try.
Convert any Coeval into a Task #
For converting any Coeval into a Task:
import monix.eval.Task
val coeval = Coeval.eval(1 + 1)
val task = coeval.to[Task]
// task: Task[Int] = Always(<function0>)
Task and Coeval being siblings, they have similar internal states
and conversion from a Coeval into a Task is direct and efficient.
Builders #
Coeval can replace functions accepting zero arguments, Scala by-name
params, lazy val or scala.util.Try. Here’s how you can build
instances:
Coeval.now #
Coeval.now lifts an already known value in the Coeval context,
the equivalent of Applicative.pure:
import monix.eval.Coeval
val coeval = Coeval.now { println("Effect"); "Hello!" }
//=> Effect
// coeval: monix.eval.Coeval[String] = Now(Hello!)
Coeval.eval #
Coeval.eval is the equivalent of Function0, taking a
function that will always be evaluated on invocation of value:
val coeval = Coeval.eval { println("Effect"); "Hello!" }
// coeval: monix.eval.Coeval[String] = Once(<function0>)
coeval.value
//=> Effect
//=> Hello!
// The evaluation (and thus all contained side effects)
// gets triggered every time
coeval.value
//=> Effect
//=> Hello!
Coeval.evalOnce #
Coeval.evalOnce is the equivalent of a lazy val, a type that cannot
be precisely expressed in Scala. The evalOnce builder does
memoization on the first run, such that the result of the evaluation
will be available for subsequent runs. It also has guaranteed
idempotency and thread-safety:
val coeval = Coeval.evalOnce { println("Effect"); "Hello!" }
// coeval: monix.eval.Coeval[String] = Once(<function0>)
coeval.value
//=> Effect
//=> Hello!
// Result was memoized on the first run!
coeval.value
//=> Hello!
Coeval.defer #
Coeval.defer is about building a factory of coevals. For example
this will behave approximately like Coeval.eval:
val coeval = Coeval.defer {
Coeval.now { println("Effect"); "Hello!" }
}
// coeval: monix.eval.Coeval[String] = Suspend(<function0>)
coeval.value
//=> Effect
//=> Hello!
coeval.value
//=> Effect
//=> Hello!
Coeval.raiseError #
Coeval.raiseError can lift errors in the monadic context of Coeval:
val error = Coeval.raiseError[Int](new IllegalStateException)
// error: monix.eval.Coeval[Int] =
// Error(java.util.concurrent.TimeoutException)
error.runTry
//=> Failure(java.lang.IllegalStateException)
Coeval.unit #
Coeval.unit is returning an already completed Coeval[Unit] instance,
provided as an utility, to spare you creating new instances with
Coeval.now(()):
val coeval = Coeval.unit
// coeval: monix.eval.Coeval[Unit] = Now(())
This instance is shared, so that can relieve some stress from the garbage collector.
Memoization #
The
Coeval#memoize
operator can take any Coeval and apply memoization on the first evaluation
(such as value, runTry) such that:
- you have guaranteed idempotency, calling
valuemultiple times will have the same effect as calling it once - subsequent evaluations will reuse the result computed by the first evaluation
So memoize effectively caches the result of the first value or
runTry call. In fact we can say that:
Coeval.evalOnce(f) <-> Coeval.eval(f).memoize
They are effectively the same. And at the moment of writing, the
implementation of memoize actually pattern matches on the source to
see if we are dealing with an Always transforming it into an
Once. You shouldn’t rely on this behavior, but this gives you an
idea of the properties involved: for the layman, you can say that
memoize turns your Coeval into a lazy val.
And memoize works with any coeval reference:
import monix.eval.Coeval
// Has async execution, to do the .apply semantics
val coeval = Coeval { println("Effect"); "Hello!" }
val memoized = coeval.memoize
memoized.value
//=> Effect
//=> Hello!
memoized.value
//=> Hello!
Operations #
FlatMap and Tail-Recursive Loops #
So lets start with a stupid example that calculates the N-th number in the Fibonacci sequence:
import scala.annotation.tailrec
@tailrec
def fib(cycles: Int, a: BigInt, b: BigInt): BigInt = {
if (cycles > 0)
fib(cycles-1, b, a + b)
else
b
}
We need this to be tail-recursive, hence the use of the
@tailrec
annotation from Scala’s standard library. And if we’d describe it with
Coeval, one possible implementation would be:
def fib(cycles: Int, a: BigInt, b: BigInt): Coeval[BigInt] = {
if (cycles > 0)
Coeval.defer(fib(cycles-1, b, a+b))
else
Coeval.now(b)
}
And now there are already differences. This is lazy, as the N-th
Fibonacci number won’t get calculated until we evaluate it. The
@tailrec annotation is also not needed, as this is stack (and heap)
safe.
Coeval has flatMap, which is the monadic bind operation, that
for things like Coeval, Task or Future is the operation that
describes recursivity or that forces ordering (e.g. execute this, then
that, then that). And we can use it to describe recursive calls:
def fib(cycles: Int, a: BigInt, b: BigInt): Coeval[BigInt] =
Coeval.eval(cycles > 0).flatMap {
case true =>
fib(cycles-1, b, a+b)
case false =>
Coeval.now(b)
}
Again, this is stack safe and uses a constant amount of memory, so no
@tailrec annotation is needed or wanted. And it has lazy behavior,
as nothing will get triggered until evaluation happens.
But we can also have mutually tail-recursive calls, w00t!
// Mutual Tail Recursion, ftw!!!
{
def odd(n: Int): Coeval[Boolean] =
Coeval.eval(n == 0).flatMap {
case true => Coeval.now(false)
case false => even(n - 1)
}
def even(n: Int): Coeval[Boolean] =
Coeval.eval(n == 0).flatMap {
case true => Coeval.now(true)
case false => odd(n - 1)
}
even(1000000)
}
Again, this is stack safe and uses a constant amount of memory.
The Applicative: zip2, zip3, … zip6 #
When using flatMap, we often end up with this:
val locationTask: Coeval[String] = Coeval.eval(???)
val phoneTask: Coeval[String] = Coeval.eval(???)
val addressTask: Coeval[String] = Coeval.eval(???)
// Ordered operations based on flatMap ...
val aggregate = for {
location <- locationTask
phone <- phoneTask
address <- addressTask
} yield {
"Gotcha!"
}
This gets transformed by the compiler into a batch of flatMap calls.
But Coeval is also an Applicative and hence it has utilities, such
as zip2, zip3, up until zip6 (at the moment of writing) and also
zipList. The example above could be written as:
val locationCoeval: Coeval[String] = Coeval.eval(???)
val phoneCoeval: Coeval[String] = Coeval.eval(???)
val addressCoeval: Coeval[String] = Coeval.eval(???)
val aggregate =
Coeval.zip3(locationCoeval, phoneCoeval, addressCoeval).map {
case (location, phone, address) => "Gotcha!"
}
In order to avoid boxing into tuples, you can also use map2,
map3 … map6:
Coeval.map3(locationCoeval, phoneCoeval, addressCoeval) {
(location, phone, address) => "Gotcha!"
}
Gather results from a Seq of Coevals #
Coeval.sequence, takes a Seq[Coeval[A]] and returns a
Coeval[Seq[A]], thus transforming any sequence of coevals into a
coeval with a sequence of results.
val ca = Coeval(1)
val cb = Coeval(2)
val list: Coeval[Seq[Int]] =
Coeval.sequence(Seq(ca, cb))
list.value
//=> List(1, 2)
The results are ordered in the order of the initial sequence.
Restart Until Predicate is True #
The Coeval being a spec, we can restart it at will. And
restartUntil(predicate) does that, executing the source over and
over again, until the given predicate is true:
import scala.util.Random
val randomEven = {
Coeval.eval(Random.nextInt())
.restartUntil(_ % 2 == 0)
}
randomEven.value
//=> -2097793116
randomEven.value
//=> 1246761488
randomEven.value
//=> 1053678416
Clean-up Resources on Finish #
Coeval.doOnFinish executes the supplied
Option[Throwable] => Coeval[Unit] function when the source finishes,
being meant for cleaning up resources or executing
some scheduled side-effect:
val coeval = Coeval(1)
val withFinishCb = coeval.doOnFinish {
case None =>
println("Was success!")
Coeval.unit
case Some(ex) =>
println(s"Had failure: $ex")
Coeval.unit
}
withFinishCb.value
//=> Was success!
// res: Int = 1
Error Handling #
Coeval does error handling. Being the side-kick of Task means it
gets mostly the same facilities for recovering from error.
First off, even though Monix expects for the arguments given to its
operators, like flatMap, to be pure or at least protected from
errors, it still catches errors, signaling them on runTry or
run:
import monix.eval.Coeval
import scala.util.Random
val coeval = Coeval(Random.nextInt).flatMap {
case even if even % 2 == 0 =>
Coeval.now(even)
case odd =>
throw new IllegalStateException(odd.toString)
}
coeval.runTry()
// res1: Try[Int] = Success(624170708)
coeval.runTry()
// res2: Try[Int] = Failure(IllegalStateException: -814066173)
Recovering from Error #
Coeval.onErrorHandleWith is an operation that takes a function mapping
possible exceptions to a desired fallback outcome, so we could do
this:
import scala.concurrent.duration._
import scala.concurrent.TimeoutException
val source = Coeval.raiseError[String](new IllegalStateException)
val recovered = source.onErrorHandleWith {
case _: IllegalStateException =>
// Oh, we know about illegal states, recover it
Coeval.now("Recovered!")
case other =>
// We have no idea what happened, raise error!
Coeval.raiseError(other)
}
recovered.runTry
// res1: Try[String] = Success(Recovered!)
There’s also Coeval.onErrorRecoverWith that takes a partial function
instead, so we can omit the “other” branch:
val recovered = source.onErrorRecoverWith {
case _: IllegalStateException =>
// Oh, we know about illegal states, recover it
Coeval.now("Recovered!")
}
recovered.runTry
// res: Try[String] = Success(Recovered!)
Coeval.onErrorHandleWith and Coeval.onErrorRecoverWith are the
equivalent of flatMap, only for errors. In case we know or can
evaluate a fallback result eagerly, we could use the shortcut
operation Coeval.onErrorHandle like:
val recovered = source.onErrorHandle {
case _: IllegalStateException =>
// Oh, we know about illegal states, recover it
"Recovered!"
case other =>
throw other // Rethrowing
}
Or the partial function version with onErrorRecover:
val recovered = source.onErrorRecover {
case _: IllegalStateException =>
// Oh, we know about illegal states, recover it
"Recovered!"
}
Restart On Error #
The Coeval type, being just a specification, it can usually restart
whatever process is supposed to deliver the final result and we can
restart the source on error, for how many times are needed:
import scala.util.Random
val source = Coeval(Random.nextInt).flatMap {
case even if even % 2 == 0 =>
Coeval.now(even)
case other =>
Coeval.raiseError(new IllegalStateException(other.toString))
}
// Will retry 4 times for a random even number,
// or fail if the maxRetries is reached!
val randomEven = source.onErrorRestart(maxRetries = 4)
We can also restart with a given predicate:
import scala.util.Random
val source = Coeval(Random.nextInt).flatMap {
case even if even % 2 == 0 =>
Coeval.now(even)
case other =>
Coeval.raiseError(new IllegalStateException(other.toString))
}
// Will keep retrying for as long as the source fails
// with an IllegalStateException
val randomEven = source.onErrorRestartIf {
case _: IllegalStateException => true
case _ => false
}
Expose Errors #
The Coeval monadic context is hiding errors that happen, much like
Scala’s Try or Future. But sometimes we want to expose those
errors such that we can recover more efficiently:
import scala.util.{Try, Success, Failure}
val source = Coeval.raiseError[Int](new IllegalStateException)
val materialized: Coeval[Try[Int]] =
source.materialize
// Now we can flatMap over both success and failure:
val recovered = materialized.flatMap {
case Success(value) => Coeval.now(value)
case Failure(_) => Coeval.now(0)
}
recovered.value
// res: Int = 0
There’s also the reverse of materialize, which is
Coeval.dematerialize:
import scala.util.Try
val source = Coeval.raiseError[Int](new IllegalStateException)
// Exposing errors
val materialized = source.materialize
// materialize: Coeval[Try[Int]] = ???
// Hiding errors again
val dematerialized = materialized.dematerialize
// dematerialized: Coeval[Int] = ???
We can also convert any Coeval into a Coeval[Throwable] that will
expose any errors that happen and will also terminate with an
NoSuchElementException in case the source completes with success:
val source = Coeval.raiseError[Int](new IllegalStateException)
val throwable = source.failed
// throwable: Coeval[Throwable] = ???
throwable.runTry()
// res: Try[Throwable] = Success(java.lang.IllegalStateException)