何时(以及何时不)定义一个Monad

14
这是一个关于为Haskell库定义自己的Monad实例的API设计实践的问题。定义Monad实例似乎是隔离DSL的好方法,例如在monad-par、hdph中的Par monad,在distributed-process中的Process,在parallel中的Eval等等。
我选取了两个Haskell库的例子,它们的目的是与数据库后端进行IO交互。我选取的例子分别是riak用于Riak IO,以及hedis用于Redis IO。
在hedis中,定义了一个Redis monad(单子)。然后,您可以通过以下方式运行与redis的IO:
data Redis a -- instance Monad Redis
runRedis :: Connection -> Redis a -> IO a
class Monad m => MonadRedis m
class MonadRedis m => RedisCtx m f | m -> f
set :: RedisCtx m f => ByteString -> ByteString -> m (f Status)

example = do
  conn <- connect defaultConnectInfo
  runRedis conn $ do
    set "hello" "world"
    world <- get "hello"
    liftIO $ print world

在Riak中,事情是不同的:
create :: Client -> Int -> NominalDiffTime -> Int -> IO Pool
ping :: Connection -> IO ()
withConnection :: Pool -> (Connection -> IO a) -> IO a

example = do
  conn <- connect defaultClient
  ping conn

runRedis的文档说:"每次调用runRedis都会从连接池中获取一个网络连接并运行给定的Redis操作。因此,当池中的所有连接都在使用时,对runRedis的调用可能会被阻塞。" 然而,riak包也实现了连接池。这是在IO monad之上没有额外的monad实例完成的。

create :: Client-> Int -> NominalDiffTime -> Int -> IO Pool
withConnection :: Pool -> (Connection -> IO a) -> IO a

exampleWithPool = do
  pool <- create defaultClient 1 0.5 1
  withConnection pool $ \conn -> ping conn

因此,这两个包之间的类比归结为以下两个函数:
runRedis       :: Connection -> Redis a -> IO a
withConnection :: Pool -> (Connection -> IO a) -> IO a

据我所知,hedis包引入了一个名为Redis的单子来封装使用runRedis的redis IO操作。相比之下,riak包在withConnection中只是接受一个接受Connection的函数,并在IO单子中执行它。
那么,定义自己的Monad实例和Monad堆栈的动机是什么?为什么riak和redis包在这方面采取了不同的方法?

6
作为回答的背景--如果不明显的话,类型Redis aConnection -> IO a大致相当。因此,这基本上是一种表面差异,类似于env-> IO aReaderT env IO a的比较。 - C. A. McCann
那就意味着也许两者都不正确,而 Codensity IO Connection 才是他一直想要的单子。 - Gabriella Gonzalez
2个回答

10
对我来说,重要的是封装和保护用户免受未来实现变化带来的影响。正如Casey所指出的那样,目前这两种方法大致相当--基本上是一个Reader Connection单子类型。但是想象一下,如果将来发生了不确定的变化,这两种方法将如何表现。如果两个包都决定用户需要状态单子接口而不是读取器,在这种情况下,Riak的withConnection函数将更改为以下类型签名:
withConnection :: Pool -> (Connection -> IO (a, Connection)) -> IO a

这将需要对用户代码进行全面的修改。但是Redis包可以在不破坏其用户的情况下完成这样的更改。

现在,有人可能会争辩说这种假设的情况非常不现实,不是您需要计划的事情。在这两种特定情况下,这可能是真的。但是所有项目随着时间的推移都会发展,并且经常以意想不到的方式发展。定义自己的monad允许您隐藏内部实现细节,为用户提供更稳定的接口,以应对未来的变化。

以这种方式陈述,有些人可能会得出结论,定义自己的monad是更好的方法。但我认为并不总是这种情况。(lens库可能是一个潜在的良好反例。)定义新的monad也有成本。如果您正在使用monad转换器,则可能会造成性能损失。在其他情况下,API可能会变得更加冗长。Haskell非常擅长让您保持语法非常简洁,在这种特定情况下,差异并不是很大-可能是一些liftIO用于redis和一些lambda用于riak。

软件设计很少是一成不变的。你很少有把握能够自信地说什么时候定义自己的单子,什么时候不需要。但我们可以意识到涉及到的权衡,以帮助我们在遇到个别情况时进行评估。


1

在这种情况下,我认为实现单子是一个错误。这就像java开发人员仅出于拥有它们的原因而实现各种设计模式。

例如,hdbc也适用于普通IO单子。

Redis库的单子没有带来任何有用的东西。它唯一实现的事情是摆脱一个函数参数(连接)。但是,在redis单子中进行每个IO操作时都需要提升,这是你需要付出代价的。

此外,如果您需要使用2个redis数据库,现在您将难以弄清楚要在哪里提升哪些操作:)

实现单子的唯一原因是创建一个新的DSL。正如您所看到的,hedis没有创建新的DSL。它的操作与任何其他数据库库完全相同。因此,hedis中的单子是肤浅的,没有合理性。


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