Haskell 代码中充斥着 TVar 操作和带有许多参数的函数:代码味道?

Haskell code littered with TVar operations and functions taking many arguments: code smell?

我正在 Haskell 中编写一个 MUD 服务器(MUD = Multi User Dungeon:基本上,一个多用户文本 adventure/role-playing 游戏)。游戏世界 data/state 由大约 15 个不同的 IntMap 代表。我的 monad 转换器堆栈如下所示:ReaderT MudData IO,其中 MudData 类型是包含 IntMaps 的记录类型,每个都有自己的 TVar(我使用的是 STM对于并发):

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

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

一些功能需要特定的 IntMap,而其他功能需要其他功能。因此,每个 IntMap 在其自己的 TVar 中提供了粒度。

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

所以,我的代码变成了 "littered",其中包含大量 readTVar 表达式和函数,这些表达式和函数需要大量参数。这是我的问题:这是代码味道吗?我是否缺少一些可以使我的代码更优雅的抽象?有没有更理想的方式来构建我的 data/code?

谢谢!

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

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

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

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

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

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

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

我们可以用MudData TVar恢复原来的MudData。我们可以通过包装 Identitynewtype 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 ...

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

{-# LANGUAGE FunctionalDependencies #-}

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

只要代码在所有 MonadReadRef r m 上都被参数化,它就是纯粹的。对于 Identity 中保存的普通值,我们可以通过 运行 使用以下 MonadReadRef 实例看到这一点。 readRef = id中的id等同于return . runIdentity.

instance MonadReadRef Identity Identity where
    readRef = id

我们将根据 MonadReadRef 重写 moreVanityThanWealth

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

当我们在 STM 中为 TVars 添加一个 MonadReadRef 实例时,我们可以在 STM 中使用这些 "pure" 计算但泄漏了侧面-阅读了 TVar 的效果。

instance MonadReadRef TVar STM where
    readRef = readTVar

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

我想问的是:分开TVar真的有好处吗?不就是premature optimization? Before taking such a design decision as splitting your data structure among multiple separate TVars, I'd definitely do some measurements (see criterion吗) .您可以创建一个示例测试来模拟预期的并发线程数和数据更新频率,并通过多个 TVar 与单个 IORef 对比来检查您真正获得或损失的是什么。 =23=]

谨记:

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

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