1. Overview

In this tutorial, we’re going to learn how to manage optional data elements in Scala. Scala’s Option type makes it easier to write robust code if we use it as intended throughout our application code.

2. Option Basics

Managing and representing optional values requires some careful thought. As we have all experienced, writing code that handles the absence of data is difficult to write and is the source of many runtime errors.

Scala’s Option is particularly useful because it enables management of optional values in two self-reinforcing ways:

  1. Type safety – We can parameterize our optional values.
  2. Functionally aware – The Option type also provides us with a set of powerful functional capabilities that aid in creating fewer bugs.

Moreover, learning and mastering the Option class is a great way to adopt Scala’s functional paradigms in our code. We’ll talk about those along the way.

Let’s start with how Scala represents optional values:

       Option\[T\]
          ^
          |
  +-------+------+
  |              |
  |              |
Some\[T\]        None\[T\]

The base class, scala.Option, is abstract and extends scala.collection.IterableOnce. This makes Scala’s Option behave like a container. This is a capability we can put to good use when we discuss mapping and filtering with Options later in this tutorial.

The important takeaway here is that the Option class and its subclasses all require a concrete type, which can be either explicit or inferred:

val o1: Option[Int] = None
val o2 = Some(10)

Both o1 and o2 are instances of Option[Int].

Finally, it’s useful to know that Option is “null aware”:

val o1: Option[Int] = Option(null)
assert(false == o1.isDefined)

This is handy when interoperating with Java, especially older Java libraries that use null to represent “not found”.

2.1. Test an Option for Some or None

We can test whether an Option is Some or None using these following methods:

  • isDefinedtrue if the object is Some
  • nonEmptytrue if the object is Some
  • isEmptytrue if the object is None

2.2. Retrieving an Option‘s Contents

We can retrieve the Option‘s value via the get method. If we invoke the get method on an instance of None, then a NoSuchElementException will be thrown. This behavior is referred to as “success bias”. Becoming comfortable with success bias is an important step in our Scala journey, and the Option type is a great place to start that journey.

Because of this, it’s tempting to write code like:

val o1: Option[Int] = ...
val v1 = if (o1.isDefined) {
  o1.get
} else {
  0
}

Or, its pattern matching counterpart:

val o1: Option[Int] = ...
val v1 = o1 match {
  case Some(n) =>
    n
  case None =>
    0
}

Both of these are considered anti-patterns in the Scala community because of their reliance on non-functional coding constructs. Other Option anti-patterns include using if/else or match to map an Option to another Option. There are more idiomatic and elegant ways to achieve the same effect using existing methods that we’ll explore later in this article.

2.3. Option Default Values

We also have a couple of other retrieval methods:

  • getOrElse – Retrieve the value if the object is Some, otherwise return a default value
  • orElse – Retrieve the Option if it is Some, otherwise return an alternate Option

The first method, getOrElse, is useful in situations where we want to return a default value if the optional value is unset. Let’s rewrite the snippet of code in the previous section to use getOrElse:

val v1 = o1.getOrElse(0)

Clearly, this is simpler and easier to maintain. Additionally, we’re not limited to simply returning a value. This is also equally valid:

val usdVsZarFxRate: Option[BigDecimal] = ... // populated via a web service call, for example
val marketRate = usdVsZarFxRate.getOrElse(throw new RuntimeException("No exchange rate defined for USD/ZAR"))

Here, imagine that we have a Federal Exchange rate that we retrieved via a web service call. But perhaps users have to be enabled to get those rates. In the case that they have not been enabled, the web service code will return None. This gives a quick way to exit any such code block since we cannot do any of the subsequent work.

3. Options as a Container

As we have discussed, an Option is a container for another value. In this regard, it can be treated as a special case of a collection, albeit with a single value. Therefore, we can traverse an Option in the same way as we traverse a List. Combined with Scala’s functional programming support, this enables us to write code that is more succinct and with fewer errors.

3.1. Mapping Options

The formal definition of the Option.map method is:

final def map[B](f: (A) => B): Option[B]

Let’s unpack this by way of some examples:

val o1: Option[Int] = Some(10)
assert(o1.map(_.toString).contains("10"))
assert(o1.map(_ * 2.0).contains(20))

val o2: Option[Int] = None
assert(o2.map(_.toString).isEmpty)

It should come as no surprise that we can use Option.map to convert the contained value to another type.

Additionally, Option provides the flatMap method to be used to collapse multiple layers within the Option.

Let’s explore these concepts using a simple set of traits and classes for tracking a games tournament:

trait Player {
  def name: String
  def getFavoriteTeam: Option[String]
}

trait Tournament {
  def getTopScore(team: String): Option[Int] // Specified team's top score or None if they haven't played yet
}

val player: Player = ...                     // Let's not worry how we instantiate these just yet
val tournament: Tournament = ...

How do we get a player’s favorite team’s top score? We could call player.getFavoriteTeam().map(tournament.getTopScore) but this returns an Option[Option[Int]]. Working with multiple layers of Option is awkward, to be sure.

Instead of using *Option.*map, we can use another method, Option.flatMap. It does exactly what we want by removing intermediate layers of Options. Let’s update our implementation of getTopScore to use it:

def getTopScore(player: Player, tournament: Tournament): Option[(Player, Int)] = {
  player.getFavoriteTeam.flatMap(tournament.getTopScore).map(score => (player, score))
}

Finally, if we just want to access the value of the Option, we can use the foreach method. This is useful when we don’t care about using the value. It is often used in “side-effecting” situations, like if we want to store the value in a database or print it to a file:

val o1: Option[Int] = Some(10)
o1.foreach(println)

This will simply print the value of o1 to stdout.

3.2. Flow Control with Options

We can also use Option.map to effect flow control. Consider the following:

val o1: Option[Int] = Some(10)
val o2: Option[Int] = None

def times2(n: Int): Int = n * 2

assert(o1.map(times2).contains(20))
assert(o2.map(times2).isEmpty)

Here we have defined two instances of an Option[Int]. The first with a value of 10 and the second, empty. We then define a simple function that simply multiplies its input by 2.

Finally, we map each of the Option‘s, o1, and o2 using the times2 function. Unsurprisingly, mapping o1 gives us Some(20) and mapping o2 gives us None.

*The important takeaway here is that in the second call, o2.map(times2), the times2 function is not called at all.*

Let’s consider a more complex example using some other methods provided by Option that allow us to implement flow control.

3.3. Flow Control with Filtering

We can also do collection-style filtering on Options:

  • filter – Returns the Option if the Option‘s value is Some and the supplied function returns true
  • filterNot – Returns the Option if the Option‘s value is Some and the supplied function returns false
  • exists – Returns true if the Option is set and the supplied function returns true
  • forall – Same behavior as exists since Options have a maximum of one value

These methods allow us to write some very concise code. For example, let’s consider a situation where we need to calculate which of two users’ favorite teams has the top score:

 def whoHasTopScoringTeam(playerA: Player, playerB: Player, tournament: Tournament): Option[(Player, Int)] = {
    getTopScore(playerA, tournament).foldRight(getTopScore(playerB, tournament)) {
      case (playerAInfo, playerBInfo) => playerBInfo.filter {
        case (_, scoreB) => scoreB > playerAInfo._2
      }.orElse(Some(playerAInfo))
    }
  }

Here, we leverage the fact that Options can behave like collections. The difference here is that an Option can have at most one value.

Code like this may seem opaque at first, but as we become more comfortable with Scala idioms, especially the power of its monadic operators, this will become easier for us to use. Note that we never have to explicitly check for None, nor do we have to do explicit if/else checks.

4. when and unless Option Builders

Scala 2.13 introduced the when and unless Option builders for creating an Option of an object based on a condition. In this section, we’ll learn about these builder methods.

4.1. when Builder

First, let’s start by taking a look at the declaration of the when method within the Option class:

def when[A](cond: Boolean)(a: => A): Option[A] =
  if (cond) Some(a) else None

We can see that method accepts a condition and returns an Option type for an object. Further, if the condition is true, we get the Some Option. Otherwise, we get None.

Next, let’s see this in action for a scenario to create an Option type based on whether a number is positive or not:

val num: Int = 25
val maybePositive = Option.when(num > 0)(num)
val expected = Some(num)
val actual = maybePositive
assert(expected == actual)

We can infer from the results that the when builder returns a Some Option as the condition evaluates to true.

Finally, let’s also use the when builder when the condition evaluates to false:

val num: Int = -4
val maybePositive = Option.when(num > 0)(num)
val expected = None
val actual = maybePositive
assert(expected == actual)

Perfect! The results are as expected.

4.2. unless Builder

The unless builder is the exact opposite of the when builder. Let’s confirm this by inspecting its definition from the Option class:

def unless[A](cond: Boolean)(a: => A): Option[A] = 
  when(!cond)(a)

Unlike the when builder, we get the Some Option type if the condition evaluates to false. Otherwise, we get the None Option type.

Now, let’s validate our understanding by using the unless builder based on whether a number is positive:

val num: Int = 25
val maybeNegative = Option.unless(num > 0)(num)
val expected = None
val actual = maybeNegative
assert(expected == actual)

We can observe that we get a None type because the number is initialized with a positive value.

Lastly, let’s also verify the use of the unless builder for a scenario where the underlying condition evaluates as false:

val num: Int = -4
val maybeNegative = Option.unless(num > 0)(num)
val expected = Some(num)
val actual = maybeNegative
assert(expected == actual)

Great! We’ve got the right results.

4.3. Custom Option Builders

Now that we’ve got a good understanding of the when and unless Option builders, let’s enrich existing types with these builders.

First, we’ll need to define the OptionBuilder object so that we can define the implicit classes within it:

object OptionBuilder {
}

Next, let’s define the OptionWhenBuilder implicit class to provide the functionality of the when builder:

implicit class OptionWhenBuilder[A](value: A) {
  def when(condition: A => Boolean): Option[A] = {
    Some(value).filter(condition)
  }
}

Further, let’s see the convenience of calling the when method directly on the Int object to create an Option type:

val num: Int = 25
val maybePositive: Option[Int] = num.when(_ > 0)
val expected = Some(25)
val actual = maybePositive
assert(expected == actual)

Fantastic! It’s indeed convenient and readable at the same time.

Moving on, let’s also define the OptionUnlessBuilder implicit class that gives the functionality of the unless builder:

implicit class OptionUnlessBuilder[A](value: A) {
  def unless(condition: A => Boolean): Option[A] = {
    Some(value).filterNot(condition)
  }
}

Finally, let’s put this in action and verify that it’s working as expected:

val num: Int = 25
val maybeNegative: Option[Int] = num.unless(_ > 0)
val expected = None
val actual = maybeNegative
assert(expected == actual)

Great! We’ve nailed this!

5. Conclusion

In this article, we looked at the basic use of Scala’s Option class. We expanded on that basic knowledge to show how we can use Option‘s functional interface to write concise and idiomatic Scala. Furthermore, we also learned about the when and unless builder methods to create Option types based on a condition.

As always, the full source code can be found over on GitHub.


« 上一篇: Akka调度器简介