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.
As before, remember toobject 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) } }
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:
Each of the following will give a compiler error: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
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.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
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.
To get the mass, we have to call all of the appropriate setters, including density, then callobject 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) } }
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
.
These examples fail:val p = Pyramid().setHeight(2).setWidth(3).setArea(6).setDensity(2).build2 p.volume //returns 4 p.mass //returns 8
I intentionally left out one line when adding the density code, so the following code compiles: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
Since thePyramid().setHeight(2).setWidth(3).setArea(6).setDensity(2).setDensity(3).build
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.