Haskell代码散落着TVar操作和函数带来许多争论:代码味道?

the*_*pie 14 haskell stm tvar

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

data MudData = MudData { _armorTblTVar    :: TVar (IntMap Armor)
                       , _clothingTblTVar :: TVar (IntMap Clothing)
                       , _coinsTblTVar    :: TVar (IntMap Coins)
Run Code Online (Sandbox Code Playgroud)

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

有些功能需要某些功能IntMap,而其他功能需要其他功能.因此,每个IntMap都有自己的TVar粒度.

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

所以,我的代码变得"乱七八糟",有很多带有大量参数的readTVar表达式和函数.以下是我的问题:这是代码味道吗?我错过了一些可以使我的代码更优雅的抽象吗?有没有更理想的方法来构建我的数据/代码?

谢谢!

Cir*_*dec 21

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

假设我们有一个仅使用衣服和硬币的纯功能:

moreVanityThanWealth :: IntMap Clothing -> IntMap Coins -> Bool
moreVanityThanWealth clothing coins = ...
Run Code Online (Sandbox Code Playgroud)

通常很高兴知道一个功能只关心衣服和硬币,但在你的情况下,这种知识是无关紧要的,只会造成头痛.我们会刻意忘记这个细节.如果我们遵循mb14的建议,我们会将完整的纯粹传递MudData'给helper函数.

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  ...
Run Code Online (Sandbox Code Playgroud)

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

data MudData f = MudData { _armorTbl    :: f (IntMap Armor)
                         , _clothingTbl :: f (IntMap Clothing)
                         , _coinsTbl    :: f (IntMap Coins)
Run Code Online (Sandbox Code Playgroud)

我们可以恢复原来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  ...
Run Code Online (Sandbox Code Playgroud)

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

moreVanityThanWealth :: MudData TVar -> STM Bool
moreVanityThanWealth md =
    do
        clothing <- readTVar . _clothingTbl $ md
        coins    <- readTVar . _coinsTbl    $ md
        return ...
Run Code Online (Sandbox Code Playgroud)

这个STM版本MudData TVar几乎与我们刚写的纯版本完全相同MudData Identity.它们仅根据引用的类型(TVarvs. Identity),我们用于从引用(readTVarvs runIdentity)中获取值的函数以及返回结果的方式(以STM或作为普通值)来区分.如果可以使用相同的功能来提供两者,那将是很好的.我们将提取两个函数之间的共同点.为此,我们将为s 引入一个类型类MonadReadRef r m,Monad我们可以从中读取某种类型的引用.r是引用的类型,readRef是从引用中获取值的函数,以及m返回结果的方式.以下MonadReadRefref-fdMonadRef类密切相关.

{-# LANGUAGE FunctionalDependencies #-}

class Monad m => MonadReadRef r m | m -> r where
    readRef :: r a -> m a
Run Code Online (Sandbox Code Playgroud)

只要代码在所有MonadReadRef r ms 上进行参数化,它就是纯粹的.我们可以通过使用以下实例中MonadReadRef的普通值的实例来运行它Identity.该idreadRef = id是一样的return . runIdentity.

instance MonadReadRef Identity Identity where
    readRef = id
Run Code Online (Sandbox Code Playgroud)

我们将改写moreVanityThanWealth来讲MonadReadRef.

moreVanityThanWealth :: MonadReadRef r m => MudData r -> m Bool
moreVanityThanWealth md =
    do
        clothing <- readRef . _clothingTbl $ md
        coins    <- readRef . _coinsTbl    $ md
        return ...
Run Code Online (Sandbox Code Playgroud)

当我们MonadReadRefTVars in 添加一个实例时STM,我们可以使用这些"纯"计算STM但泄漏了TVar读取s 的副作用.

instance MonadReadRef TVar STM where
    readRef = readTVar
Run Code Online (Sandbox Code Playgroud)


Pet*_*lák 15

是的,这显然会使您的代码变得复杂,并使重要的代码与许多样板详细信息混杂在一起.具有4个以上参数的函数是问题的标志.

我会问这样一个问题:你真的通过单独的TVars 得到任何东西吗?是不是过早优化的情况?在做出这样的设计决策之前,在多个独立的TVars 之间拆分数据结构之前,我肯定会做一些测量(参见标准).您可以创建一个样本测试来模拟预期的并发线程数和数据更新频率,并通过将多个TVars与单个s与一个s相比来检查你真正获得或失去的是什么IORef.

记住:

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

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

  • 谢谢你的周到答复.我认为你真的很开心:我没有决定在任何基准测试中使用多个TVar.事实上,我的游戏可能永远不会有足够的同时玩家在单个锁上创建严重的争用.尽管如此,我还是想做"最好的方式",并编写一个可扩展的服务器.但我设想的"最好的方式"最终咬我.我可能刚刚学到了另一个教训. (3认同)