I’ve recently learned how to use two complimentary static techniques for controlling how many times methods are called in an API: Phantom Types, and Scala type constraints.
Why would you want to control the number of times a method is called? Consider, for example, the common Builder Object pattern. I don’t mean the classic GoF Builder Pattern — I use builders all the time, and I don’t at all recognize this class diagram describing what a Builder is supposed to be. By Builder I mean an object with a fluid API whose job is to collect up state for the purpose of creating one or more other objects, which is pretty common in Java and many Scala DSLs.
Rafael Ferriera wrote a piece about builders and Phantom Types I’m going to use as a starting point for introduction of the topic. I’ll start with his introduction of a ScotchBuilder: a Scala object that knows how to take a proper gentleman’s order for a scotch.
Let me quote Raphael to introduce the domain:
So, let’s say you want to order a shot of scotch. You’ll need to ask for a few things: the brand of the whiskey, how it should be prepared (neat, on the rocks or with water) and if you want it doubled. Unless, of course, you are a pretentious snob, in that case you’ll probably also ask for a specific kind of glass, brand and temperature of the water and who knows what else. Limiting the snobbery to the kind of glass, here is one way to represent the order in scala.
You can imagine providing a ScotchBuilder class to generate immutable OrderOfScotch objects with a fluid API. Below is a first-pass at such a ScotchBuilder, which is your typical fluid implementation. Its nice, but we can do better. (Code, again, taken originally from Raphael’s post, modulo changing from stateful to stateless and fixing a couple of his typos)
case class ScotchBuilder(
theBrand :Option[String] = None,
theMode :Option[Preparation] = None,
theDoubleStatus :Option[Boolean] = None,
theGlass :Option[Glass] = None) {
def withBrand(b:String) = copy(theBrand = Some(b))
def withMode(p:Preparation) = copy(theMode = Some(p))
def isDouble(b:Boolean) = copy(theDoubleStatus = Some(b))
def withGlass(g:Glass) = copy(theGlass = Some(g))
def build() = new OrderOfScotch(theBrand.get, theMode.get, theDoubleStatus.get, theGlass);
}
There are two unattractive features with this Builder that we are going to clean up:
- a client can re-invoke the same setter methods over and over
- the client can also completely forget to call other methods that should be called
Check this out, where I am able to make a non-sense scotch order:
Welcome to Scala version 2.8.1.final (Java HotSpot(TM) 64-Bit Server VM, Java 1.
6.0_21).
Type in expressions to have them evaluated.
Type :help for more information.
scala> val b = new ScotchBuilder
b: ScotchBuilder = ScotchBuilder@6f77e5d4
scala> b withBrand "Cragganmore" withMode Neat isDouble false withBrand "Macallan 25 year" isDouble true build
res3: OrderOfScotch = OrderOfScotch(Macallan 25 year,Neat,true,None)
Notice I was able to specify the brand twice, and the size as both “double” and “single”. Quite ambiguous what I meant there. Considering the price difference between a single Cragganmore (cheap) and a double Macallan 25 (expensive!), that’s maybe an ambiguity we’d like to stamp out of the system.
I’m going to now show you how to use both Phantom Types and type constraints to ensure at compile time certain Builder methods are invoked:
- with at-most-once semantics. E.g., withGlass should be called zero or one time by client code for a single ScotchBuilder instance.
- with exactly-once semantics. E.g., withBrand, withMode, and isDouble each need to be called exactly once.
- and, by the way, you can use this same technique to define one-or-more-times semantics. (Though I’m not going to go that far in this article. If you grok at-most-once and exactly-once, you will be able to figure out one-or-more-times.)
These techniques are not limited to just fluid Builder APIs. Pretty much any API where you wanted to constraint the call semantics can employ Phantom Types and type constraints to achieve the desired call semantics. This is going to apply to objects that traditionally walk through a lifecycle. For example, any API where there are init() and destroy(). The Builder under consideration also has a lifecycle: several configuration methods must be called, and then finally the build method gets invoked.
Continuing the Builder example, first I’m going to stop the build method from working if there are any builder values that haven’t been set correctly. That is, using Phantom Types and type constraints, I’m going to set things up so that compiler won’t even compile code that attempts to build a scotch when all the builder parameters have not been set. After that I’m going to stop the individual withXYZ methods from being called more than once using the same technique.
Here’s how I’m going to do make the build method uncallable except when the Builder has been fully configured: I’m going to add to the ScotchBuilder class one type parameter per method whose calls we want to track. The type parameters are going to track whether each of the withXYZ() methods have been called or not; the classes Zero and Once are defined to represent these two states. I’m then going to constrain the build method to only be callable if the appropriate type parameters are bound to the Once type.
So I’m adding 4 type parameters, each able to be bound to the type Zero or Once. So instead of having 1 ScotchBuilder class, I actually am defining 16. That is, 16 different permutations of the possible bindings to the 4 type parameters. The build method will then be constraining to be callable on ScotchBuilder[Once, Once, Once, _] (one of 2 specific bindings).
This first chunk of code adds the Zero and Once types to track the number of times the individual withXYZ() methods are called below. Note that the case class copy method allows you to specify type parameters, which I’m using in the implementation of each withXYZ method:
abstract sealed class Count
case class Zero extends Count
case class Once extends Count
object ScotchBuilder {
def apply() = new ScotchBuilder[Zero, Zero, Zero, Zero]()
}
case class ScotchBuilder
[WithBrandTracking <: Count,
WithModeTracking <: Count,
IsDoubleTracking <:Count,
WithGlassTracking <: Count] (
theBrand :Option[String] = None,
theMode :Option[Preparation] = None,
theDoubleStatus :Option[Boolean] = None,
theGlass :Option[Glass] = None) {
def withBrand(b:String) = copy[Once, WithModeTracking, IsDoubleTracking, WithGlassTracking](theBrand = Some(b))
def withMode(p:Preparation) = copy[WithBrandTracking, Once, IsDoubleTracking, WithGlassTracking](theMode = Some(p))
def isDouble(b:Boolean) = copy[WithBrandTracking, WithModeTracking, Once, WithGlassTracking](theDoubleStatus = Some(b))
def withGlass(g:Glass) = copy[WithBrandTracking, WithModeTracking, IsDoubleTracking, Once](theGlass = Some(g))
//...build() method definition with appropriate type constraints is just below...
}
And below is the build method, with type constraints guaranteeing the build method can only be called on instances of type ScotchBuilder[Once, Once, Once, _].
case class ScotchBuilder[...] {
...
type IsOnce[T] = =:=[T, Once]
...
def build[B <: WithBrandTracking : IsOnce, M <: WithModeTracking : IsOnce, D <: IsDoubleTracking : IsOnce] =
new OrderOfScotch(withBrand.get, withMode.get, isDoubleStatus.get, withGlass)
...
}
I use the =:= type class to guarantee this constraint on the ScotchBuilder type parameters. An implicit value of =:=[A, B] only exists when A == B. (For a deeper explanation of the =:= type constraining object, check out this blog post by Debasish Ghosh). I’ve further created a type alias IsOnce[T] = =:=[T, Once], which allows me to apply =:= as a type class.
The upshot of all of this is that any attempt to invoke build on a ScotchBuilder not matching ScotchBuilder[Once, Once, Once, _] simply cannot be compiled. You literally cannot compile code that improperly uses a ScotchBuilder to build a order of scotch!
Note that in the code above we never actually create an instance of Zero or Once — these type parameter bindings are purely for compile-time bookkeeping. Hence the term Phantom Types, because these types are never instantiated nor participate at runtime.
We can even do a little better with the builder above by constraining the withXYZ methods to have exactly-once or at-most-once call semantics, as appropriate. This is going to make the compiler fail at the point where the API is being misused — i.e., where a withXYZ method is being used the second time for a single ScotchBuilkder instance. So it’ll be a lot easier when using this API to figure out what you did wrong. Here is the final version of ScotchBuilder:
sealed abstract class Preparation /* This is one way of coding enum-like things in scala */
case object Neat extends Preparation
case object OnTheRocks extends Preparation
case object WithWater extends Preparation
sealed abstract class Glass
case object Short extends Glass
case object Tall extends Glass
case object Tulip extends Glass
case class OrderOfScotch(val brand:String, val mode:Preparation, val isDouble:Boolean, val glass:Option[Glass])
abstract sealed class Count
case class Zero extends Count
case class Once extends Count
object ScotchBuilder {
def apply() = new ScotchBuilder[Zero, Zero, Zero, Zero]()
}
case class ScotchBuilder
[WithBrandTracking <: Count,
WithModeTracking <: Count,
IsDoubleTracking <:Count,
WithGlassTracking <: Count] (
theBrand :Option[String] = None,
theMode :Option[Preparation] = None,
theDoubleStatus :Option[Boolean] = None,
theGlass :Option[Glass] = None) {
type IsOnce[T] = =:=[T, Once]
type IsZero[T] = =:=[T, Zero]
def withBrand[B <: WithBrandTracking : IsZero](b:String) =
copy[Once, WithModeTracking, IsDoubleTracking, WithGlassTracking](theBrand = Some(b))
def withMode[M <: WithModeTracking : IsZero](p:Preparation) =
copy[WithBrandTracking, Once, IsDoubleTracking, WithGlassTracking](theMode = Some(p))
def isDouble[D <: WithModeTracking : IsZero](b:Boolean) =
copy[WithBrandTracking, WithModeTracking, Once, WithGlassTracking](theDoubleStatus = Some(b))
def withGlass[G <: WithGlassTracking : IsZero](g:Glass) =
copy[WithBrandTracking, WithModeTracking, IsDoubleTracking, Once](theGlass = Some(g))
def build[B <: WithBrandTracking : IsOnce, M <: WithModeTracking : IsOnce, D <: IsDoubleTracking : IsOnce] =
new OrderOfScotch(withBrand.get, withMode.get, isDoubleStatus.get, withGlass)
}
All this extra type bookkeeping is well worth it for me because it means I don’t have to write a bunch more test code. I never need to worry or test for cases where client is is misusing my ScotchBuilder: it simply is not possible to do. And there’s never a need to write regression tests against a case which is impossible.