Coeval
For the latest version: see here!
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 val
and by-name parameters - doesn’t trigger the execution, or any effects until
value
orrun
- 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 val
cannot be expressed by developers as a type, you cannot take alazy val
parameter or return alazy val
from 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
@tailrec
as 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.Try
type is overlapping in scope, given theCoeval
focus 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!
Attempt, 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 Attempt
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.{Attempt, Now, Error}
val coeval1 = Coeval(1 + 1)
val result1: Attempt[Int] = coeval1.runAttempt
// result1 = Now(2)
val coeval2 = Coeval.raiseError[Int](new RuntimeException("Hello!"))
val result2: Attempt[Int] = coeval2.runAttempt
// result = Error(java.lang.RuntimeException: Hello!)
Hence the Coeval
type, or more precisely Coeval.Attempt
, 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 Attempt
and Try
.
Convert any Coeval into a Task #
For converting any Coeval
into a Task:
val coeval = Coeval.eval(1 + 1)
val task = coeval.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
value
multiple 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 zipMap2
,
zipMap3
… zipMap6
:
Coeval.zipMap3(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
runAttempt
:
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]] =
coeval.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 = coeval.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)