typelevel/cats-mtl

Add revised MonadLog?

vlovgr opened this issue · 10 comments

If we adapt the MonadLog type class presented in IO & Logging Capabilities to fit as an extension to FunctorListen, we end up with the following. We've replaced log with tell and extract is replaced by listen.

trait MonadLog[F[_], L] extends FunctorListen[F, L] {
  val monad: Monad[F]

  val monoid: Monoid[L]

  def clear[A](fa: F[A]): F[A]

  def flush[A](fa: F[A])(f: L => F[Unit]): F[A]
}

Personally, I don't think it's the right way to go:

  • we're extending from a Functor hierarchy, while we require Monad,
  • this makes us require not one, but two additional constraints, with Monoid,
  • logs are forced to take a form like F[(A, L)], and cannot be stored separately.

Instead, I propose we adapt MonadLog as an extension to MonadState. In fact, any MonadState[F, L] can be a MonadLog[F, L] with a Monoid[L] instance, meaning we can reuse the existing MonadState instances. Such an adapted version of MonadLog could be defined as follows.

trait MonadLog[F[_], L] extends MonadState[F, L] with Serializable {
  val monoid: Monoid[L]

  def log(l: L): F[Unit]

  def clear: F[Unit]

  def flush(f: L => F[Unit]): F[Unit]
}

Default implementation could be defined as follows.

trait DefaultMonadLog[F[_], L] extends DefaultMonadState[F, L] with MonadLog[F, L] {
  def log(l: L): F[Unit] =
    modify(monoid.combine(_, l))

  def clear: F[Unit] =
    set(monoid.empty)

  def flush(f: L => F[Unit]): F[Unit] =
    monad.flatMap(monad.flatMap(get)(f))(_ => clear)
}

Laws could be defined as follows.

trait MonadLogLaws[F[_], L] extends MonadStateLaws[F, L] {
  implicit val logInstance: MonadLog[F, L]
  implicit val monoid: Monoid[L] = logInstance.monoid

  import logInstance._
  import monad.{pure, unit}
  import monoid.empty

  def logThenClearDoesNothing(l: L) =
    log(l) *> clear <-> unit

  def logThenGetReturnsLog(l: L) =
    log(l) *> get.flatMap(clear.as) <-> log(l) *> clear.as(l)

  def logsThenGetReturnsCombinedLog(l1: L, l2: L) =
    log(l1) *> log(l2) *> get.flatMap(clear.as) <->
      log(l1) *> log(l2) *> clear.as(l1 combine l2)

  def clearThenGetReturnsEmpty =
    clear *> get <-> pure(empty)

  def clearThenClearClearsOnce =
    clear *> clear <-> clear

  def flushIsGetThenClear(f: L => F[Unit]) =
    flush(f) <-> get.flatMap(f) *> clear
}

Hey @vlovgr, thanks for raising this issue. I'm not sure I fully understand everything just yet, I'm going to try to read the blog post as soon as possible :)

@LukaJCB thanks for taking the time! I'm just looking to get some feedback on whether this would be a useful addition to the project or not. :) I realised I haven't properly explained why MonadLog looks like it does, so thought I would provide a further explanation below.


If you're using FunctorListen for log accumulation, you basically don't have the ability to clear the accumulated logs. This is important if you ever want to not dispatch all the logs at the same time. For example, whilst accumulating logs, you might want to immediately dispatch a single log message. Or whilst processing a request, you want to accumulate some log entries, then dispatch those as a message, and then keep going without those entries.

The MonadLog version in the blog post, and the revised version extending FunctorListen at the top of this issue, basically adds clear to let you do just that. To be able to write meaningful laws for clear, we need to know what empty looks like, hence the requirement on a Monoid instance. If we have Monoid, then we can write further laws for log and extract (i.e. tell and listen), which explicitly describe monoidal log accumulation.

If you, at some point, want to dispatch currently accumulated log entries (even if a single one), you'd have to write something like the following, using listen and clear. The main point to note is that it requires flatMap, and hence Monad (technically FlatMap would do).

F.clear {
  for {
    aWithLogs <- F.listen(fa)
    (a, logs) = aWithLogs
    _ <- dispatch(logs)
  } yield a
}

If we know we'll always need to have a Monad to support this intermediate dispatch, then we could just as well require it in MonadLog, and support it with a flush operation.

For the reasons listed in the issue description, I don't think MonadLog extending FunctorListen is the way to go. The other option I propose is to extend MonadState. Here we essentially make MonadState more strict, by requiring a Monoid, and write laws to describe how monoidal log accumulation happens. The benefit over MonadState is just that: log accumulation laws, and the reusability that comes with it.

With the MonadState version we're also free to implement log accumulation both as F[(A, L)] (e.g. StateT or WriterT), or with e.g. a Ref from cats-effect. In a way, it's like a combination of a more powerful FunctorListen and MonadState.

It's very interesting, I'm probably going to have to think about it more, but I think MonadState might be over-constraining, writing a clear function for WriterT is completely doable and IIUC you could write a MonadLog instance for WriterT as well. For flush it depends on the version we want to support. WriterT can easily support def flush[A](fa: F[A])(f: L => F[Unit]): F[A], but obviously can't support def flush(f: L => F[Unit]): F[Unit].

I'm a bit wary of adding type classes that have too much power when one could achieve the same thing with more limited requirements.

That said, if we look at the Haskell or PureScript mtl libraries, we'll see that their MonadWriter actually has an additional method to listen and tell: pass, which looks like this:

class MonadTell w m <= MonadWriter w m | m -> w where
  listen :: forall a. m a -> m (Tuple a w)
  pass :: forall a. m (Tuple a (w -> w)) -> m a

With pass and Monoid[L], you could easily define clear.
Another thing you could do instead of using MonadState is to make use of Group:

def clear[F[_]: FlatMap, L: Group](fa: F[A])(implicit F: FunctorListen[F, L]): F[A] =
  F.listen(fa).flatMap { case (a, l) => F.tell(Group[L].inverse(l)).as(a) }

Though this is probably less useful in practice, since there's not many Group instances out there and no collection type currently supports it (though it would be interesting to try to develop one), so this is probably not the best way to go.

Instead, I'd like to make a counter proposal, how about an ApplicativePass:

trait ApplicativePass[F[_], L] extends FunctorListen[F, L] {
  val applicative: Applicative[F]
  val monoid: Monoid[L]
  override val functor: Functor[F] = applicative

  def pass[A](faf: F[(A, L => L)]): F[A]

  def clear[A](fa: F[A]): F[A] =
    pass(functor.tupleRight(fa, _ => monoid.empty))
}

As for laws, we can use the laws from your blog post in addition to what I could think of for pass:

pass(pure(a -> f)) === tell(f(Monoid[L].empty)) *> pure(a)
pass(tell(l).tupleRight(f)) === tell(f(l))

listen(pass(pure(a -> f))) === pure(a -> f(Monoid[L].empty))
listen(pass(tell(l).tupleRight(f)) === pure(() -> f(l))

And we can also add additional laws for tell given the monoid constraint:

tell(l1) *> tell(l2) === tell(l1 |+| l2)
tell(l1) <* tell(l2) === tell(l1 |+| l2)

WDYT?

Thanks a lot! That looks very interesting. I agree with all your points. However, my main worry is that clear without flush (ideally def flush(f: L => F[Unit]): F[Unit], as I'm interested in using a Ref) is not as useful on its own. In my experience, I actually always want flush rather than clear (clear just being a consequence of using flush). If we had MonadWriter, I think that would've been a good base for MonadLog, simply requiring Monoid.

Okay, so I think we might need to divide between what's convenient for the end user and what the base abstraction is supposed to express.

Instead of using MonadWriter and forcing Monad even though the laws don't need it, maybe we could add some syntax for flush that requires a Monad:

trait PassSyntax {
  implicit def toPassOps[F[_], A](fa: F[A]): PassOps[F, A] = new PassOps(fa)
}

final class PassOps[F[_], A](val fa: F[A]) extends AnyVal {
  def clear[L]: F[A](implicit A: ApplicativePass[F, L]): F[A]
  def flush[L](fa: F[A])(f: L => F[Unit])(implicit F: Monad[F], A: ApplicativePass[F, L]): F[A]
}

I think this may be more in line with the spirit of this project :)

Yes, I totally agree ApplicativePass is better than MonadLog based off MonadState. And yes, you only need Monad for an internal law, so I think what you suggest is perfectly fine here. 👍 I'm fairly new to the project, so really appreciate the guidance. :)

Cool! Would you be willing to create a PR for this? :)

I'd also like to hear from the other maintainers /cc @SystemFw @andyscott

I think ApplicativePass looks neat, but I'm mainly interested in having the log accumulation happening outside of F[A] (e.g. in a Ref), rather than with e.g. a WriterT. That is, I'm still looking to support the following functions (whether flush is on the type class or not):

def clear: F[Unit]
def flush(f: L => F[Unit]): F[Unit]

and, as far as I can tell, ApplicativePass cannot support those. The only place I can see these be supported with the current type classes is via MonadState. (I could be missing something, please let me know if that's the case.) Hence, I'm not that inclined to write up ApplicativePass. I'm more than happy to keep a MonadLog version based of MonadState in my own project, if the feeling is it doesn't belong here. That is also perfectly fine. :)

I still think ApplicativePass would be a nice addition to the project. Should we rename this issue to 'Add ApplicativePass', and whoever is interested in contributing it, can do so? :)

Yeah if you want a signature like def flush(f: L => F[Unit]): F[Unit] without any input F[A] you're going to need something context-sensitive like State. For the less restrictive signature def flush[L](fa: F[A])(f: L => F[Unit]): F[A]

I'm fully on board with log accumulation happening outside of F[A] instead of using WriterT. That should work with either ApplicativePass and MonadState, I reckon.

That said, I'm not entirely sure this project would be the best place for MonadLog, it seems to just be a tiny wrapper around MonadState that IMO, isn't general enough to be included, though I'm open to change my mind if given convincing arguments :)

Yes, MonadLog is just that: the addition of a Monoid requirement, and laws for how log accumulation happens, so it can be reused. I can see how it makes sense to only add type classes that are general enough. It would be good to try and define 'general enough' as advice for future contributors as well. :)

I'm going to close this. Feel free to create a new issue for ApplicativePass. :)