Search code examples
haskelltypeclass

Typeclasses vs. functions?


I am currently experimenting with typeclasses & as an exercise, the ability to log in a variety of contexts (i.e. print to the console in the context of IO). I started off by implementing my Logger as a typeclass made up of various functions for logging with the idea in mind that I could define an instance for the IO monad but leave room for additional implementation in the context of other monads.

The end result is :

-- |Class / wrapper for convenient use within another monad.
class Logger m where
    -- |Logs an error message /(prefixed with the '__[ERROR]__' tag)/
    logError    :: String -> m ()
 
    -- |Logs a warning message /(prefixed with the '__[WARNING]__' tag)/
    logWarning  :: String -> m ()

    -- |Logs a success message /(prefixed with the '__[SUCCESS]__' tag)/
    logSuccess  :: String -> m ()

    -- |Logs an informative message /(prefixed with the '__[INFO]__' tag)/
    logInfo     :: String -> m ()

    -- |Logs a regular message /(i.e with no prefix)/
    logMsg      :: String -> m ()

-- |Instance of logger in the IO monad
instance Logger IO where
    logError    = printError
    logWarning  = printWarning
    logSuccess  = printSuccess
    logInfo     = printInfo
    logMsg      = printMsg

-- |Instance of logger for a state
instance (MonadIO m) => Logger (StateT s m) where
    logError    = liftIO . printError
    logWarning  = liftIO . printWarning
    logSuccess  = liftIO . printSuccess
    logInfo     = liftIO . printInfo
    logMsg      = liftIO . printMsg

This seemed like a good idea at the time (and coming from an OOP background I am drawn to making everything into 'classes' when I probably shouldn't)

I have come to realise I could have just as easily defined my logging functions directly with type contraints and call it a day, e.g. :

logError :: (MonadIO m) => String -> m ()
logError = liftIO . printError

And so on for the other functions and I would have something that can be called in any IO-based monad...


Obviously, both solutions have got their benefits and their trade-offs.

Could my use-case of a typeclass for Logger be considered "abuse" or do I have the right idea in implementing it that way (my understanding is that type classes allow for ad hoc polymorphism which is what I had in mind).

One limitation I have read about & which I am still trying to fully conceptualise is the fact there can only be one instance of a typeclass for any given type, so in my case, I have already defined an instance for StateT which live in the IO monad, meaning I lose the ability to override for subsquent states with the same signature. I am aware of this caveat but I am having a hard time thinking of a situation where this would become a concrete problem.

On the flip side, the simple function-based approach is just as elegant to use although it does prevent overriding the behaviour without defining a brand new function to be used in a different context.

Should typeclasses only be used/written as a last resort when functions can just as easily do the job?

I would appreciate some insight and feedback on the two approaches.

Thanks in advance,


Solution

  • Absolutely reuse typeclasses that already do the thing you care about -- in this case, MonadIO.

    That said, I think logging is an especially interesting application. For example, consider AccumT [String] IO. Should the log lift an IO operation, or add? It's not super clear that one is clearly correct and the other clearly incorrect. For that reason, you might even consider going from the typeclass route -- which can only have one implementation per type -- to the ADT route:

    -- incidentally, you should use this in your class, too
    data Level = Error | Warning | Success | Info | Msg
        deriving (Eq, Ord, Read, Show, Bounded, Enum)
    
    newtype Logger m = Logger { log :: Level -> String -> m () }
    

    Then you could have separate implementations for AccumT:

    makeLoggingMessage :: Level -> String -> String
    makeLoggingMessage lev msg = show lev ++ ": " ++ msg -- or whatever
    
    viaIO :: MonadIO m => Logger m
    viaIO = Logger $ \lev msg -> liftIO . putStrLn $ makeLoggingMessage lev msg
    
    viaAccum :: Monad m => Logger (AccumT [String] m)
    viaAccum = Logger $ \lev msg -> add [makeLoggingMessage lev msg]
    

    There might be other variants, too; maybe one that adds a timestamp and one that doesn't, for example.

    By the way, this data type suggestion is not merely academic. The lumberjack library's LogAction data type1 is almost exactly this, has a whole library built around it, and is used by professional Haskell programmers.

    Choosing between the three options -- existing typeclass, new typeclass, or data type -- is something that you'll slowly gain experience at doing. As a rule of thumb, probably the most reliable advice I can give newcomers on this topic is: don't create a new typeclass. ^_^

    1Some folks may also recognize this from the co-log library, which I'm told was a heavy inspiration in the design of lumberjack.