4
votes

The tagless-final pattern lets us write pure functional programs which are explicit about the effects they require.

However, scaling this pattern might become challenging. I'll try to demonstrate this with an example. Imagine a simple program that reads records from the database and prints them to the console. We will require some custom typeclasses Database and Console, in addition to Monad from cats/scalaz in order to compose them:

def main[F[_]: Monad: Console: Database]: F[Unit] =
  read[F].flatMap(Console[F].print)

def read[F[_]: Functor: Database]: F[List[String]] =
  Database[F].read.map(_.map(recordToString))

The problem starts when I want to add a new a effect to a function in the inner layers. For example, I want my read function to log a message if no records were found

def read[F[_]: Monad: Database: Logger]: F[List[String]] =
  Database[F].read.flatMap {
    case Nil => Logger[F].log("no records found") *> Nil.pure
    case records => records.map(recordToString).pure
  }

But now, I have to add the Logger constraint to all the callers of read up the chain. In this contrived example it's just main, but imagine this is several layers down a complicated real-world application.

We can look at this issue in two ways:

  1. We can say it's a good thing that were explicit about our effects, and we know exactly which effects are needed by each layer
  2. We can also say that this leaks implementation details - main doesn't care about logging, it's just needs the result of read. Also, in real applications you see really long chains of effects in the top layers. It feels like a code-smell, but I can't put my finger on what other approach I can take.

Would love to get your insights on this.

Thanks.

1
Logging is a "capability" not an implementation detail. So the statement "We can also say that this leaks implementation details - main doesn't care about logging" is not true.Knows Not Much
About "all the callers of read up the chain": what you're calling layers here form a tree, and while it's true that near the base of the tree (your main), you're stuck enumerating all the effects needed by all dependents, these trees a) don't tend to be terribly deep in practice and b) the implementations near the base should be fairly minimal, just composing pieces with fewer requirements and more logic.Travis Brown

1 Answers

2
votes

We can also say that this leaks implementation details - main doesn't care about logging, it's just needs the result of read. Also, in real applications you see really long chains of effects in the top layers. It feels like a code-smell, but I can't put my finger on what other approach I can take.

I actually believe the contrary is true. One of the key promises of pure FP is equational reasoning as a means of deriving the method implementation from it's signature. If read needs a logging effect in order to do it's business, then by all means it should be declaratively expressed in the signature. Another advantage of being explicit about your effects is the fact that when they start to accumulate, perhaps we need to rethink what this specific method is doing and split it up into smaller components? Or should this effect really be used here?

It is true that effects stack up, but as @TravisBrown mentioned in the comments, it is usually the highest place in the call stack that has to "suffer the consequence" of actually providing all the implicit evidence for the entire call tree.