15

我正在用 Haskell 编写一个 MUD 服务器(MUD = Multi User Dungeon:基本上,一个多用户文本冒险/角色扮演游戏)。游戏世界数据/状态用大约 15 个不同IntMap的 s 表示。我的 monad 转换器堆栈看起来像这样:ReaderT MudData IO,其中MudData类型是包含IntMaps 的记录类型,每个都有自己的TVar(我使用 STM 进行并发):

data MudData = MudData { _armorTblTVar    :: TVar (IntMap Armor)
                       , _clothingTblTVar :: TVar (IntMap Clothing)
                       , _coinsTblTVar    :: TVar (IntMap Coins)

...等等。(我使用的是镜头,因此是下划线。)

一些功能需要某些IntMaps,而其他功能需要其他功能。因此,每一个IntMap都有自己的TVar提供粒度。

但是,我的代码中出现了一种模式。在处理播放器命令的函数中,我需要TVar在 STM monad 中读取(有时稍后写入)我的 s。因此,这些函数最终在它们的where块中定义了一个 STM 助手。这些 STM 助手通常有很多readTVar操作,因为大多数命令需要访问少数IntMaps. 此外,给定命令的函数可能会调用许多纯辅助函数,这些函数也需要部分或全部IntMaps. 因此,这些纯辅助函数有时最终会接受很多参数(有时超过 10 个)。

因此,我的代码变得“乱七八糟”,包含大量带有大量readTVar参数的表达式和函数。这是我的问题:这是代码异味吗?我是否缺少一些使我的代码更优雅的抽象?有没有更理想的方式来构建我的数据/代码?

谢谢!

4

2 回答 2

22

这个问题的解决方案是改变纯辅助函数。我们真的不希望它们是纯粹的,我们想泄露一个副作用——无论它们是否读取特定的数据。

假设我们有一个只使用衣服和硬币的纯函数:

moreVanityThanWealth :: IntMap Clothing -> IntMap Coins -> Bool
moreVanityThanWealth clothing coins = ...

很高兴知道一个函数只关心例如衣服和硬币,但在你的情况下,这些知识是无关紧要的,只会让人头疼。我们会故意忘记这个细节。如果我们遵循 mb14 的建议,我们会将MudData'如下所示的整个 pure 传递给辅助函数。

data MudData' = MudData' { _armorTbl    :: IntMap Armor
                         , _clothingTbl :: IntMap Clothing
                         , _coinsTbl    :: IntMap Coins

moreVanityThanWealth :: MudData' -> Bool
moreVanityThanWealth md =
    let clothing = _clothingTbl md
        coins    = _coinsTbl    md
    in  ...

MudData并且MudData'彼此几乎相同。其中一个将其字段包装在TVars 中,而另一个则没有。我们可以进行修改MudData,以便它需要一个额外的类型参数( kind * -> *)来包装字段。MudData将具有稍微不寻常的 kind (* -> *) -> *,它与镜头密切相关,但没有太多的库支持。我称这种模式为Model

data MudData f = MudData { _armorTbl    :: f (IntMap Armor)
                         , _clothingTbl :: f (IntMap Clothing)
                         , _coinsTbl    :: f (IntMap Coins)

我们可以MudDataMudData TVar. 我们可以通过将字段包装在Identity,中来重新创建纯版本newtype Identity a = Identity {runIdentity :: a}。就 而言MudData Identity,我们的函数可以写成

moreVanityThanWealth :: MudData Identity -> Bool
moreVanityThanWealth md =
    let clothing = runIdentity . _clothingTbl $ md
        coins    = runIdentity . _coinsTbl    $ md
    in  ...

我们已经成功忘记了我们使用了哪些部分MudData,但是现在我们没有我们想要的锁粒度。作为副作用,我们需要恢复我们刚刚忘记的东西。如果我们编写STM助手的版本,它看起来像

moreVanityThanWealth :: MudData TVar -> STM Bool
moreVanityThanWealth md =
    do
        clothing <- readTVar . _clothingTbl $ md
        coins    <- readTVar . _coinsTbl    $ md
        return ...

这个STM版本MudData TVar几乎和我们刚才写的纯版本一样MudData Identity。它们的区别仅在于引用的类型(TVarvs. Identity)、我们使用什么函数从引用中获取值(readTVarvs runIdentity)以及返回结果的方式(在STM或作为普通值)。如果可以使用相同的功能来提供两者,那就太好了。我们将提取两个函数之间的共同点。为此,我们将为 s 引入一个类型类MonadReadRef r mMonad我们可以从中读取某种类型的引用。r是引用的类型,是从引用readRef中获取值的函数,以及m返回结果的方式。以下MonadReadRef是密切相关的MonadRef来自ref-fd 的类。

{-# LANGUAGE FunctionalDependencies #-}

class Monad m => MonadReadRef r m | m -> r where
    readRef :: r a -> m a

只要代码在所有MonadReadRef r ms 上被参数化,它就是纯粹的。我们可以通过使用下面的MonadReadReffor 保存在Identity. idin与readRef = id相同return . runIdentity

instance MonadReadRef Identity Identity where
    readRef = id

我们将moreVanityThanWealth根据MonadReadRef.

moreVanityThanWealth :: MonadReadRef r m => MudData r -> m Bool
moreVanityThanWealth md =
    do
        clothing <- readRef . _clothingTbl $ md
        coins    <- readRef . _coinsTbl    $ md
        return ...

当我们在s 中添加一个MonadReadRef实例时,我们可以使用这些“纯”计算,但会泄漏读取 s的副作用。TVarSTMSTMTVar

instance MonadReadRef TVar STM where
    readRef = readTVar
于 2015-03-07T19:56:40.630 回答
16

是的,这显然会使您的代码变得复杂,并用大量样板细节使重要代码变得混乱。超过 4 个参数的函数是问题的征兆。

我会问一个问题:你真的通过单独TVar的 s 获得任何东西吗?这不是过早优化的情况吗?在做出将数据结构拆分为多个单独TVar的 s 的设计决策之前,我肯定会进行一些测量(请参阅标准)。您可以创建一个示例测试,对预期的并发线程数和数据更新频率进行建模,并通过多个TVars 与一个 s 与一个IORef.

记住:

  • 如果有多个线程在STM事务中竞争公共锁,则事务可能会在成功完成之前重新启动几次。所以在某些情况下,拥有多个锁实际上会使事情变得更糟。
  • 如果最终只需要同步一个数据结构,则可以考虑使用一个IORef。它的原子操作非常快,可以弥补单个中央锁的不足。
  • 在 Haskell 中,纯函数很难长时间阻塞原子STM或事务。IORef原因是懒惰:您只需要在这样的事务中创建 thunk,而不是评估它们。对于单个 atomic 尤其如此IORef。thunk 在此类事务之外进行评估(通过检查它们的线程,或者如果您需要更多控制,您可以决定在某个时候强制它们;这在您的情况下可能是需要的,就好像您的系统在没有任何人观察的情况下发展一样,您可以轻松地积累未评估的 thunk)。

如果事实证明拥有多个TVars 确实至关重要,那么我可能会在自定义 monad 中编写所有代码(正如@Cirdec 在我编写答案时所描述的那样),其实现将从主代码中隐藏,并且它将提供读取(也许还有写入)状态部分的功能。然后它将作为单个STM事务运行,仅读取和写入所需的内容,并且您可以拥有一个纯版本的 monad 进行测试。

于 2015-03-07T20:56:38.530 回答