In this blog we consider how to deal with optional data - absent values that are not the result of an error. This might happen when a function returns null when not expected. A value of null is often used to represent an absent value. We can avoid using null by providing a type for representing optional values: the Option<A> class. The class is included in the custom library Dogs.
Kotlin
treats null in a special way so that
we may work safely with values that might be null. For instance, the null-safe operator for accessing properties
foo?.bar?.baz will not throw an exception if either foo or its bar property
is null, instead directly returning null.
Another approach to this problem is
to provide a type for representing optional values. Values that may be present
or absent are supported by the Option<A> sealed class. Option<A>
is a container for an optional value of type A. If the value of type A is present, then the Option<A>
is an instance of Some<A>
containing the value of type A. If
the value is absent, then the Option<A>
is an instance of None.
You create an Option<String> for a given value by instantiating an instance of the Some class with the some factory function:
val greeting: Option<String> = some("Hello Ken")
If you know the value is absent, you assign the None object through the none factory function:
val goodbye: Option<String> = none()
The
Option sealed class is shown below. It is an algebraic data type. The member function isEmpty returns true for None and false for
instances of Some. The function filter returns the receiver if it is Some and the predicate applied to this value; otherwise, returns None. The function fold returns the result of applying the first function if the option is empty; otherwise return the second function applied to the wrapped value.
object None : Option<Nothing>()
class Some<out A> internal constructor(val value: A) : Option<A>()
abstract fun isEmpty(): Boolean
fun contains(predicate: (A) -> Boolean): Boolean
fun filter(predicate: (A) -> Boolean): Option<A>
fun <B> fold(none: () -> B, some: (A) -> B): B
fun <B> map(f: (A) -> B): Option<B>
fun <B> option(defaultValue: B, f: (A) -> B): B
// ...
} // Option
Pattern matching
Selected Option operations
fun divide(num: Int, den: Int): Option<Int> =
if (num % den != 0) none() else some(num / den)
fun division(a: Int, b: Int, c: Int): Option<Pair<Int, Int>> =
divide(a, c).bind{ac -> divide(b, c).bind{bc -> some(Pair(ac, bc))}}
assertEquals(some(3), divide(12, 4))
assertEquals(none(), divide(12, 5))
assertEquals(some(Pair(2, 3)), division(18, 27, 9))
assertEquals(none(), division(18, 27, 6))
// ...
} // Option
A number of contravariant functions are also defined as extension functions:
fun <A> Option<A>.getOrElse(defaultValue: A): A
The getOrElse function uses a default value in case an optional value is absent:
assertEquals(5, some(5).getOrElse(99))
assertEquals(99, none<Int>().getOrElse(99))
The option member function takes a default value and a function as parameters. If the receiver value is None then the defaultValue is returned. Otherwise, the function is applied to the value wrapped in the Some and returns the result. In the following the default value is zero and the increment is the transformer function.
assertEquals(0, none<Int>().option(0){n -> 1 + n})
assertEquals(5, some(4).option(0){n -> 1 + n})
The generic type parameter B used in the declaration for the option function is a simple Int type in the above example. However, it might be a more complex type such as a List type. This is shown in the next example:
assertEquals(ListF.empty(), none<Int>().option(ListF.empty()){n -> ListF.of(-n, n)})
assertEquals(ListF.of(-4, 4), some(4).option(ListF.empty()){n -> ListF.of(-n, n)})
The default is the empty List and the transformer parameter converts the value wrapped in a Some into a List of the value and its negative.
Of course, the type parameter B might be something more exotic such as a function type. In the following the default is the function isEven and the transformer is the curried binary function same. When the receiver is a Some it must wrap a String so that same can be called with the String. The resulting function expects an Int parameter and returns a Boolean that is true if the Int equals the string length.
fun isEven(n: Int): Boolean = (n % 2 == 0)
val same: (String) -> (Int) -> Boolean = {str -> {n -> (str.length == n)}}
val op1: Option<String> = none()
val op2: Option<String> = some("ken")
val op3: Option<String> = some("immutable")
assertEquals(false, op1.option(::isEven, same)(3))
assertEquals(true, op2.option(::isEven, same)(3))
assertEquals(false, op2.option(::isEven, same)(4))
assertEquals(true, op3.option(::isEven, same)(9))
Observe how the Option type is declared as an abstract sealed class with two internal sub-types representing the absence and the presence of data. The absence of data is captured by the object None while the presence of data is represented by the subclass Some. The Some constructor is tagged as internal so values are only produced through the factory function some.
val op: Option<Int> = Some(5) // cannot access Some constructor on right hand side
Pattern matching
Neither the object None nor the class Some are tagged as internal or private. That way the type names can be used in user-defined functions:
fun <A> contains(op: Option<A>, predicate: (A) -> Boolean): Boolean =
when (op) {
is Option.None -> false
is Option.Some -> predicate(op.value)
} // contains
assertEquals(true, contains(some(4)){n -> isEven(n)})
assertEquals(false, contains(none<Int>()){n -> isEven(n)})
Selected Option operations
One important function of the Option class is the member function map. The function map applies the transforming function parameter converting an Option<A> into an Option<B>:
assertEquals(some(true), some(4).map{n -> isEven(n)})
assertEquals(none(), none<Int>().map{n -> isEven(n)})
The Option type is like a
container with zero or one elements. The higher order function bind is used to make the Option class into a monad. A
monad is a way to structure computations in terms of values
and sequences of computations using those values. Monads allow the programmer
to build up computations using sequential building blocks, which can themselves
be sequences of computations. The monad determines how combined computations
form a new computation and frees the programmer from having to code the
combination manually each time it is required. It is useful to think of a monad
as a strategy for combining computations into more complex computations.
The example below demonstrates how we might
use the monadic Option type. Running the program with the inputs 18, 27 and 9
delivers the result Some(Pair(2, 3)), demonstrating that 18 and 27 are exactly
divisible by 9. The inputs 18, 27 and 6 produce the result None, showing
that 18 and 27 are not both exactly divisible by 6:
if (num % den != 0) none() else some(num / den)
fun division(a: Int, b: Int, c: Int): Option<Pair<Int, Int>> =
divide(a, c).bind{ac -> divide(b, c).bind{bc -> some(Pair(ac, bc))}}
assertEquals(some(3), divide(12, 4))
assertEquals(none(), divide(12, 5))
assertEquals(some(Pair(2, 3)), division(18, 27, 9))
assertEquals(none(), division(18, 27, 6))
The monadic Option is supported by the bind (or flatMap) member function. Its signature is:
fun <B> bind(f: (A) -> Option<B>): Option<B>
A monad fails fast. If any of the Option values in a sequence of binds is a None then the result is a None. In function division if the function call divide(a, c) delivers a None then processing is complete and None is returned. This means that the function call divide(b, c) is short-circuited and never executed.
Our goal is to stop computation if an error (None) is obtained. In other words fail-fast. Sometime we may want to accumulate all errors, for example when validating user input, but this is a different problem and leads to a different solution discussed below.
The Option type is also a functor, supporting the extension function fmap which simply repeats the map function. The Option type then represents something that can be mapped over.
fun <A, B> Option<A>.fmap(f: (A) -> B): Option<B> = this.map(f)
The inclusion of function fmap gives rise to a number of useful utilities that can be defined by it. Function distribute, for example, is applied to a pair wrapped in an Option and distributes the values into a pair of Options:
fun <A, B> Option<Pair<A, B>>.distribute(): Pair<Option<A>, Option<B>>
Here is a simple illustration:
assertEquals(Pair(some("Ken"), some(25)), some(Pair("Ken", 25)).distribute())
assertEquals(Pair(none(), none()), none<Pair<String, Int>>().distribute())
Option composition
The immutable List class from the custom library supports the extension function sequenceOption that combines a List<Option<A>> into an Option<List<A>>. It delivers a Some<List<A>> if all the values in the original list are Some instances. If there is at least one None in the list then the function delivers a None. The signature for sequenceOption is:
fun <A> List<Option<A>>.sequenceOption(): Option<List<A>>
Composing the various Option functions we can sum the integer values in the list of Options:
assertEquals(
10,
ListF.of(some(1), some(2), some(3), some(4)).sequenceOption().map{list -> list.sum()}.getOrElse(0)
)
10,
ListF.of(some(1), some(2), some(3), some(4)).sequenceOption().map{list -> list.sum()}.getOrElse(0)
)
assertEquals(
0,
ListF.of(some(1), some(2), none(), some(4)).sequenceOption().map{list -> list.sum()}.getOrElse(0)
)
0,
ListF.of(some(1), some(2), none(), some(4)).sequenceOption().map{list -> list.sum()}.getOrElse(0)
)
The Option type is also an applicative functor. When mapping functions over the Option functor with fmap we have provided a unary function for the mapping as in some(6).fmap{m: Int -> isEven(m)}. What do we get if we provide a curried binary function? For example, what type is some(6).fmap{m: Int -> {n: Int -> (m == n)}}? The answer is we get a unary function wrapped in an Option as shown for the value binding opF.
assertEquals(some(6), some(5).fmap{m: Int -> m + 1})
val opF: Option<(Int) -> Boolean> = some(6).fmap{m: Int -> {n: Int -> (m == n)}}
assertEquals(some(11), some(5).ap(some(6).fmap{m: Int -> {n: Int -> m + n}}))
assertEquals(some(11), {m: Int -> {n: Int -> m + n}} fmap some(5) appliedOver some(6))
We cannot map a function wrapped in an Option over another Option. The Option type as an applicative functor includes the extension function ap:
fun <A, B> Option<A>.ap(f: Option<(A) -> B>): Option<B>
which applies the wrapped function to the receiver Option. This is shown in the assertion immediately following the opF binding. The infix version of ap is known as appliedOver and lets us express our intention in an applicative style as shown in the final assertion.
Case Study
The final illustration demonstrates how to use an Option to change how we might use a Kotlin Map. In the listing the function get returns an Option when querying for a given key. The standard implementation of Map.get(key) returns null if the key is absent.
fun <K, V> get(key: K, map: Map<K, V>): Option<V> {
val v: V? = map.get(key)
return if (v == null) none() else some(v)
}
val staff: Map<String, User> = mapOf(
"ken" to User("KenB", "ken@gmail.com"),
"john" to User("JohnS", "john@gMail.com"),
"jessie" to User("JessieK", "jessie@gmail.com")
)
assertEquals(some(User("KenB", "ken@gmail.com")), get("ken", staff))
assertEquals(some("ken@gmail.com"), get("ken", staff).bind{user -> some(user.email)})
assertEquals(none(), get("Irene", staff))
The code sample demonstrates how functions returning an Option can be composed. You do not have to test for None occurring and we do not risk throwing a NullPointerException.
Suppose we are using Option to work with the results of look ups in two Maps. If we simply need to combine the results from two independent look ups we can use the applicative fmap2 function:
val departments: Map<String, String> = MapF.of( // department indexed by employee
"Ken" to "Software",
"John" to "Software",
"Jessie" to "Database"
)
val salaries: Map<String, Int> = MapF.of( // salaries indexed by employee
"Ken" to 45000,
"John" to 40000,
"Jessie" to 45000
)
val op: Option<Pair<String, Int>> = fmap2(departments.lookUpKey("Jessie"), salaries.lookUpKey("Jessie")){
dept, salary -> Pair(dept, salary)
}
assertEquals(some(Pair("Jessie", 45000)), op)
Here we are performing two look ups but they are independent and we merely want to combine their results within the Option context. If we want the result of one look up to effect what look up we perform next then we need bind as shown next:
val ids: Map<String, Int> = MapF.of(
"Ken" to 123,
"John" to 456,
"Jessie" to 789
)
val departments: Map<Int, String> = MapF.of(
123 to "Software",
456 to "Software",
789 to "Database"
)
val salaries: Map<Int, Int> = MapF.of(
123 to 45000,
456 to 40000,
789 to 45000
)
val op: Option<Pair<String, Int>> =
ids.lookUpKey("Jessie").bind{id: Int ->
fmap2(departments.lookUpKey(id), salaries.lookUpKey(id)) {
dept, salary -> Pair(dept, salary)
}
}
assertEquals(some(Pair("Database", 45000)), op)
Here ids is a Map between the employee name and employee id. To access Jessie's department and salary we first need to resolve Jessie's name to her id and then use this id to perform look ups in both departments and salaries.
Combining options
Adopting the Option type may seem to make existing code obsolete. An existing code base may include many functions that convert some type A into some type B. Working with the Option type might suggest that we need functions from Option<A> to Option<B>. We can easily adapt the existing code base by using the custom library function lift from the object declaration OptionF:
fun <A, B> lift(f: (A) -> B): (Option<A>) -> Option<B>
In the following liftLength lifts a function from a String to an Int into a function from an Option<String> to an Option<Int>. Similarly, liftParseOct lifts a function from a String to an Int into a function from an Option<String> to an Option<Int>. In this example we are lifting the parseOct function which parses a String to an Int with radix 8.
val liftLength: (Option<String>) -> Option<Int> = lift(String::length)
assertEquals(some(3), liftLength(some("ken")))
assertEquals(none(), liftLength(none()))
val parse: (Int) -> (String) -> Int = flip(C2(Integer::parseInt))
val parseOct: (String) -> Int = parse(8)
val liftParseOct: (Option<String>) -> Option<Int> = lift(parseOct)
assertEquals(some(7), liftParseOct(some("7")))
assertEquals(some(83), liftParseOct(some("123")))
To map a binary function over two Option values we use the fmap2 library function:
fun <A, B, C> fmap2(oa: Option<A>, ob: Option<B>, f: (A) -> (B) -> C): Option<C>
In the following we combine an Option<String> and an Option<Int> into an Option<Boolean> where the resulting Boolean is true if the Int and the String length are the same.
val opName: Option<String> = some("ken")
val opInt3: Option<Int> = some(3)
val opInt6: Option<Int> = some(6)
assertEquals(some(true), fmap2(opName, opInt3){str -> {n -> (str.length == n)}})
assertEquals(some(false), fmap2(opName, opInt6){str -> {n -> (str.length == n)}})
Composing Option instances is just the beginning. We will need to compose an Option type with some other type. For example, we may wish to convert a List<Option<A>> into an Option<List<A>> where the values in the result List that is wrapped in an Option are those values from the source List that were wrapped in Option. Usually, we want a Some<List<A>> if all the elements in the List are a Some<A>, and a None if one or more elements in the List are a None. The library function sequenceOption from ListF does just this:
assertEquals(some(ListF.of(1, 2, 3, 4)), ListF.of(some(1), some(2), some(3), some(4)).sequenceOption())
assertEquals(none(), ListF.of(some(1), some(2), none(), some(4)).sequenceOption())
assertEquals(none(), ListF.of(some(1), some(2), none(), none()).sequenceOption())
Effectful computations
Consider the class Account that might be used in a banking application. A function open will receive details of the account through its parameter list to create and initialize an Account. The return type of open is Account but how do we handle failure? We might choose to return null or throw an exception. However, we can better handle failure as effects that compose with other abstractions in our code. An effect adds capabilities to to a computation so that we do not have to use side effects. An effect is usually modeled as a type that wraps these additional capabilities around some type. Our Option<A> adds the capability of optionality to the type A. Hence the return type for the function open would be Option<Account>. We we see many more examples of this from our custom library.
The code for the Dogs library can be found at:
No comments:
Post a Comment