类型类与函数?

3

我目前正在尝试使用类型类进行实验,作为练习,能够在各种上下文中记录日志的能力 (即在IO上下文中打印到控制台)。我开始通过实现我的 Logger 作为由各种用于记录日志的函数组成的类型类来实现这一点,这样我就可以为IO单子定义一个实例,但也为其他单子的上下文中添加附加实现留出空间。

最终结果是:

-- |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

这在当时似乎是个好主意(由于来自面向对象编程背景,我会倾向于将所有东西都变成“类”,即使不应该这样做)
我意识到我同样可以直接定义带有类型限制的日志函数并处理完毕,例如:
logError :: (MonadIO m) => String -> m ()
logError = liftIO . printError

对于其他函数也是这样,我将拥有可以在任何基于IO的monad中调用的东西...
显然,两种解决方案都有其优点和权衡。
我的 Logger 类型类的用例是否可以被认为是 "滥用",或者我通过这种方式实现它的想法是正确的吗?(我理解类型类允许使用特定多态性,这正是我想到的)
我读过一个限制,我仍在努力完全理解它,即对于任何给定类型,只能有一个类型类实例,因此在我的情况下,我已经定义了 StateT 的实例,它位于 IO monad 中,这意味着我失去了覆盖具有相同签名的后续状态的能力。我知道这个警告,但我很难想象这会成为一个具体的问题。
另一方面,简单的基于函数的方法同样优雅易用,尽管它阻止了在不定义要在不同上下文中使用的全新函数的情况下重写行为。 当函数也能轻松完成工作时,类型类应该只在最后一步使用/编写吗? 我希望能对这两种方法获得一些洞见和反馈。
提前感谢。

1
我已经更新了我的帖子,只提出了一个问题,谢谢。 - Althar93
1
Haskell 很容易重构,所以首先采用简单的解决方案。 - Lambda Fairy
我很欣赏 Haskell 如此容易重构,但我的问题更关注于在决定编写类型类还是常规函数时涉及到的思考过程和直觉。 - Althar93
2
我认为你的StateT实例应该有一个不同的限制条件:instance Logger m => Logger (StateT s m) where logError = lift . logError,等等。 - dfeuer
1
由于您具有面向对象的背景并且询问有关日志记录的问题,因此您可能会发现我关于可重复执行的文章系列很有用。这个三部分的系列包括一个完整的Haskell示例,其中包括GitHub上的所有源代码。 - Mark Seemann
显示剩余3条评论
1个回答

4

绝对要重用已经实现你所关心的功能的类型类,例如 MonadIO

话虽如此,我认为日志记录是一个特别有意思的应用。例如,考虑 AccumT [String] IO。日志应该将一个 IO 操作提升,还是应该使用 add?很难说哪个是明确正确的,哪个是明显不正确的。出于这个原因,甚至可以考虑从类型类路线转向 ADT 路线:

-- 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 () }

那么你可以为 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]

可能还有其他变体,例如一个添加时间戳的变体和一个不添加时间戳的变体。

顺便说一下,这个数据类型建议并不仅仅是学术上的。Lumberjack库的LogAction数据类型1几乎完全符合这个建议,而且还有一个完整的库围绕它构建,并被专业的Haskell程序员使用。

在现有类型类、新类型类或数据类型之间做出选择是需要慢慢积累经验的。作为一个经验法则,我可以给新手们提供的最可靠的建议可能是:不要创建一个新的类型类。 ^_^

1有些人可能也会从co-log库中认识到这个,据我所知,它在设计lumberjack时是一个很大的灵感来源。


我之前没有考虑过使用你建议的 newtype 的可能性,这绝对是我想要探索的一条途径。它看起来更冗长,因为你必须有不同命名的实现,但它确实看起来更强大和可扩展。结合你的答案和之前的评论,我感觉这个回答非常好,并提供了足够的途径,使得各种替代方案的优点变得清晰明了。 - Althar93

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接