Writing Interpreters
So far we have discussed how to consume existing interpreters, this section is meant to serve as a guide as to how to write your own interpreter.
This is not necessary (or recommended) for day-to-day usage of Uniform but it opens up lots of possibilities should you have somewhat unusual requirements.
Unnatural Transformations
Uniform journeys are essentially tagless final programs with one
important twist - the methods accept type parameters. As such we
can call ask[Int]
rather than needing askInt
, askString
, etc for
every possible datatype we might want to consume.
If we did have hundreds of askN
methods one for every
possible datatype our interpreters would need to provide an
implementation of every datatype we wanted, we would need to
update the uniform language itself if we wanted to add a new type and
therefore every interpreter (if all other users wanted to use your new
datatype or not).
Using ask[T]: UF[T]
presents its own problems however - when using tagless
final we transform our UF[T]
into our desired datatype,
WebMonad[T]
perhaps. UF ~> WebMonad
is therefore the natural
transformation that is being applied.
But natural transformations are total - for every possible A
we must
be able to convert UF[A]
into WebMonad[A]
. Therefore the only methods
we would be able to invoke for A
are ones that belong to Any
such
as toString
.
Ordinarily we would add a typeclass to represent the support for a
given datatype, for example ask[A: WebMonadSupport]
but then our
typeclasses are bound to the journey itself at compile time rather
than being specific to a given interpreter.
We need something that is not quite a natural transformation. Our journey should be able to refer to any datatype it needs but the typeclass instance should be owned and retrieved by the interpreter.
The journey must declare the types it uses, and the interpreter must provide support for each datatype in the journey it interprets by way of a typeclass. The journey must know nothing of the typeclasses used by any given interpreter.
I found this to be a surprisingly difficult problem to solve, and several techniques were explored in different versions of uniform.
The four approaches I found were -
- use the Eff monad (effective but makes the syntax much more convoluted)
- using runtime reflection (prevents ScalaJS from working correctly)
- using hetrogenous lists of typeclass instances (works well, but leads to a complex syntax)
- using macros together with tags to inhibit type erasure (the current approach)
Monad Interpreters
The quickest and easiest type of interpreter to create is one based
off of monads. The interpreter will chain together the steps of the
journey into instances of your given datatype and then use flatMap
to bind them into each other.
When taking this approach you only need to specify how to map interact, etc onto your chosen type.
import ltbs.uniform._
import ltbs.uniform.validation.Rule
import cats.{Id, Monoid, Monad}
// we need to know how to tell and ask together
// we don't care about tell, and our ask is a Monoid
// so we just wrap the Monoid instance in a type
// with two parameters
case class Zero[T, A](monoid: Monoid[A]) {
def value = monoid.empty
}
// wherever there is an implicit Monoid we want an
// implicit Zero instance.
implicit def zeroInstance[T,A: Monoid] = Zero[T,A](implicitly)
object ZeroInterpreter extends MonadInterpreter[Id, Zero, Noop] {
def monadInstance = implicitly[Monad[Id]]
override def interactImpl[T, A](
key: String,
tellValue: T,
default: Option[A],
validation: Rule[A],
customContent: Map[String,(String, List[Any])],
interaction: Zero[T,A]
): Id[A] = interaction.value
override def askListImpl[A](
key: String,
askJourney: (Option[Int], List[A]) => Id[A],
default: Option[List[A]],
validation: Rule[List[A]],
customContent: Map[String,(String,List[Any])],
asker: Noop[A]
): Id[List[A]] = Nil
}
Lets create a simple journey in order to test our interpreter -
val journey = for {
a <- ask[String]("a")
c <- ask[Int]("c")
} yield (a,c)
We can now process the journey through the interpreter -
// we want an implicit instance of Monoid for String and Int
import cats.implicits._
// run the journey using the interpreter
ZeroInterpreter.interpret(journey)
// res0: (String, Int) = ("", 0)