Skip to content

Latest commit

 

History

History
128 lines (89 loc) · 5.05 KB

README.md

File metadata and controls

128 lines (89 loc) · 5.05 KB

Free-to-Compose

Build Status codecov Dependency Status

Library that eases the usage of Free Monads based on cats and allows to compose multiple free monads into one.

It provides macros to automatically generate the lifting functions and composable lifting functions. The library can be used in two ways: simple lifting functions and a composable variant based on Rúnar's talk "reasonably priced monads".

Simple Usage

Declare your Ops (the F[_]):

object ConsoleOps {
  sealed trait ConsoleOp[+A]
  case class Println(text: String) extends ConsoleOp[Unit]
  case class Readln() extends ConsoleOp[String]
}

then have the macro generate the lifting functions:

import freetocompose.FreeToCompose
object Console {
  @addLiftingFunctions[ConsoleOps.ConsoleOp]('Console) object functions
}

Console.functions will contain the following definitions:

  • type Console[+A] = Free[ConsoleOp, A] //Console is the name you specified as the parameter
  • def println(text: String): Console[Unit]
  • def readln(): Console[String]

Use the lifted functions like usual:

import Console.functions._
val program: Console[String] = {
  for {
    _ <- println("Please tell me your name (empty to exit):")
    greeting = "Hello"
    name <- readln
    _ <- println(s"$greeting $name")
  } yield name
}

to run it use:

val pgm = program.foldMap(ConsoleCompile.toTrampoline)
pgm.run

for the complete example see the code under Console.scala and ConsoleExample.scala

Composable Usage

The example shown above works great in situation where you have a single resource or IO-type. But let's say we want to build a simple hotel reservation system that needs to communicate with the customer and keep track of which rooms are already occupied inside a persistent database.

We keep the ConsoleOps described above and add a second free monad called store:

object StoreOps {
  sealed trait StoreOp[+A]
  case class Put(key: String, value: String) extends StoreOp[Unit]
  case class Get(key: String) extends StoreOp[Option[String]]
}

we again use a macro to generate the lifting functions but this time we'll use composing lifting functions:

object Console {
  @addComposingFunctions[ConsoleOps.ConsoleOp]('Console) object composing
}
object Store {
  @addComposingFunctions[StoreOps.StoreOp]('Store) object composing
}

The definitions generated inside Console (Store is equivalent) are:

  • type Console[F[_]] = Inject[ConsoleOp, F]
  • def println[F[_] : Console](text: String): Free[F, Unit]
  • def readlnF[_] : Console: Free[F, String]

When using we may now combine the two different kinds of operations into one monad:

def hotelCheckin[F[_] : Console : Store] = for {                    //need to declare all op-types used as context bounds
  hotel <- get("hotel")                                             //Store Op
  _ <- println(s"Welcome to the $hotel, please enter your name:")   //Console Op
  name <- readln()                                                  //Console Op
  _ <- put("customer", name)                                        //Store Op
} yield name

of course you then also need to combine the Compilers (= Transforms = cats.~>):

 val compiler = ConsoleCompile.toTrampoline || StoreCompile.toTrampoline()
 val program = assignRoom[compiler.From].foldMap(compiler)
 program.run

for the complete example see the code under Console.scala, Store.scala and Example.scala

Implementation

The implementation is actually rather simple (well, macros are always a bit involved, but the generated code is).

  • the Ops get lifted into a coproduct of Ops (if it where shapeless we'd write Console :+: Store :+: CNil)
  • the macro is just there to reduce the boilerplate with the type and lifting function definitions.

So, no black magic involved.

Credits and further readings