Skip to content

Instantly share code, notes, and snippets.

@drdozer
Last active January 1, 2017 23:59
Show Gist options
  • Save drdozer/ecc0df3685c4a3f1b1c51bb21406bfb3 to your computer and use it in GitHub Desktop.
Save drdozer/ecc0df3685c4a3f1b1c51bb21406bfb3 to your computer and use it in GitHub Desktop.
/* This models a door.
Doors can be opened and closed, and can also be locked and unlocked.
However, a door can only be opened and closed when locked, and can only be locked and unlocked when closed.
The exercise is to write a fully type-safe, tagless API for this that only exposes legal moves, and where the door,
open/closed and locked/unlocked implementations can potentially be freely composed.
*/
/* We start with a door.
This is the tagless API, which is intended to be purely abstract, so there's no way to construct a door.
It is up to the choice of representation to pick a representation of a door.
*/
sealed trait Door
/* We can model the state of being locked and unlocked as a type constructor wrapping another type.
This allows us to freely compose lockability with other properties, and to make lockable things that aren't doors.
*/
sealed trait Lockable[T]
sealed trait Unlocked[T] extends Lockable[T]
sealed trait Locked[T] extends Lockable[T]
/* Likewise, we can model the state of being open or closed as a type constructor, allowing us to freely compose
openability with other properties, and to make openable things that aren't doors.
*/
sealed trait Openable[T]
sealed trait Opened[T] extends Openable[T]
sealed trait Closed[T] extends Openable[T]
/*
This is the operations AST for a lockable thing. It can be locked and unlocked, but only when in the correct state.
Notice that we don't directly manipulate a locked or unlocked thing, but instead manipulate it via its representation.
*/
trait LockableAst[Rep[_]] {
def lock[T](u: Rep[Unlocked[T]]): Rep[Locked[T]]
def unlock[T](l: Rep[Locked[T]]): Rep[Unlocked[T]]
}
/*
This is the operation AST for an openable thing. It can be opened and closed, but only when in the correct state.
This AST has a couple of methods, `whenClosed` and `whenOpened` that allow the wrapped type to be manipulated when in
the open or closed state.
*/
trait OpenableAst[Rep[_]] {
def open[T](c: Rep[Closed[T]]): Rep[Opened[T]]
def close[T](o: Rep[Opened[T]]): Rep[Closed[T]]
def whenClosed[T1, T2](f: Rep[T1] => Rep[T2], c: Rep[Closed[T1]]): Rep[Closed[T2]]
def whenOpened[T1, T2](f: Rep[T1] => Rep[T2], o: Rep[Opened[T1]]): Rep[Opened[T2]]
}
/*
A lockable door.
This lets you lock and unlock the door.
*/
trait LockableDoorAst[Rep[_]] {
def lockDoor(u: Rep[Unlocked[Door]]): Rep[Locked[Door]]
def unlockDoor(l: Rep[Locked[Door]]): Rep[Unlocked[Door]]
}
/*
This is the obvious derivation of a lockable door from a lockable.
*/
implicit def LockableDoorAst[Rep[_]](implicit LockableAst: LockableAst[Rep]) : LockableDoorAst[Rep] = new LockableDoorAst[Rep] {
override def lockDoor(u: Rep[Unlocked[Door]]): Rep[Locked[Door]] = LockableAst.lock(u)
override def unlockDoor(l: Rep[Locked[Door]]): Rep[Unlocked[Door]] = LockableAst.unlock(l)
}
/*
An openable, lockable door.
It can be opened and closed when unlocked, and can be locked and unlocked when closed.
*/
trait OpenableLockableDoorAst[Rep[_]] {
def open(cu: Rep[Closed[Unlocked[Door]]]): Rep[Opened[Unlocked[Door]]]
def close(ou: Rep[Opened[Unlocked[Door]]]): Rep[Closed[Unlocked[Door]]]
def lock(cu: Rep[Closed[Unlocked[Door]]]): Rep[Closed[Locked[Door]]]
def unlock(cl: Rep[Closed[Locked[Door]]]): Rep[Closed[Unlocked[Door]]]
}
/*
This is the obvious implementation of an openable, lockable door, given an implementation for opening and locking.
*/
implicit def OpenableLockableDoorAst[Rep[_]](implicit OpenableAst: OpenableAst[Rep], LockableDoorAst: LockableDoorAst[Rep]): OpenableLockableDoorAst[Rep] = new OpenableLockableDoorAst[Rep] {
override def open(cu: Rep[Closed[Unlocked[Door]]]): Rep[Opened[Unlocked[Door]]] = OpenableAst.open(cu)
override def close(ou: Rep[Opened[Unlocked[Door]]]): Rep[Closed[Unlocked[Door]]] = OpenableAst.close(ou)
override def lock(cu: Rep[Closed[Unlocked[Door]]]): Rep[Closed[Locked[Door]]] = OpenableAst.whenClosed(LockableDoorAst.lockDoor, cu)
override def unlock(cl: Rep[Closed[Locked[Door]]]): Rep[Closed[Unlocked[Door]]] = OpenableAst.whenClosed(LockableDoorAst.unlockDoor, cl)
}
/*
Here we put it all together, as a function that will ake a closed, locked door and then unlock it, open it, close it and lock it again.
It demonstrates the type-safety of these manipulations.
It is impossible to go through the OpenLockableDoor interface and perform an illegal move. You can't open a locked door,
or close a closed door, for example.
*/
def unlockOpen[Rep[_]](d: Rep[Closed[Locked[Door]]])(implicit OpenableLockableDoorAst: OpenableLockableDoorAst[Rep]): Rep[Opened[Unlocked[Door]]] = {
import OpenableLockableDoorAst._
open(unlock(d))
}
/*
It is possible to circumvent this safety by directly getting hold of an Openable instance for the lockable door.
With this, you could open and close a locked door by calling open/close.
To prevent this from being possible, we could make a new type for the openable, lockable door and define its Ast in
terms of that, unwrapping the new type for the implementation only.
*/
/*
Ony the Lockable and Openable ASTs provide unimplemented primitive operations.
All the other ASTs are defined in terms of these.
So, to provide an implementation of these APIs, we just need to provide an implementation of these two.
*/
/*
Our first implementation will be `FrontDoor`, which has boolean flags for if it is open and unlocked.
*/
case class FrontDoor(isOpen: Boolean, isLocked: Boolean)
/*
Notice that there's nothing about this case class that enforces any of our constraints.
It is just a dumb case class with no contracts.
We need some representation container. In this very simple case, we are only ever going to be working with FrontDoor
instances, so we can fudge it with a const type:
*/
type FrontDoorRep[T] = FrontDoor
/*
Now we can provide the instances for LockableAst and OpenableAst.
Mostly these just use the case class copy constructor to flip flags.
*/
implicit object MyDoorLockableAst extends LockableAst[FrontDoorRep] {
override def lock[T](u: FrontDoorRep[Unlocked[T]]): FrontDoorRep[Locked[T]] = u.copy(isLocked = true)
override def unlock[T](l: FrontDoorRep[Locked[T]]): FrontDoorRep[Unlocked[T]] = l.copy(isLocked = false)
}
implicit object FrontDoorOpenableAst extends OpenableAst[FrontDoorRep] {
override def open[T](c: FrontDoorRep[Closed[T]]): FrontDoorRep[Opened[T]] = c.copy(isOpen = true)
override def close[T](o: FrontDoorRep[Opened[T]]): FrontDoorRep[Closed[T]] = o.copy(isOpen = false)
override def whenClosed[T1, T2](f: (FrontDoorRep[T1]) => FrontDoorRep[T2],
c: FrontDoorRep[Closed[T1]]): FrontDoorRep[Closed[T2]] = f(c)
override def whenOpened[T1, T2](f: (FrontDoorRep[T1]) => FrontDoorRep[T2],
o: FrontDoorRep[Opened[T1]]): FrontDoorRep[Opened[T2]] = f(o)
}
/*
The implementations of `whenClosed` and `whenOpened` are a bit odd.
They just apply the function directly to the door.
This is because in this representation, we are using the same implementation datatype regardless of if the AST datatype
is open or closed, locked or unlocked, or just a plain door.
We can now manipulate a `FrontDoor` using a program built using our AST.
First we need a factory to make a door:
*/
trait DoorFactoryAst[Repr[_]] {
def closedLockedDoor: Repr[Closed[Locked[Door]]]
}
implicit object MyDoorFactoryAst extends DoorFactoryAst[FrontDoorRep] {
override def closedLockedDoor: FrontDoorRep[Closed[Locked[Door]]] = FrontDoor(isOpen = false, isLocked = true)
}
val uo = unlockOpen[FrontDoorRep](implicitly[DoorFactoryAst[FrontDoorRep]].closedLockedDoor)
println(s"Started with ${implicitly[DoorFactoryAst[FrontDoorRep]].closedLockedDoor}, unlocked it and opened it, and ended up with $uo")
/*
You can see how we've taken a completely contract-less, dumb implementation datatype, and through the use of our DSL,
guaranteed that we've manipulated it exactly according to our domain contracts.
I think that's quite neat.
You choose the DSL that encodes the contract you want, map it down onto an aribtrary datatype, and "hey presto!" that
datatype will now conform to your contract.
*/
/*
Let's build another interpretation of our language, this time in `List[String]`.
The interpretation this time is purely going to log the operations performed into a list of messages.
*/
type LogOps[T] = List[String]
implicit object LogOpsLockableAst extends LockableAst[LogOps] {
override def lock[T](u: LogOps[Unlocked[T]]): LogOps[Locked[T]] = "locking" :: u
override def unlock[T](l: LogOps[Locked[T]]): LogOps[Unlocked[T]] = "unlocking" :: l
}
implicit object LogOpsOpenableAst extends OpenableAst[LogOps] {
override def open[T](c: LogOps[Closed[T]]): LogOps[Opened[T]] = "openning" :: c
override def close[T](o: LogOps[Opened[T]]): LogOps[Closed[T]] = "closing" :: o
override def whenClosed[T1, T2](f: (LogOps[T1]) => LogOps[T2], c: LogOps[Closed[T1]]): LogOps[Closed[T2]] = {
val fc = f(c)
s"${fc.head} when closed" :: fc.tail
}
override def whenOpened[T1, T2](f: (LogOps[T1]) => LogOps[T2], o: LogOps[Opened[T1]]): LogOps[Opened[T2]] = {
val fo = f(o)
s"${fo.head} when opened" :: f(o).tail
}
}
implicit object LogOpsDoorFactoryAst extends DoorFactoryAst[LogOps] {
override def closedLockedDoor: LogOps[Closed[Locked[Door]]] = "a closed, locked door" :: Nil
}
val uoStr = unlockOpen[LogOps](implicitly[DoorFactoryAst[LogOps]].closedLockedDoor)
println(s"Logged operations: ${uoStr.reverse.mkString(" ==> ")}")
/*
As you can see, this is correctly collecting all the primitive operations into a list.
As you can also see, all the fancy contract-enforcing opperations in `OpenableLockableDoorAst` have been staged away.
They were run once when building the door unlocking and opening program, and then are nowhere to be seen.
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment