Consider a class like the following ..
// a sample task in a project case class Task(name: String) // a project with a list of tasks & dependencies amongst the // various tasks case class Project(name: String, startDate: java.util.Date, endDate: Option[java.util.Date] = None, tasks: List[Task] = List(), deps: List[(Task, Task)] = List())
We can always use the algebraic data type definition above to add tasks and dependencies to a project. Besides being cumbersome as a user level API, it also is a way to program too close to the implementation. The user is coupled to the fact that we use a
List
to store tasks, making it difficult to use any alternate implementation in the future. We can offer a Builder like OO interface with fluent APIs, but that also adds to the verbosity of implementation, makes builders mutable and is generally more difficult to compose with other generic functional abstractions. Ideally we should be having a DSL that lets users create projects and add tasks and dependencies to them.
In this post I will discuss a few functional abstractions that will stay behind from the user APIs, and yet provide the compositional power to wire up the DSL. This is a post inspired by this post which discusses a similar DSL design using Endo and Writers in Haskell.
Let's address the issues one by one. We need to accumulate tasks that belong to the project. So we need an abstraction that helps in this accumulation e.g. concatenation in a list, or in a set or in a Map .. etc. One abstraction that comes to mind is a
Monoid
that gives us an associative binary operation between two objects of a type that form a monoid. trait Monoid[T] { def append(m1: T, m2: T): T def zero: T }
A
List
is a monoid with concatenation as the append
. But since we don't want to expose the concrete data structure to the client API, we can talk in terms of monoids.The other data structure that we need is some form of an abstraction that will offer us the writing operation into the monoid. A
Writer
monad is an example of this. In fact the combination of a Writer
and a Monoid
is potent enough to have such a DSL in the making. Tony Morris used this combo to implement a logging functionality ..for { a <- k withvaluelog ("starting with " + _) b <- (a + 7) withlog "adding 7" c <- (b * 3).nolog d <- c.toString.reverse.toInt withvaluelog ("switcheroo with " + _) e <- (d % 2 == 0) withlog "is even?" } yield eWe could use this same technique here. But we have a problem -
Project
is not a monoid and we don't have a definition of zero
for a Project
that we can use to make it a Monoid
.
Is there something that would help us get a monoid from Project
i.e. allow us to use Project
in a monoid ?
Enter
Endo
.. an endomorphism which is simply a function that takes an argument of type T
and returns the same type. In Scala, we can state this as ..
sealed trait Endo[A] { // The captured function def run: A => A //.. }Scalaz defines
Endo[A]
and provides a lot of helper functions and syntactic sugars to use endomorphisms. Among its other properties, Endo[A]
provides a natural monoid and allows us to use A
in a Monoid
. In other words, endomorphisms of A
form a monoid under composition. In our case we can define an Endo[Project]
as a function that takes a Project
and returns a Project
. We can then use it with a Writer
(as above) and implement the accumulation of tasks within a Project
.
Exercise: Implement Tony Morris' logger without side-effects using an Endo.
Here's how we would like to accumulate tasks in our DSL ..
for { _ <- task("Study Customer Requirements") _ <- task("Analyze Use Cases") a <- task("Develop code") } yield a
Let's define a function that adds a
Task
to a Project
..
// add task to a project val withTask = (t: Task, p: Project) => p.copy(tasks = t :: p.tasks)
and use this function to define the DSL API
task
which makes an Endo[Project]
and passes it as a Monoid
to the Writer
monad. In the following snippet, (p: Project) => withTask(t, p)
is a mapping from Project => Project
, which gets converted to an Endo
and then passed to the Writer
monad for adding to the task list of the Project
.
def task(n: String): Writer[Endo[Project], Task] = { val t = Task(n) for { _ <- tell(((p: Project) => withTask(t, p)).endo) } yield t }
The DSL snippet above is a monad comprehension. Let's add some more syntax to the DSL by defining dependencies of a Project. That's also a mapping from one
Project
state to another and can be realized using a similar function like withTask
..
// add project dependency val withDependency = (t: Task, on: Task, p: Project) => p.copy(deps = (t, on) :: p.deps)
.. and define a function
dependsOn
to our DSL that allows the user to add the explicit dependencies amongst tasks. But this time instead of making it a standalone function we will make it a method of the class Task
. This is only for getting some free syntactic sugar in the DSL. Here's the modified Task
ADT ..
case class Task(name: String) { def dependsOn(on: Task): Writer[Endo[Project], Task] = { for { _ <- tell(((p: Project) => withDependency(this, on, p)).endo) } yield this } }Finally we define the last API of our DSL that glues together the building of the Project and the addition of tasks and dependencies without directly coupling the user to some of the underlying implementation artifacts.
def project(name: String, startDate: Date)(e: Writer[Endo[Project], Task]) = { val p = Project(name, startDate) e.run._1(p) }And we can finally create a
Project
along with tasks and dependencies using our DSL ..
project("xenos", now) { for { a <- task("study customer requirements") b <- task("analyze usecases") _ <- b dependsOn a c <- task("design & code") _ <- c dependsOn b d <- c dependsOn a } yield d }In case you are interested I have the whole working example in my github repo.
1 comment:
Good tuto. step by step explanations.
Post a Comment