Arrow is a library that adds types that make it easier to implementation functional patterns.
In this exercise we will introduce some of the core Arrow types and we will look at error handling the functional way.
sealed class Either<out A, out B>
Per the definition; either is a wrapper around 2 types. By convention the left side holds a possible exception and the right side the wrapped value.
For instance:
fun divide(v: Int, d: Int): Either<ArithmeticException, Double> =
if (d == 0) Either.Left(ArithmeticException("Divide by zero"))
else Either.Right(v.toDouble() / d)
In the above code, we have a method that has a fully defined path of execution without a possible side effect where an exception creates a short-cut out of the main flow.
So, for a small exercise using Either
we can create some methods that will
showcase exception handling.
Firstly we need some way of converting a currency into another currency. For that we can use a conversion map like this one:
val conversionMap = mapOf<Pair<String,String>,Double>(
("£" to "$") to 1.25,
("€" to "$") to 1.07,
("£" to "€") to 0.83
)
So using the above table, let's create a convertPrice
method in a couple
of steps and setup exception handling in the process.
We will build the logic in a few basic functions, later we will introduce the exception handling logic
Create the base conversion function first:
/**
* This should throw an exception if there is no conversion rate available
* for the used currency
*/
fun convertPrice(price: Price, currency: String): Price = TODO()
Also grab the function we created to format the price in [exercise 2] (Exercise-2.md), and rewrite it a little:
fun formatPrice(price: Price): String = TODO() // Copy the logic
Lastly we will introduce a price parser method, that can be used to build a
Price
from a string. This function will allow us to investigate a flow where
there are multiple possible exceptions that can be thrown.
fun parsePrice(from: String): Price = TODO()
Calling these in order might look like so:
val price = parsePrice("1.11€")
.let { convertPrice(it, "$") }
.let { formatPrice(it) }
println("Parsed, converted price: $price")
This chain of calls can bomb out on any exception, and do try that out!
Now we are going to change the above functions to handle the exceptions so that it is clear it can be passed out of them.
fun formatPrice(price: Price): Either<IllegalArgumentException,String> = TODO()
fun parsePrice(from: String): Either<ParseException,Price> = TODO()
fun convertPrice(price: Price, currency: String):
Either<IllegalArgumentException,Price> = TODO()
So where these methods would have thrown an exception, now it should return
the exception wrapped using Either.Left(...)
.
And for a normal return value wrap it in Either.Right(...)
.
These are specific classes that we can use the Kotlin type-matching using a
when
statement, ie:
when(e) {
is Either.Right -> TODO() // Success
is Either.Left -> TODO() // Failure
}
The chaining of the methods can now be done using the standard Either
methods flatMap
and map
:
val price = parsePrice("1.11€")
.flatMap { convertPrice(it, "$") }
.flatMap { formatPrice(it) }
println("Parsed, converted price: $price")
And as a last step, we could output a message depending on the type with type-matching (continuing from above)
when(price) {
is Either.Right -> println("Parsed, converted price: ${price.value}")
is Either.Left -> println("Failed to parse price from: $priceString. Failure: ${price.left()}") // Failure
}
sealed class Validated<out E, out A>
Validated
works very similar to Either
but the difference is that in general
type Validated
is used to accumulate errors, while Either
is used to
short-circuit a computation upon the first error.
We can take the above created method and replace Either
with Validated
as a small exercise.
sealed class Eval<out A>
Eval is a monad which controls evaluation of a value or a computation that produces a value.
Three basic evaluation strategies:
- Now: evaluated immediately
- Later: evaluated once when value is needed
- Always: evaluated every time value is needed
The Later
and Always
are both lazy strategies while Now
is eager. Later
and Always
are distinguished from each other only by memoization: once
evaluated Later
will save the value to be returned immediately if it is
needed again. Always
will run its computation every time.
It is not generally good style to pattern-match on Eval instances. Rather, use
.map
and .flatMap
to chain computation, and use .value
to get the result
when needed. It is also not good style to create Eval
instances whose
computation involves calling .value
on another Eval
instance – this can
defeat the trampolining and lead to stack overflows.
To get a feel for reasons for using Eval
and delayed execution, let's
create a long-running function that will eventually cause a StackOverflowError.
Here is a naive implementation to determine if a number is odd or even:
/**
* Returns true if the given value is even
*/
fun even(v: Int): Boolean =
if (v == 0) true
else odd(v - 1)
/**
* Returns true if the given value is odd
*/
fun odd(v: Int): Boolean =
if (v == 0) false
else even(v - 1)
fun main() {
// Blow it up
println(odd(100_000_001))
}
So, let's rewrite above code using Eval
, and here's a start:
fun even(v: Int): Eval(Boolean) =
Eval.always { v == 0 }.flatMap {
TODO()
}
fun main() {
// Smooth like butter
println(odd(100_000_001).value())
}
interface Effect<R, A>
Effect
is a suspend
function that encapsulates the resulting value A
and
an exceptional result R
.
To write an Effect
we will use the constructor method effect
to
encapsulate the logic.
The DSL for building effect also has function shift
for shifting out of
the normal flow and returning the exceptional result.
It also contains validator functions like ensure(condition) { .. }
that, in
case the condition is not satisfied, will shift the result from the lambda.
To have a look at the way that Effect
can be used, we create a small
function that reads a file.
Take the following code, and let's build on it:
object InvalidPath
fun readFile(path: String): Effect<InvalidPath,Unit> = effect {
if (path.isBlank()) shift(InvalidPath)
else Unit
}
Now we can improve on this by using some ensure
methods.
fun readFile(path: String): Effect<InvalidPath,Unit> = effect {
ensure(path.isNotBlank()) { InvalidPath }
Unit
}
You could also change the function to accept a nullable value path: String?
and add another ensure
to make sure that the value is actually not null.
Next step is to actually read the content of a file. Reading a file can cause other exceptional circumstances, so we can model those other situations like so:
sealed class FileError {
object InvalidPath: FileError()
object FileNotFound: FileError()
object SecurityError: FileError()
}
And we have to ensure we can capture the file content. For that we use a simple class that encapuslates the content as a list of strings:
class Content(val body: List<String>)
So continuing from the readFile
function we should expand it with actual
File(path).readLines()
capability:
fun readFile(path: String): Effect<FileError,Content> = effect {
ensure(path.isNotBlank()) { FileError.InvalidPath }
try {
File(path).readLines()
} catch (e: FileNotFoundException) {
TODO()
}
}
And to invoke this method, and you should try that for valid and invalid
paths, we can for instance take the resulting effect and convert it to a
Either
like so (note that main is now also a suspend
function):
suspend fun main() {
val result = readFile("gradle.properties").toEither()
when(result) {
TODO()
}
}