Reputation: 157
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,
Upvotes: 3
Views: 131
Reputation: 152707
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.
Upvotes: 4