Scala Option, Lift Box and how to make your code better June 1, 2010
If you come from an imperative (Java, Ruby) background, you'll probably recognize the following code:
x = someOperation
if !x.nil?
y = someOtherOperation
if !y.nil? doSomething(x,y)
return "it worked"
end
end
return "it failed"
Okay, so that's pseudo-code, but there are tons of operation, guard, operation, guard, blah blah constructs.
Further, null/nil are passed around as failures. This is especially bad when it's null, but it's pretty bad when it's nil because it's not clear to the consumer of the API that there can be a "call failed" return value.
In Java, null is a non-object. It has no methods. It is the exception to the statically typed rule (null has no class, but any reference of any class can be set to null.) Invoking a method on null has one and only one result: an exception is thrown. null is often returned from methods as a flag indicating that the method ran successfully, but yielded no meaningful value. For example, CardHolder.findByCreditCardNumber("2222222222") In fact, the guy who invented null called it a billion dollar mistake.
Ruby has nil which is marginally better than null. nil is a real, singleton object. There's only one instance of nil in the whole system. It has methods. It is a subclass of Object. Object has a method called "nil?" which returns false, except the nil singleton overrides this method to return true. nil is returned much like null in Java. It's the "no valid answer" answer.
Scala does something different.
There's an abstract class, called Option. Options are strongly typed. They are declared Option[T]. This means an Option can be of any type, but once its type is defined, it does not change. There are two subclasses of Option: Some and None. None is a singleton (like nil). Some is a container around the actual answer. So, you might have a method that looks like:
def findUser(name: String): Option[User] = {
val query = buildQuery(name)
val resultSet = performQuery(query)
val retVal = if (resultSet.next) Some(createUser(resultSet)) else None
resultSet.close
retVal
}
Some, you've got a findUser method that returns either Some(User) or None. So far, it doesn't look a lot different than our example above. So, to confuse everyone, I'm going to talk about collections for a minute.
A really nice thing in Scala (yes, Ruby has this too) is rich list operations. Rather than creating a counter and pulling list (array) elements out one by one, you write a little function and pass that function to the list. The list calls the function with each element and returns a new list with the values returned from each call. It's easier to see it in code:
scala> List(1,2,3).map(x => x * 2)
line0: scala.List[scala.Int] = List(2,4,6)
The above code multiplies each list item by two and "map" returns the resulting list. Oh, and you can be more terse, if you want:
scala> List(1,2,3).map(_ * 2)
line2: scala.List[scala.Int] = List(2,4,6)
You can nest map operations:
scala> List(1,2,3).map(x => List(4,5,6).map(y => x * y))
line13: scala.List[scala.List[scala.Int]] = List(List(4,5,6),List(8,10,12),List(12,15,18))
And, you can "flatten" the inner list:
scala> List(1,2,3).flatMap(x => List(4,5,6).map(y => x * y))
line14: scala.List[scala.Int] = List(4,5,6,8,10,12,12,15,18)
Finally, you can "filter" only the even numbers from the first list:
scala> List(1,2,3).filter(_ % 2 == 0).
flatMap(x => List(4,5,6).map(y => x * y))
line16: scala.List[scala.Int] = List(8,10,12)
But, as you can see, the map/flatMap/filter stuff gets pretty verbose. Scala introduced a "for" comprehension to make the code more readable:
scala> for {x <- List(1,2,3) if x % 2 == 0
y <- List(4,5,6)} yield x * y
res0: List[Int] = List(8, 10, 12)
Okay, but what does this have to do with Option[T]?
Turns out that Option implements map, flatMap, and filter (the methods necessary for the Scala compiler to use in the 'for' comprehension). Just as a side note, when I first encountered the phrase "'for' comprehension", I got scared. I've been doing programming for years and never heard of a "comprenhension" let alone a 'for' one. Turns out, that there's nothing fancy going on, but "'for' comprehension" is just a term of art for the above construct.
So, the cool thing is that you can use this construct very effectively. The first example is simple:
scala> for {x <- Some(3)
y <- Some(4)} yield x * y
res1: Option[Int] = Some(12)
"That's nice, you just wrote a lot of code to multiply 3 by 4."
Let's see what happens if we have a "None" in there:
scala> val yOpt: Option[Int] = None
yOpt: Option[Int] = None
scala> for {x <- Some(3)
y <- yOpt} yield x * y
res3: Option[Int] = None
So, we get a "None" back. How do we turn this into a default value?
scala> (for {x <- Some(3); y <- yOpt} yield x * y) getOrElse -1
res4: Int = -1
scala> (for {x <- Some(3); y <- Some(4)} yield x * y) getOrElse -1
res5: Int = 12
Note that the "getOrElse" code is "passed by name". Put another way, that code is only executed if the "else" clause is valid.
Lift has an analogous construct called Box.
A Box Full or not. A non-Full Box can be the Empty singleton or a Failure. A Failure carries around information about why the Box contains no value.
Failure is very helpful because you can carry around information to display an error... an HTTP response code, a message, what have you.
In Lift, I put this all together in the following way:
- methods that return request parameters return Box[String]
- finder methods on models (not find all, just the ones that return a single instance) return Box[Model]
- any method that would have returned a null if I was writing in Java returns a Box[T] in Lift
That means you get code that looks like:
scala> for {id <- S.param("id") ?~ "id param missing"u <- getUser(id) ?~ "User not found"
} yield u.toXml
res6: net.liftweb.common.Box[scala.xml.Elem] = Failure(id param missing,Empty,Empty)
There's no explicit guard/test to see if the "id" parameter was passed in and there's no explicit test to see if the user was found.
Note also that this code is completely type-safe. While there was no explicit type declarations, the compiler was able to figure out what types the various objects were.
So, let's look at the code inside a REST handler:
serve {
case "user" :: "info" :: _ XmlGet _ =>
for {
id <- S.param("id") ?~ "id param missing" ~> 401
u <- User.find(id) ?~ "User not found"
} yield u.toXml
}
If the id parameter is missing, present a nice error message and return a 401 (okay... this is random, but you get the point). And by default, if the user isn't found, return a 404 with the error that the user isn't found.
Here's what it looks like using wget:
dpp@bison:~/lift_sbt_prototype$ wget http://localhost:8080/user/info.xml
--2010-06-01 15:07:27-- http://localhost:8080/user/info.xml
Resolving localhost... ::1, 127.0.0.1
Connecting to localhost|::1|:8080... connected.
HTTP request sent, awaiting response... 401 Unauthorized
Authorization failed.
dpp@bison:~/lift_sbt_prototype$ wget http://localhost:8080/user/info.xml?id=2
--2010-06-01 15:07:44-- http://localhost:8080/user/info.xml?id=2
Resolving localhost... ::1, 127.0.0.1
Connecting to localhost|::1|:8080... connected.
HTTP request sent, awaiting response... 404 Not Found
2010-06-01 15:07:44 ERROR 404: Not Found.
dpp@bison:~/lift_sbt_prototype$ wget http://localhost:8080/user/info.xml?id=1
--2010-06-01 15:24:12-- http://localhost:8080/user/info.xml?id=1
Resolving localhost... ::1, 127.0.0.1
Connecting to localhost|::1|:8080... connected.
HTTP request sent, awaiting response... 200 OK
Length: 274 [text/xml]
Saving to: `info.xml?id=1'
dpp@bison:~/lift_sbt_prototype$ cat info.xml\?id\=1
<?xml version="1.0" encoding="UTF-8"?>
<User id="1" firstName="Elwood" ... validated="true" superUser="false"></User>
One more thing about Box and Option... they lead to less complex, more maintainable code. Even if you didn't know anything about Scala or Lift, you can read the XML serving code and the console exchange and figure out what happened any why it happened. This is a lot more readable than deeply nested if statements. And if it's readable, it's maintainable.
I hope this is an understandable introduction to Scala's Option class and 'for' comprehension and how Lift makes use of these tools.