Skip to content

Instantly share code, notes, and snippets.

@JonNorman
Last active October 30, 2018 00:11
Show Gist options
  • Save JonNorman/0bd59108b136515cd81f8c108e93c5bf to your computer and use it in GitHub Desktop.
Save JonNorman/0bd59108b136515cd81f8c108e93c5bf to your computer and use it in GitHub Desktop.
/***
* Handling errors and working with success! aka: Option
*
* Every language needs to deal with things not going to plan.
* Many languages - including Java - do this via exceptions. Even
* if your function says it returns an Int, it might just explode.
*
* Some languages - including Java ( ͡ಠ ʖ̯ ͡ಠ) - use nulls. Even
* if your function says it returns an Int, it might return nothing.
*
* The upshot of the above behaviour is incessant checking to try
* any catch any errors that might be thrown or obsessively checking
* that what you were told is an Int is indeed an Int.
*
* Not only is this tiresome, but more deeply, your view and knowledge
* of the world has begun to branch from what the compiler knows about
* the world, which means it can be less and less useful in checking
* things for you.
*
* There's got to be a better way! And there is. For a start, let's
* look at Option
*/
/*
First let's see what happens if we DON'T use Options
*/
// let's try and derive the surname from a full (western-style) name.
def getSurnameUnsafe(name: String): String = {
name.split(" ")(1)
}
getSurnameUnsafe("Christopher Nolan")
//getSurnameUnsafe("Madonna") //ah...
/*
Now we could/should check for this in our function
*/
def getSurnameUnsafe2(name: String): String = {
val names = name.split(" ")
if (names.size == 2) {
names(1)
} else {
??? // what do we put here? Empty string? Throw an error?
}
}
/*
Or we could check this before calling our function
*/
val name: String = "Sting"
if (name.split(" ").size == 2) {
getSurnameUnsafe(name)
} else {
// decide what to do if this doesn't make sense...
// what if this bit of code is in the middle of some other larger bit of complex logic?
// It might not have the context or authority to decide what to do!
}
/*
All of these approaches are a bit icky and skirting around the issue.
Conceptually WE know that there are inputs for which our function
doesn't make sense: in this case, single word names. We can use the in-built
Option type to represent just this case. On the happy path where our function
does make sense we return something; on the unhappy path we return nothing.
Enter Option!
*/
val someInt: Option[Int] = Some(6)
val notAnInt: Option[Int] = None
/*
An Option is an interface that is extended by only two classes:
Some - a wrapper that contains some value
None - an object that doesn't contain anything
Let's try rewriting a safe version of our getSurname function...
*/
def getSurnameSafe(name: String): Option[String] = {
name.split(" ").toList match {
// here we match on a List with two items, we ignore the first item and wrap the second in a Some
case List(_, surname) => Some(surname)
// any other value for our list and we don't care - return a None
case _ => None
}
}
// let's give it a whirl
getSurnameSafe("Hayao Miyazaki") // Some(Miyazaki)
getSurnameSafe("Ringo") // None
getSurnameSafe("") // None
getSurnameSafe("Wesley Wales Anderson") // None - we could try and handle this case but it's a bit boring so let's move on...
// there are a LOT of functions that return Options in the standard library, just looking at List, we have a few:
List(1,2,3,4,6).headOption // what if the list is empty?
List(1,2,4,5).find(_ == 7) // what if 7 isn't in there?
// we see it also with key-value collections like Map
val filmRatings = Map("Inception" -> 5, "Chinatown" -> 4)
filmRatings.get("Inception") // Some(5)
filmRatings.get("Crazy Rich Asians") // None
/*
Okay so we've modelled the fact that things might be missing. Great. What can
we then do with it?
In the same way that we can map over the elements in a List with .map and .foreach, we
can do this with our Options.
*/
Some(2).map(_ * 9) // Some(18)
val maybeNumber: Option[Int] = None
maybeNumber.map(_ * 9) // None
/*
Instead of extracting the value and doing something with it as you might expect,
we are instead passing our functions TO the Option and transforming the value within.
This has many advantages:
1. Our functions don't have to care about whether a value may be there or not. If
it is then .map will apply our function, if it isn't then it won't.
2. We can delay the need to handle the unhappy case - None - to wherever needs
to make that decision.
*/
/*
Let's say we have a succession of operations we want to execute. It starts with getting some
typed input from a user for the age of their dog, it converts that to dog years and it finally
uses our handy dogAge classifier to classify the dog. Phew.
*/
def parseAgeFromString(age: String) = {
if (age.forall(_.isDigit)) {
Some(age.toInt)
} else {
None
}
}
def convertHumanToDogYears(humanYears: Int) = humanYears * 7
def classifyDog(age: Int): String = {
if (age <= 7) {
"Puppy"
} else if (age <= 21) {
"Adolescent"
} else if (age <= 49) {
"Adult"
} else {
"Senior"
}
}
// let's see if this works
parseAgeFromString("7")
.map(convertHumanToDogYears)
.map(classifyDog)
// silly user, that's not the input I was expecting. idiot.
parseAgeFromString("Seven")
.map(convertHumanToDogYears)
.map(classifyDog)
/*
Food for thought:
Conceptually an Option of a given type, can be though of as behaving the same as a List of the same type:
- Some("value") == List("value")
- None == Nil
Try rewriting the above functions to return Lists and Nil (an empty list) instead of Some and None to see for yourself.
This is all to say that the notions of map / filter / exists / etc are higher level functions that
crop up all over the shop on all sorts of types / collections / wrappers.
*/
/*
Now let's consider the case where, within a chain of operations, there are a lot of maybes...
Let's say we are running a film rental service and we want to offer the functionality where a user can search
for an Actor in our catalogue of actors, then find a film that they've appeared in and get the
director for that film. It's a SUPER common use case, it's amazing Netflix haven't implemented this yet...
A problem with all of this is that our data isn't 100% and sometimes isn't fully complete...
*/
object FilmExample {
// let's create some simple models...
case class Actor(id: Int, name: String)
case class Director(name: String, kudosLevel: Int)
// the people we paid to populate the film database did a shoddy job - director is only populated some of the time
case class Film(name: String, actorIds: List[Int], director: Option[Director])
// in reality our actors and films would be stored in a database somewhere, but the concept is still the same
// our searches may turn up empty...
val actors: List[Actor] = List(
Actor(1, "Jennifer Lawrence"),
Actor(2, "Bill Nighy"),
Actor(3, "Rachel McAdams")
)
val films: List[Film] = List(
Film("About Time", List(3, 8, 9), Some(Director("Richard Curtis", 1))),
Film("Winter's Bone", List(1, 17, 20), None)
)
def findActorByName(name: String): Option[Actor] = {
actors.find(_.name == name)
}
def findFilmWithActor(actor: Actor): Option[Film] = {
films.find(_.actorIds.contains(actor.id))
}
}
// we import these functions into the main namespace so we don't have to prefix them e.g. FilmExample.findActorByName(...)
import FilmExample.{findActorByName, findFilmWithActor, Director}
// right let's finall implement this thing...
def findDirectorForActor(actorName: String) = {
findActorByName(actorName).map( actor =>
findFilmWithActor(actor).map (film =>
film.director
)
)
}
// okay let's give this a go...
findDirectorForActor("Rachel McAdams")
findDirectorForActor("Bill Nighy")
findDirectorForActor("Jennifer Lawrence")
findDirectorForActor("Bradley Cooper")
/* Wowsers... I mean it is working in the sense that there are a lot of places where things could fall over
so having a bunch of nested Options DOES make sense, but it isn't useful. If we want to do something
with the value that might be lurking 3 levels deep we are going to have to map over it three times.
This is ungainly and a right pain in the neck. Fortunately, we can solve this via an operation called flatten.
If we call .flatten on a nested Option, it will collapse the two options into a single Option:
*/
Some(Some(2)).flatten // Some(2)
Some(None).flatten // None
/*
How handy!
Note: this also works on Lists! In the same way that if you have an Option[Option[Int]] calling .flatten
will collapse it into a single Option[Int], if you have a List[List[Int]], calling .flatten
will collapse it into a single List[Int]. Give it a go!
*/
List(
List(1,2,3,4),
List(5,6,7,8),
Nil,
List(9),
Nil
).flatten // List(1, 2, 3, 4, 5, 6, 7, 8, 9)
/*
This is all well and good, but our little actor -> director function returns 3 nested options! We could call flatten
a bunch of times as so:
*/
findDirectorForActor("Rachel McAdams").flatten.flatten // Some(Director(Richard Curtis,1))
/*
... but apart from looking gross, it should really be handled by the function itself as we - the caller - don't
care about the various hoops our app has to go through, we just want to get a Director if possible and know
when we don't. What if another risky operation gets added? The signature of the function is going to change to
Option[Option[Option[Option[Director]]]] and we'll have to update our calling code, despite us not caring at all
about the difference between an Actor that is missing from our actors list and a film with no director specified.
We could rewrite our function as so...
*/
def findDirectorForActor2(actorName: String): Option[Director] = {
findActorByName(actorName).map( actor =>
findFilmWithActor(actor).map(film =>
film.director
).flatten
).flatten
}
findDirectorForActor2("Rachel McAdams") // Some(Director(Richard Curtis,1))
/*
This is still pretty rank to look at. There has GOT to be a better way. And there is (yet again)! Instead of calling
.map and then a .flatten, we can use the method .flatMap, which does exactly that: a .map followed by a .flatten
*/
def findDirectorForActor3(actorName: String): Option[Director] = {
findActorByName(actorName).flatMap( actor =>
findFilmWithActor(actor).flatMap(film =>
film.director
)
)
}
findDirectorForActor3("Rachel McAdams") // Some(Director(Richard Curtis,1))
/*
So now if we are chaining together lots of operations that might each produce an Option, we can use flatMap
to ensure that we are left with a single Option at the end of it. How convenient!
As before, this also works for Lists. Have a play with these new functions:
*/
val bandNames =
List("The", "A", "Our", "Some").flatMap(article =>
List(article + " crazy", article + " changing", article + " growing").flatMap( articleAdjective =>
List(articleAdjective + " balloons", articleAdjective + " days", articleAdjective + "monkeys")
)
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment