If you are not yet ready to lose information that is encoded by the Option
data type, then you may wish to explore alternative implementations of Option
that are more garbage-collection-friendly. In this section, we present an alternative approach that also provides type-safety while avoiding boxing and instantiation of the Some
instances. We leverage tagged types and specialization, and disallow null as a valid value for Some
to come up with the following implementation:
sealed trait Opt object OptOps { def some[@specialized A](x: A): A @@ Opt = Tag(x) def nullCheckingSome[@specialized A](x: A): A @@ Opt = if (x == null) sys.error("Null values disallowed") else Tag(x) def none[A]: A @@ Opt = Tag(null.asInstanceOf[A]) def isSome[A](o: A @@ Opt): Boolean = o != null def isEmpty[A](o: A @@ Opt): Boolean = !isSome(o) def unsafeGet[A](o: A @@ Opt): A = if (isSome(o)) o.asInstanceOf[A] else sys.error("Cannot get None") def fold[A, B](o: A @@ Opt)(ifEmpty: => B)(f: A => B): B = if (o == null) ifEmpty else f(o.asInstanceOf[A]) }
This implementation defines factory methods to construct optional types (that is, some
, nullCheckingSome
, and none
). In contrast to Scala's Option
, this implementation uses tagged types to add type information to a value rather than creating a new value to encode optionality. The implementation of none
takes advantage of null
being a value in Scala rather than a language in keyword as is the case in Java. Remember, unless performance requirements required such extreme measures, we would not default to these more esoteric approaches. The tagged type returned by each factory method preserves type-safety, and it requires an explicit unwrapping to access the underlying type.
If you would like to learn more about Scala's representation of the null
value, we encourage you to check out these two StackOverflow posts at http://stackoverflow.com/questions/8285916/why-doesnt-null-asinstanceofint-throw-a-nullpointerexception and http://stackoverflow.com/questions/10749010/if-an-int-cant-be-null-what-does-null-asinstanceofint-mean. In both posts, multiple responders provide excellent responses that will help you deepen your understanding.
The remaining methods in OptOps
define methods that you would find in the implementation of Scala's Option
. Rather than instance methods, we see that the methods are static because there are no new instances that are allocated by the factory methods. It is possible to define an implicit class that would provide a syntax emulating instance method invocation, but we avoid doing this because we are operating under the assumption of extreme performance sensitivity. Semantically, the operations that are defined in OptOps
are equivalent to the Scala Option
analogs. Instead of matching against a value representing no value (that is, None
), we again take advantage of the ability to reference null
as a value.
With this implementation, the runtime overhead consists of instance checking and invocations of scalaz.Tag
. We lose the ability to pattern match, and instead we must either fold or, in extreme cases, use isSome
and unsafeGet
. To get a better understanding of runtime differences, we microbenchmarked Option
creation using Scala's Option
and the preceding tagged type implementation. The microbenchmark gives you a taste for the change in syntax. We encourage you to run javap
to disassemble the bytecode in order to prove to yourself that this implementation avoids boxing and object creation:
class OptionCreationBenchmarks { @Benchmark def scalaSome(): Option[ShareCount] = Some(ShareCount(1)) @Benchmark def scalaNone(): Option[ShareCount] = None @Benchmark def optSome(): ShareCount @@ Opt = OptOps.some(ShareCount(1)) @Benchmark def optSomeWithNullChecking(): ShareCount @@ Opt = OptOps.nullCheckingSome(ShareCount(1)) @Benchmark def optNone(): ShareCount @@ Opt = OptOps.none @Benchmark def optNoneReuse(): ShareCount @@ Opt = noShares } object OptionCreationBenchmarks { case class ShareCount(value: Long) extends AnyVal val noShares: ShareCount @@ Opt = OptOps.none }
We run the test with the following familiar parameters:
sbt 'project chapter3' 'jmh:run OptionCreationBenchmarks -foe true'
The results are summarized in the following table:
Benchmark |
Throughput (ops per second) |
Error as percentage of throughput |
|
351,536,523.84 |
±0.75 |
|
344,201,145.90 |
±0.23 |
|
232,684,849.83 |
±0.37 |
|
233,432,224.39 |
±0.28 |
|
345,826,731.05 |
±0.35 |
|
133,583,718.28 |
±0.24 |
Perhaps the most impressive result here is that throughput increases approximately 57% when using the tagged type implementation of Some
over the Scala-provided implementation. This is likely due to reduced memory allocation pressure. We see that None
creation throughput is qualitatively similar. We also observe that there appears to be zero cost to add a null check in the construction of a tagged Some
option. If you trust your team to avoid passing around null values, then the check is superfluous.
We also created a set of benchmarks to evaluate fold performance to get a sense of the relative cost of using this alternative Option
implementation. Here is the source code for a simple fold benchmark:
class OptionFoldingBenchmarks { @Benchmark def scalaOption(): ShareCount = scalaSome.fold(ShareCount(0))(c => ShareCount(c.value * 2)) @Benchmark def optOption(): ShareCount = OptOps.fold(optSome)(ShareCount(0))(c => ShareCount(c.value * 2)) } object OptionFoldingBenchmarks { case class ShareCount(value: Long) extends AnyVal val scalaSome: Option[ShareCount] = Some(ShareCount(7)) val optSome: ShareCount @@ Opt = OptOps.some(ShareCount(7)) }
This benchmark was run using the same set of parameters as before:
jmh:run OptionFoldingBenchmarks -foe true
The results of this test are summarized in the following table:
Benchmark |
Throughput (ops per second) |
Error as percentage of throughput |
|
346,208,759.51 |
±1.07 |
|
306,325,098.74 |
±0.41 |
In this benchmark we are hoping to prove that there is no significant throughput degradation when using the alternative tagged type-inspired implementation over the Scala Option
. A significant degradation in performance would jeopardize the performance wins that we found in the creation benchmark. Fortunately, this benchmark suggests fold throughput actually increases approximately 13% over the Scala Option
fold implementation.
It is a relief to see benchmarking yield results that confirm your hypothesis. However, it is equally important to understand why favorable results were produced, and to be able to explain this. Without an understanding of how these results happened, you are unlikely to be able to reproduce the results. How would you explain fold throughput improvement of the tagged type-inspired implementation over the Scala Option
implementation? Consider the implementation and memory allocation differences that we covered.
The benchmarks suggest that the tagged type-inspired Option
implementation yields qualitative performance improvements over the Scala Option
implementation. If you are faced with a performance issue and profiling reveals the Scala Option
to be the bottleneck, it may make sense to explore this alternative implementation. While the performance improves, realize that a tradeoff exists. When using the alternative implementation, you lose the ability to pattern match. This seems like a small price to pay because you are able to instead use the fold operation. The higher price to pay is integration with the standard library and third-party libraries. If your critical path code interacts heavily with the Scala standard library or a third-party library, you will be forced to rewrite significant chunks of code to use the alternative Option
implementation. In this scenario, if you are under time pressure, it may make sense to reconsider whether or not modeling parts of the domain with null
is sensible. If your critical path code avoids significant interaction with the Scala standard library or third-party libraries, then using the alternative Option
implementation might be an easier decision.
Our case study is inspired by a novel approach Alexandre Bertails explores in his blog post at https://bertails.org/2015/02/15/abstract-algebraic-data-type/. He solves the same performance issues that we addressed by defining an approach that he refers to as abstract algebraic data types. Both approaches rely on using type constraints to model Option
without instance allocation. By abstracting over the Option
algebraic data type and its operations, he is able to devise an implementation free of allocations and boxing. We encourage you to explore this approach because it is another great example of how to achieve safety while still providing excellent performance.