ocharles/logging-effect

[Question] Associated type-families instead of `MultiParamTypeclasses` and `FunctionalDependencies` for `MonadLog`

Closed this issue · 4 comments

Is there a reason MultiParamTypeclasses and FunctionalDependencies are used in the MonadLog typeclass instead of associated type families?

e.g.

class Monad m => MonadLog message m | m -> message where
  logMessageFree :: (forall n. Monoid n => (message -> n) -> n) -> m ()
  default logMessageFree :: (m ~ t n, MonadTrans t, MonadLog message n) => (forall mon. Monoid mon => (message -> mon) -> mon) -> m ()
  logMessageFree inj = lift (logMessageFree inj)

vs

class Monad m => MonadLog m where
  type MonadLogMsg m
  logMessageFree :: (forall n. Monoid n => (MonadLogMsg m -> n) -> n) -> m ()
  default logMessageFree :: (m ~ t n, MonadTrans t, MonadLog n) => (forall mon. Monoid mon => (MonadLogMsg n -> mon) -> mon) -> m ()
  logMessageFree inj = lift (logMessageFree inj)

The former requires UndecideableInstances when deriving MonadLog for bespoke monad transformers; a contrived example (pseudo code):

{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE GeneralizeNewtypeDeriving #-}
{-# LANGUAGE MultiParamTypeclasses #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE DerivingStrategies #-}
-- Required, because instance head for `MonadLog` does not get smaller 
-- when deriving `MonadLog` for monad transformers that inherit the
--`MonadLog` instance from their base monad
{-# LANGUAGE UndecidableInstances #-}

import Control.Monad.State
import Control.Monad.Trans

newtype MyT m a = MyT { unMyT :: StateT Int m a }
  deriving newtype (Functor, Applicative, Monad, MonadTrans, MonadState Int)

-- Requires {-# LANGUAGE UndecidableInstances #-}
deriving newtype MonadLog msg m => MonadLog msg (MyT m)

I am not "against" using UndecidableInstances here, as I know in this case it's "safe" since the functional dependency fixes the msg type and thus the only typeclass parameter that needs to shrink is the m in MonadLog msg m. However, I think that it might be avoidable if the associated type family approach is used. I haven't actually tried it myself, but I'm wondering if this is something you thought about and decided against for some interesting reason-- a reason I'm interested in learning about!

After toying around with the idea, I find that perhaps mapLogMessage and friends wouldn't manifest as easily if at all 🤔Such a change would require a bit of work on the part of LoggingT, Handler, etc which I didn't foresee. Perhaps the ease of embedding one LoggingT message inside of a MonadLog message' with (message -> message') function could be the sole reason to use this approach... I haven't dug too deeply yet to confirm.

Thanks for your response, feel free to close or leave open as you see fit; perhaps if left open it will evoke some insightful response from the community. I'll keep hacking away at swapping out the approach, but I'm getting stuck at the wrapping of arbitrary log message values with WithSeverity, since the refactored MonadLog class no longer exposes the msg type variable, instead hiding it behind the instance definition; thus I can't figure out a sensible way to change the type signature of e.g. logDebug :: MonadLog (WithSeverity a) => a -> m () into something coherent with the new approach.

You can still bring the associated type into the constraint:

logDebug :: (MonadLog m, MessageType m ~ WithSeverity a) => a -> m ()

That said, I will close this because I won't be changing the API anyway - unless we find a very compelling reason.