Thursday, September 10, 2009

Type Safe Builder in Scala, Part 4

A type-safe builder with mutually exclusive parameters.

In my previous three posts I presented various versions of a type-safe builder that enforced, at compile time, that required setters were called exactly once and optional setters were called no more than once. In those examples there was a one-to-one correspondence between the setters (such as withBrand) and type variables that were used to enforce the number of calls to those methods (such as HAS_BRAND). We can use the type variables in different ways to change what combination of methods calls is allowed. In particular, we can have multiple parameters which can be set in various combinations, only some of which are allowed. For example, we can have two mutually exclusive parameters, where you must set either one but not the other.

To show how this works, I have created a builder for a Pyramid calculator that can be used to calculate the physical parameters of a rectangular pyramid. After creating a builder, you can call various setter methods to set some of the physical parameters of the pyramid, including the length, width and area of the base, the height, and the length of an upright edge. You must set exactly two out of three of the length, width and area of the base, and you must set one but not both of the height and edge. Once you have done that, you call build to get back the calculator from which you can retrieve any of the five physical parameters listed above plus volume. If you call too few or too many of the setters in each group, the call to build will not compile.
object Pyramid { //A small collection of class types to define a state machine that counts abstract class COUNTER { type Count <: COUNTER } abstract class MANY extends COUNTER { type Count = MANY } abstract class TWO extends COUNTER { type Count = MANY } abstract class ZERO_OR_ONE extends COUNTER abstract class ONE extends ZERO_OR_ONE { type Count = TWO } abstract class ZERO extends ZERO_OR_ONE { type Count = ONE } //We require positive values for our calls class Positive(val d:Double) { if (d<=0) throw new IllegalArgumentException("non-positive value") } implicit def doubleToPositive(d:Double) = new Positive(d) implicit def intToPositive(n:Int) = new Positive(n) //The class that manages the state of our specification class Specs private[Pyramid]() { self:Specs => //Caller must set exactly two out of three of these val length:Double = 0 val width:Double = 0 val area:Double = 0 //Caller must set exactly one of these two heights val height:Double = 0 //vertical height to the tip val edge:Double = 0 //from base to tip along an edge //We maintain compiler-time state to count the two types of calls type TT <: { type COUNT_LENGTH <: COUNTER type COUNT_WIDTH <: COUNTER type COUNT_AREA <: COUNTER type COUNT_BASE <: COUNTER // length, width or area type COUNT_HEIGHT <: COUNTER type COUNT_EDGE <: COUNTER type COUNT_VERT <: COUNTER // height or edge } class SpecsWith(bb:Specs) extends Specs { override val length = bb.length override val width = bb.width override val area = bb.area override val height = bb.height override val edge = bb.edge } def setLength(d:Positive) = new SpecsWith(this) { override val length:Double = d.d type TT = self.TT { type COUNT_LENGTH = self.TT#COUNT_LENGTH#Count type COUNT_BASE = self.TT#COUNT_BASE#Count } } def setWidth(d:Positive) = new SpecsWith(this) { override val width:Double = d.d type TT = self.TT { type COUNT_WIDTH = self.TT#COUNT_WIDTH#Count type COUNT_BASE = self.TT#COUNT_BASE#Count } } def setArea(d:Positive) = new SpecsWith(this) { override val area:Double = d.d type TT = self.TT { type COUNT_AREA = self.TT#COUNT_AREA#Count type COUNT_BASE = self.TT#COUNT_BASE#Count } } def setHeight(d:Positive) = new SpecsWith(this) { override val height:Double = d.d type TT = self.TT { type COUNT_HEIGHT = self.TT#COUNT_HEIGHT#Count type COUNT_VERT = self.TT#COUNT_VERT#Count } } def setEdge(d:Positive) = new SpecsWith(this) { override val edge:Double = d.d type TT = self.TT { type COUNT_EDGE = self.TT#COUNT_EDGE#Count type COUNT_VERT = self.TT#COUNT_VERT#Count } } } //Starting point: nothing is set def apply() = new Specs { type TT = { type COUNT_LENGTH = ZERO type COUNT_WIDTH = ZERO type COUNT_AREA = ZERO type COUNT_BASE = ZERO type COUNT_HEIGHT = ZERO type COUNT_EDGE = ZERO type COUNT_VERT = ZERO } } //Required ending point: two base measures, one height measure, //no single parameter more than once type CompleteSpecs = Specs { type TT <: { type COUNT_LENGTH <: ZERO_OR_ONE type COUNT_WIDTH <: ZERO_OR_ONE type COUNT_AREA <: ZERO_OR_ONE type COUNT_BASE = TWO type COUNT_HEIGHT <: ZERO_OR_ONE type COUNT_EDGE <: ZERO_OR_ONE type COUNT_VERT = ONE } } //Calc1 includes the first set of values that can be calculated class Calc1 private[Pyramid](spec:CompleteSpecs) { import java.lang.Math.sqrt //The three related base measures lazy val length = if (spec.length!=0) spec.length else spec.area/spec.width lazy val width = if (spec.width!=0) spec.width else spec.area/spec.length lazy val area = if (spec.area!=0) spec.area else spec.length*spec.width //The two related height measures lazy val height = if (spec.height!=0) spec.height else sqrt(spec.edge*spec.edge-length*length/4-width*width/4) lazy val edge = if (spec.edge!=0) spec.edge else sqrt(length*length/4+width*width/4+spec.height*spec.height) lazy val volume = length * width * height / 3 } implicit def specsOK(spec:CompleteSpecs) = new { def build = new Calc1(spec) } }
As before, remember to import Pyramid._ when using this code.

Let's examine the code.

To start, I have a small type-based state machine, similar to what I used in my previous post. I changed the names of the classes to more accurately reflect the fact that I am counting the number of calls to methods, and I added a class to represent a count of two as distinct from larger numbers.

Next I define a class that ensures that the values passed to the setters are all strictly positive numbers (since they represent physical quantities), and I add a couple of implicit conversion methods to allow the caller to pass in ints or doubles. If the caller passes in a negative number, the builder will throw a runtime exception.

The Specs class is where I maintain my state information as the builder is being constructed. I use the same basic approach as in my previous post, with a set of parameter values and a set of compile-time constraints, the latter represented by the TT compound type. Note that in addition to the type parameter values that are directly associated with the parameters, which are used to ensure that each individual setter is called not more than once, there are two additional type values that do not directly correspond to parameter values or individual setters. The COUNT_BASE type value is associated with the length, width and area parameters, while the COUNT_VERT type value is associated with the height and edge parameters. This association is specified in the setter methods.

The SpecsWith class allows me to default all values to the previous step in the builder chain, so that I can override just the value I want to change in each setter.

Each of the five setters sets its parameter value, sets a new value for its individual type value counter, and also sets a new type value for one of the non-individual counters. Note how the setters for length, width and area all refer to COUNT_BASE, while the setters for height and edge both refer to COUNT_VERT.

I chose to define an apply() method rather than call it builder. This allows me to start my builder chain by specifying just Pyramid().

The CompleteSpecs type definition defines the end point that will be valid for a call to build. You can see here how it requires that COUNT_BASE be TWO and COUNT_HEIGHT be ONE. The other call counts can be zero or one.

Calc1 is the class that actually does the calculation of the physical parameters.

Finally, the specsOK implicit method provides the link that allows only a complete builder to call the build method that returns the calculator.

Here is an example of how you use it:
import Pyramid._ //we need the implicit conversion to be in scope val p = Pyramid().setLength(10).setWidth(8).setHeight(6).build p.length //returns 10 p.area //returns 80 p.volume //returns 160
Each of the following will give a compiler error:
Pyramid().setWidth(2).setHeight(2).build //only one BASE param, need 2 Pyramid().setWidth(2).setLength(3).setArea(6).setHeight(2).build //too many BASE params Pyramid().setWidth(2).setWidth(3).setHeight(2).build //setWidth called twice
Note the second example: even though the base area (6) is compatible with the width and length, this line gives a compiler error because three BASE parameters were specified; the compile time checks do not look at the values of the parameters.

Let's add a parameter for density such that, if we have called the setter for density, we can get back a calculator that can tell us the mass of the pyramid as well as everything else. The code below shows, in bold, what needs to be added to the above example in order to do that.
object Pyramid { //A small collection of class types to define a state machine that counts abstract class COUNTER { type Count <: COUNTER } abstract class MANY extends COUNTER { type Count = MANY } abstract class TWO extends COUNTER { type Count = MANY } abstract class ZERO_OR_ONE extends COUNTER abstract class ONE extends ZERO_OR_ONE { type Count = TWO } abstract class ZERO extends ZERO_OR_ONE { type Count = ONE } //We require positive values for our calls class Positive(val d:Double) { if (d<=0) throw new IllegalArgumentException("non-positive value") } implicit def doubleToPositive(d:Double) = new Positive(d) implicit def intToPositive(n:Int) = new Positive(n) //The class that manages the state of our specification class Specs private[Pyramid]() { self:Specs => //Caller must set exactly two out of three of these val length:Double = 0 val width:Double = 0 val area:Double = 0 //Caller must set exactly one of these two heights val height:Double = 0 //vertical height to the tip val edge:Double = 0 //from base to tip along an edge //Optional value; if set, we can calculate mass val density:Double = 0 //We maintain compiler-time state to count the two types of calls type TT <: { type COUNT_LENGTH <: COUNTER type COUNT_WIDTH <: COUNTER type COUNT_AREA <: COUNTER type COUNT_BASE <: COUNTER // length, width or area type COUNT_HEIGHT <: COUNTER type COUNT_EDGE <: COUNTER type COUNT_VERT <: COUNTER // height or edge type COUNT_DENSITY <: COUNTER } class SpecsWith(bb:Specs) extends Specs { override val length = bb.length override val width = bb.width override val area = bb.area override val height = bb.height override val edge = bb.edge override val density = bb.density } def setLength(d:Positive) = new SpecsWith(this) { override val length:Double = d.d type TT = self.TT { type COUNT_LENGTH = self.TT#COUNT_LENGTH#Count type COUNT_BASE = self.TT#COUNT_BASE#Count } } def setWidth(d:Positive) = new SpecsWith(this) { override val width:Double = d.d type TT = self.TT { type COUNT_WIDTH = self.TT#COUNT_WIDTH#Count type COUNT_BASE = self.TT#COUNT_BASE#Count } } def setArea(d:Positive) = new SpecsWith(this) { override val area:Double = d.d type TT = self.TT { type COUNT_AREA = self.TT#COUNT_AREA#Count type COUNT_BASE = self.TT#COUNT_BASE#Count } } def setHeight(d:Positive) = new SpecsWith(this) { override val height:Double = d.d type TT = self.TT { type COUNT_HEIGHT = self.TT#COUNT_HEIGHT#Count type COUNT_VERT = self.TT#COUNT_VERT#Count } } def setEdge(d:Positive) = new SpecsWith(this) { override val edge:Double = d.d type TT = self.TT { type COUNT_EDGE = self.TT#COUNT_EDGE#Count type COUNT_VERT = self.TT#COUNT_VERT#Count } } def setDensity(d:Positive) = new SpecsWith(this) { override val density:Double = d.d type TT = self.TT { type COUNT_DENSITY = self.TT#COUNT_DENSITY#Count } } } //Starting point: nothing is set def apply() = new Specs { type TT = { type COUNT_LENGTH = ZERO type COUNT_WIDTH = ZERO type COUNT_AREA = ZERO type COUNT_BASE = ZERO type COUNT_HEIGHT = ZERO type COUNT_EDGE = ZERO type COUNT_VERT = ZERO type COUNT_DENSITY = ZERO } } //Required ending point: two base measures, one height measure, //no single parameter more than once type CompleteSpecs = Specs { type TT <: { type COUNT_LENGTH <: ZERO_OR_ONE type COUNT_WIDTH <: ZERO_OR_ONE type COUNT_AREA <: ZERO_OR_ONE type COUNT_BASE = TWO type COUNT_HEIGHT <: ZERO_OR_ONE type COUNT_EDGE <: ZERO_OR_ONE type COUNT_VERT = ONE } } //Calc1 includes the first set of values that can be calculated class Calc1 private[Pyramid](spec:CompleteSpecs) { import java.lang.Math.sqrt //The three related base measures lazy val length = if (spec.length!=0) spec.length else spec.area/spec.width lazy val width = if (spec.width!=0) spec.width else spec.area/spec.length lazy val area = if (spec.area!=0) spec.area else spec.length*spec.width //The two related height measures lazy val height = if (spec.height!=0) spec.height else sqrt(spec.edge*spec.edge-length*length/4-width*width/4) lazy val edge = if (spec.edge!=0) spec.edge else sqrt(length*length/4+width*width/4+spec.height*spec.height) lazy val volume = length * width * height / 3 } implicit def specsOK(spec:CompleteSpecs) = new { def build = new Calc1(spec) } //Second set of allowable computations type CompleteSpecs2 = Specs { type TT <: { type COUNT_LENGTH <: ZERO_OR_ONE type COUNT_WIDTH <: ZERO_OR_ONE type COUNT_AREA <: ZERO_OR_ONE type COUNT_BASE = TWO type COUNT_HEIGHT <: ZERO_OR_ONE type COUNT_EDGE <: ZERO_OR_ONE type COUNT_VERT = ONE type COUNT_DENSITY = ONE } } class Calc2 private[Pyramid](spec:CompleteSpecs2) extends Calc1(spec) { lazy val mass = spec.density * volume } implicit def specsOK2(spec:CompleteSpecs2) = new { def build2 = new Calc2(spec) } }
To get the mass, we have to call all of the appropriate setters, including density, then call build2 rather than build to get our calculator. That will give us a calculator that can give us the mass value as well as all the values in the calculator returned by build.
val p = Pyramid().setHeight(2).setWidth(3).setArea(6).setDensity(2).build2 p.volume //returns 4 p.mass //returns 8
These examples fail:
Pyramid().setHeight(2).setWidth(3).setArea(6).build2 //no density Pyramid().setHeight(2).setWidth(3).setArea(6).setDensity(2).setDensity(3).build2 //density specified twice
I intentionally left out one line when adding the density code, so the following code compiles:
Pyramid().setHeight(2).setWidth(3).setArea(6).setDensity(2).setDensity(3).build
Since the build method returns a calculator that does not do anything with the density, this is perhaps not a problem. You can test your understanding of Scala types by figuring out what one line you need to add to the density example to make this last call fail to compile.

4 comments:

Daniel said...

Aside from the awful error messages, it really got to the point where it can be quite useful!

Hans said...

This is a really interesting and clever use of Scala's type system - I enjoyed it.

(What I would need are managers, who also see the big advantages of Scala over Java.)

Toland said...

I've only skimmed your blog up until now. I couldn't have imagined this was possible. Great series :)

seance said...

Hi Jim,

Thanks for the great article. It's still very valid after over 2 years!

I created a little builder using structural types as in the article, but also using generalized type constraints and context bound.

Please check it out at https://github.com/seance/Sakismet!

Thanks in advance for any comments!