2

I've recently picked up the Free Monad pattern using cats in an attempt to create a DSL which can be "simplified" before execution. For example, let's say I create a language for interacting with lists:

  sealed trait ListAction[A]
  case class ListFilter[A](in: List[A], p: A => Boolean) extends ListAction[List[A]]
  case class ListMap[A, B](in: List[A], f: A => B) extends ListAction[List[B]]

  type ListProgram[A] = Free[ListAction, A]

Before executing any program built with these actions, I want to optimise it by transforming subsequent filters into a single filter and transforming subsequent maps into a single map in order to avoid iterating over the list multiple times:

// Pseudo code - doesn't compile, just illustrates my intent

def optimise[A](program: ListProgram[A]): ListProgram[A] = {
  case ListFilter(ListFilter(in, p1), p2) => optimise(ListFilter(in, { a: A => p1(a) && p2(a) }))
  case ListMap(ListMap(in, f1), f2) => optimise(ListMap(in, f2 compose f1))
}

Is this possible using the Free Monad, either by inspecting the last action when adding to the program or by optimising as above? Thanks very much.


Below is the code I've been using to create my programs:

  trait ListProgramSyntax[A] {
    def program: ListProgram[List[A]]

    def listFilter(p: A => Boolean): ListProgram[List[A]] = {
      program.flatMap { list: List[A] =>
        Free.liftF[ListAction, List[A]](ListFilter(list, p))
      }
    }

    def listMap[B](f: A => B): ListProgram[List[B]] = program.flatMap { list =>
      Free.liftF(ListMap(list, f))
    }
  }

  implicit def syntaxFromList[A](list: List[A]): ListProgramSyntax[A] = {
    new ListProgramSyntax[A] {
      override def program: ListProgram[List[A]] = Free.pure(list)
    }
  }

  implicit def syntaxFromProgram[A](existingProgram: ListProgram[List[A]]): ListProgramSyntax[A] = {
    new ListProgramSyntax[A] {
      override def program: ListProgram[List[A]] = existingProgram
    }
  }

For example:

  val program = (1 to 5).toList
    .listMap(_ + 1)
    .listMap(_ + 1)
    .listFilter(_ % 3 == 0)


EDIT: After my colleague searched for "Free Monad optimize" using the American spelling we found a good answer to this question asserting it is not possible to do this before interpretation.

However, it must surely be possible to interpret the program to produce an optimised version of it and then interpret that to retrieve our List[A]?

Dmytro Mitin
  • 48,194
  • 3
  • 28
  • 66
William Carter
  • 1,287
  • 12
  • 19
  • 1
    "*it must surely be possible to interpret the program to produce an optimised version of it*" - how is that "before execution"? The point is that you cannot interpret `flatMap` without calling the function. – Bergi Jan 27 '20 at 19:30
  • Yep, I see it now. I will explore other tools to achieve what I want. Thanks for the help. – William Carter Jan 27 '20 at 23:02

1 Answers1

0

I've managed to get what I want by just defining my "program" structure in a recursive ADT:

  sealed trait ListAction[A]
  case class ListPure[A](list: List[A]) extends ListAction[A]
  case class ListFilter[A](previous: ListAction[A], p: A => Boolean) extends ListAction[A]
  case class ListMap[A, B](previous: ListAction[A], f: A => B) extends ListAction[B]

  trait ListActionSyntax[A] {
    def previousAction: ListAction[A]

    def listFilter(p: A => Boolean): ListFilter[A] = ListFilter(previousAction, p)

    def listMap[B](f: A => B): ListMap[A, B] = ListMap(previousAction, f)
  }

  implicit def syntaxFromList[A](list: List[A]): ListActionSyntax[A] = {
    new ListActionSyntax[A] {
      override def previousAction: ListAction[A] = ListPure(list)
    }
  }

  implicit def syntaxFromProgram[A](existingProgram: ListAction[A]): ListActionSyntax[A] = {
    new ListActionSyntax[A] {
      override def previousAction: ListAction[A] = existingProgram
    }
  }

  def optimiseListAction[A](action: ListAction[A]): ListAction[A] = {
    def trampolinedOptimise[A](action: ListAction[A]): Eval[ListAction[A]] = {
      action match {

        case ListFilter(ListFilter(previous, p1), p2) =>
          Eval.later {
            ListFilter(previous, { e: A => p1(e) && p2(e) })
          }.flatMap(trampolinedOptimise(_))

        case ListMap(ListMap(previous, f1), f2) =>
          Eval.later {
              ListMap(previous, f2 compose f1)
          }.flatMap(trampolinedOptimise(_))

        case ListFilter(previous, p) =>
          Eval.defer(trampolinedOptimise(previous)).map { optimisedPrevious =>
            ListFilter(optimisedPrevious, p)
          }

        case ListMap(previous, f) =>
          Eval.defer(trampolinedOptimise(previous)).map { optimisedPrevious =>
            ListMap(optimisedPrevious, f)
          }

        case pure: ListPure[A] => Eval.now(pure)
      }
    }

    trampolinedOptimise(action).value
  }

  def executeListAction[A](action: ListAction[A]): List[A] = {
    def trampolinedExecute[A](action: ListAction[A]): Eval[List[A]] = {
      action match {
        case ListPure(list) =>
          Eval.now(list)

        case ListMap(previous, f) =>
          Eval.defer(trampolinedExecute(previous)).map { list =>
            list.map(f)
          }

        case ListFilter(previous, p) =>
          Eval.defer(trampolinedExecute(previous)).map { list =>
            list.filter(p)
          }
      }
    }

    trampolinedExecute(action).value
  }

This has the downside that I don't get stack-safety for free and have to ensure my optimisation and execution methods are properly trampolined.

William Carter
  • 1,287
  • 12
  • 19