Last active
October 24, 2024 17:55
-
-
Save manjuraj/21330b3abd2465fe3ce9 to your computer and use it in GitHub Desktop.
typesafe builders in scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// References: | |
// - http://www.tikalk.com/java/type-safe-builder-scala-using-type-constraints/ | |
// - http://www.blumenfeld-maso.com/2011/05/statically-controlling-calls-to-methods-in-scala/ | |
// - http://dcsobral.blogspot.com/2009/09/type-safe-builder-pattern.html | |
// - http://blog.rafaelferreira.net/2008/07/type-safe-builder-pattern-in-scala.html | |
// - http://jim-mcbeath.blogspot.com/2009/09/type-safe-builder-in-scala-part-4.html | |
// - http://villane.wordpress.com/2010/03/05/taking-advantage-of-scala-2-8-replacing-the-builder/ | |
// - http://debasishg.blogspot.com/2010/08/using-generalized-type-constraints-how.html#sthash.GKfUGq9p.dpuf | |
// | |
// | |
// Typesafe builder using implicit parameters and equality type constraint =:= | |
// | |
class Builder private (i: Int) { | |
def this() = this(-1) | |
def withProperty(i: Int) = new Builder(i) | |
def build() = println(i) | |
} | |
scala> new Builder().build // withProperty not called (runtime error) | |
-1 | |
scala> new Builder().withProperty(1).withProperty(2).build // withProperty called twice (runtime error) | |
2 | |
// Tell the compiler whether the builder is complete or not by encoding | |
// this info as a type as use this encoding in the generic type of the | |
// builder class | |
sealed trait TBoolean | |
sealed trait TTrue extends TBoolean | |
sealed trait TFalse extends TBoolean | |
class Builder[HasProperty <: TBoolean] private(i: Int) { | |
def this() = this(-1) | |
def withProperty(i: Int) = new Builder[TTrue](i) | |
def build() = println(i) | |
} | |
object Builder { | |
def apply() = new Builder[TFalse]() | |
} | |
// With this repr: | |
// - complete builder is of type Builder[TTrue] | |
// - incomplete builder is of type Builder[TFalse] | |
// To make it typesafe, we want the call to build to be compiled | |
// only if invoked on Builder[TTrue]. We do this by using generic | |
// type constraints and implicit parameters | |
// We can achieve this through an implicit parameter that acts as | |
// evidence that HasProperty is TTrue. If HasProperty is TTrue, | |
// there is an instance that the compiler can find and so call | |
// to build is compile time safe. If HasProperty is TFalse, then | |
// there is no such instance and the compiler will issue a | |
// warning | |
// A =:= B defines that type A and type B are one and the same | |
sealed abstract class =:=[From, To] extends (From => To) | |
object =:= { | |
implicit def tpEquals[A]: A =:= A = new (A =:= A) { | |
def apply(x: A) = x | |
} | |
} | |
scala> implicitly[Int =:= Int] | |
res2: =:=[Int,Int] = <function1> | |
scala> implicitly[Int =:= Double] | |
<console>:8: error: Cannot prove that Int =:= Double. | |
implicitly[Int =:= Double] | |
// So, with the =:= as a mechansim to enforce type constraints, we can | |
// add appropriate type constraints to withProperty() and build() method | |
// so that they only work on right instances of builder | |
class Builder[HasProperty <: TBoolean] private(i: Int) { | |
def this() = this(-1) | |
def withProperty(i: Int)(implicit ev: HasProperty =:= TFalse) = new Builder[TTrue](i) | |
def build()(implicit ev: HasProperty =:= TTrue) = println(i) | |
} | |
object Builder { | |
def apply() = new Builder[TFalse] | |
} | |
scala> Builder().build | |
<console>:13: error: Cannot prove that TFalse =:= TTrue. | |
Builder().build | |
^ | |
scala> Builder().withProperty(2).withProperty(3) | |
<console>:13: error: Cannot prove that TTrue =:= TFalse. | |
Builder().withProperty(2).withProperty(3) | |
^ | |
scala> Builder().withProperty(2).build() | |
2 | |
scala> Builder().withProperty(2).build | |
2 | |
// | |
// Control statically how many times methods are called in an API using a combination of: | |
// - Phantom Types, and | |
// - Generic Type Constraints | |
// | |
// | |
// Domain: scotch builder | |
// - brand of shiskey | |
// - how it should be prepared | |
// - kind of glass | |
// - brand | |
// | |
sealed trait Preparation | |
case object Neat extends Preparation | |
case object OnTheRocks extends Preparation | |
case object WithWater extends Preparation | |
sealed trait Glass | |
case object Short extends Glass | |
case object Tall extends Glass | |
case object Tulip extends Glass | |
case class OrderOfScotch(brand: String, mode: Preparation, isDouble: Boolean, val glass: Option[Glass]) | |
case class ScotchBuilder( | |
brand: Option[String] = None, | |
mode: Option[Preparation] = None, | |
doubleStatus: Option[Boolean] = None, | |
glass: Option[Glass] = None) { | |
def withBrand(b: String) = copy(brand = Some(b)) | |
def withMode(p: Preparation) = copy(mode = Some(p)) | |
def isDouble(b: Boolean) = copy(doubleStatus = Some(b)) | |
def withGlass(g: Glass) = copy(glass = Some(g)) | |
def build() = new OrderOfScotch(brand.get, mode.get, doubleStatus.get, glass); | |
} | |
// | |
// Issues with above builder: | |
// - client can re-invoke the same setter methods over and over again | |
// - client can also completely forget to call other methods that should be called | |
// | |
// | |
// Solution: use Phantom Types and Generic Type constraints to ensure at | |
// compile time only 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 | |
// - exactly-once semantics. E.g., withBrand, withMode, and isDouble each need | |
// to be called exactly once | |
// - one-or-more-times semantics | |
// | |
// The above technique are not just limited to Builer APIs. Pretty much any API | |
// where you wanted to constrain the call semantics can employ Phantom Types | |
// and Generic Type constraints to achieve the desired call semantics. This is | |
// going to apply to objects that traditionally walk through a life cycle. For | |
// example, any API where there are init() and destroy(). The Builder under | |
// construction also has a lifecycle: several configuration methods must be | |
// called, and then final the build method gets invoked | |
// | |
// Here, the build() method is uncallable except when the Builder is fully | |
// configured. ScotchBuilder class takes one type parameter per method | |
// whose calls we want to track. The type parameters are going to track | |
// whether each of the with() methods have been called or not; the Phantom | |
// Types - Zero and Once are defined to represent these two states. The | |
// build() method is only callable if the appropriate type parameters | |
// are bound to Once Type | |
// | |
// So we add 4 type parameters, each able to be bound to the type | |
// Zero or Once. So instead of having 1 ScotchBuilder class, we actually | |
// are 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). | |
// | |
sealed trait Count | |
sealed trait Zero extends Count | |
sealed trait Once extends Count | |
object ScotchBuilder { | |
def apply() = new ScotchBuilder[Zero, Zero, Zero, Zero]() | |
} | |
case class ScotchBuilder[WithBrandTracking <: Count, WithModeTracking <: Count, IsDoubleTracking <: Count, WithGlassTracking <: Count]( | |
brand: Option[String] = None, | |
mode: Option[Preparation] = None, | |
doubleStatus: Option[Boolean] = None, | |
glass: Option[Glass] = None) { | |
def withBrand(b: String) = copy[Once, WithModeTracking, IsDoubleTracking, WithGlassTracking](brand = Some(b)) | |
def withMode(p: Preparation) = copy[WithBrandTracking, Once, IsDoubleTracking, WithGlassTracking](mode = Some(p)) | |
def isDouble(b: Boolean) = copy[WithBrandTracking, WithModeTracking, Once, WithGlassTracking](doubleStatus = Some(b)) | |
def withGlass(g: Glass) = copy[WithBrandTracking, WithModeTracking, IsDoubleTracking, Once](glass = Some(g)) | |
def build()(implicit evb: WithBrandTracking =:= Once, evm: WithModeTracking =:= One, evds: IsDoubleTracking =:= Once)= { | |
new OrderOfScotch(brand.get, mode.get, doubleStatus.get, glass) | |
} | |
} | |
// | |
// We use the =:= type class to guarantee this constraint on the | |
// ScotchBuilder type parameters. An implicit value of =:=[A, B] only | |
// exists when A == B. We further created a type alias | |
// IsOnce[T] = =:=[T, Once], which allows us to apply =:= as a | |
// type class and use context bound syntax | |
// | |
// 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 with() 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 with() | |
// 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 | |
// | |
sealed trait Count | |
sealed trait Zero extends Count | |
sealed trait Once extends Count | |
object ScotchBuilder { | |
def apply() = new ScotchBuilder[Zero, Zero, Zero, Zero]() | |
} | |
case class ScotchBuilder[WithBrandTracking <: Count, WithModeTracking <: Count, IsDoubleTracking <: Count, WithGlassTracking <: Count]( | |
brand: Option[String] = None, | |
mode: Option[Preparation] = None, | |
doubleStatus: Option[Boolean] = None, | |
glass: 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](brand = Some(b)) | |
def withMode[M <: WithModeTracking : IsZero](p: Preparation) = copy[WithBrandTracking, Once, IsDoubleTracking, WithGlassTracking](mode = Some(p)) | |
def isDouble[D <: IsDoubleTracking : IsZero](b: Boolean) = copy[WithBrandTracking, WithModeTracking, Once, WithGlassTracking](doubleStatus = Some(b)) | |
def withGlass[G <: WithGlassTracking : IsZero](g: Glass) = copy[WithBrandTracking, WithModeTracking, IsDoubleTracking, Once](glass = Some(g)) | |
def build[B <: WithBrandTracking : IsOnce, M <: WithModeTracking : IsOnce, D <: IsDoubleTracking : IsOnce]() = { | |
new OrderOfScotch(brand.get, mode.get, doubleStatus.get, glass) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment