A demonstration on how to compose different instruction sets together using Coproduct
minimizing boilerplate using
FreeK
Libraries used:
In the example shown here which is based off Chris Myer's talk: A Year Living Freely, we cheat and make instruction sets inherit from a common instruction set. When you do this, it becomes easy to compose instruction sets due to the common instruction set so you build a big interpreter that takes in the common instruction trait and dispatches instructions to the little interpreters. You can see an example of this in action here.
If you don't have the luxury of controlling the source of all instruction sets then you need to turn to Coproducts
.
Rúnar talks about this concept here. This
project is a demonstration similar to Rúnar's but using FreeK's work to
minimize as much boilerplate as possible.
We have two instruction sets:
-
Logging
sealed trait LogInstruction[Result] case class Debug(message: String) extends LogInstruction[Unit] case class Info(message: String) extends LogInstruction[Unit] case class Warn(message: String) extends LogInstruction[Unit]
-
Greeting
sealed trait GreetingsInstruction[Result] case class WhoAreYou(message: String) extends GreetingsInstruction[String] case object Hello extends GreetingsInstruction[Unit] case object Bye extends GreetingsInstruction[Unit]
We want to be able to compose instructions from both instruction sets together into a single program.
Define a Coproduct
that mixes the instruction sets together with the help of FreeK
import freek._
sealed trait LogInstruction[Result]
// ...
sealed trait GreetingsInstruction[Result]
// ...
type ApplicationInstruction = LogInstruction :|: GreetingsInstruction :|: NilDSL
val ApplicationInstruction = DSL.Make[ApplicationInstruction]
Define smart constructors that lift both your instruction sets into the Coproduct instruction set using Free
// smart constructors
def debug(message: String): Free[ApplicationInstruction.Cop, Unit] =
Debug(message).freek[ApplicationInstruction]
def info(message: String): Free[ApplicationInstruction.Cop, Unit] =
Info(message).freek[ApplicationInstruction]
def warn(message: String): Free[ApplicationInstruction.Cop, Unit] =
Warn(message).freek[ApplicationInstruction]
def whoAreYou(message: String): Free[ApplicationInstruction.Cop, String] =
WhoAreYou(message).freek[ApplicationInstruction]
def hello: Free[ApplicationInstruction.Cop, Unit] =
Hello.freek[ApplicationInstruction]
def bye: Free[ApplicationInstruction.Cop, Unit] =
Bye.freek[ApplicationInstruction]
You can write your interpreters for each instruction set as usual
-
Logging
// Log interpreter implementation val logInterpreter = new (LogInstruction ~> Id) { override def apply[A](fa: LogInstruction[A]): Id[A] = fa match { case Debug(message) => println(s"DEBUG: $message") () case Warn(message) => println(s"WARN: $message") () case Info(message) => println(s"INFO: $message") () } }
-
Greeting
val greetingsInterpreters = new (GreetingsInstruction ~> Id) { override def apply[A](fa: GreetingsInstruction[A]): Id[A] = fa match { case WhoAreYou(message: String) => println(message) val userInput = scala.io.StdIn.readLine() userInput case Hello => println("Hello!") () case Bye => println("Bye!") () } }
You can write your program using instructions from both instruction sets using the smart constructors. Since they have been lifted into Free of the Coproduct, you can compose instructions from both instruction sets
val instructions = for {
_ <- debug("beginning program")
_ <- hello
you <- whoAreYou("Enter your name")
_ <- info(s"Hello $you")
_ <- warn("Exiting program")
} yield ()
These are just instructions that haven't been executed yet, they just describe what we would like to do. Let's look at how we can run these instructions through an interpreter that side-effects and executes these instructions. To do this we need to compose our little interpreters together:
// Combine interpreters
val composedInterpreter = logInterpreter :&: greetingsInterpreters
Now let's run the instructions that were composed together from different instruction sets through the composed interpreter
instructions.interpret(composedInterpreter)