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
类型是包含 IntMap
s 的记录类型,每个都有自己的 TVar
(我使用的是 STM对于并发):
data MudData = MudData { _armorTblTVar :: TVar (IntMap Armor)
, _clothingTblTVar :: TVar (IntMap Clothing)
, _coinsTblTVar :: TVar (IntMap Coins)
...等等。 (我使用的是镜头,因此是下划线。)
一些功能需要特定的 IntMap
,而其他功能需要其他功能。因此,每个 IntMap
在其自己的 TVar
中提供了粒度。
但是,我的代码中出现了一种模式。在处理玩家命令的函数中,我需要读取(有时稍后写入)STM monad 中的 TVar
s。因此,这些函数最终在它们的 where
块中定义了一个 STM 助手。这些 STM 助手通常有很多 readTVar
操作,因为大多数命令需要访问少数 IntMap
s。此外,给定命令的函数可能会调用许多纯辅助函数,这些辅助函数也需要部分或全部 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 ...
MudData
和 MudData'
几乎完全相同。其中一个将其字段包装在 TVar
中,而另一个则没有。我们可以修改 MudData
以便它采用额外的类型参数(类型 * -> *
)来包装字段。MudData
将具有稍微不寻常的类型 (* -> *) -> *
,它与镜头密切相关,但没有太多的库支持。我称这种模式为 Model.
data MudData f = MudData { _armorTbl :: f (IntMap Armor)
, _clothingTbl :: f (IntMap Clothing)
, _coinsTbl :: f (IntMap Coins)
我们可以用MudData TVar
恢复原来的MudData
。我们可以通过包装 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 ...
这个MudData TVar
的STM
版本和我们刚刚为MudData Identity
写的纯版本几乎一模一样。它们的区别仅在于引用的类型(TVar
与 Identity
),我们使用什么函数从引用中获取值(readTVar
与 runIdentity
),以及如何返回结果(在 STM
中或作为普通值)。如果可以使用相同的功能来提供两者,那就太好了。我们将提取这两个函数之间的共同点。为此,我们将为 Monad
引入一个类型 class MonadReadRef r m
,我们可以从中读取某种类型的引用。 r
是引用的类型,readRef
是从引用中获取值的函数,m
是返回结果的方式。以下MonadReadRef
与MonadRef
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
中为 TVar
s 添加一个 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 TVar
s, I'd definitely do some measurements (see criterion吗) .您可以创建一个示例测试来模拟预期的并发线程数和数据更新频率,并通过多个 TVar
与单个 IORef
对比来检查您真正获得或损失的是什么。 =23=]
谨记:
- 如果在一个
STM
事务中有多个线程竞争公共锁,事务可能会在成功完成之前重新启动几次。所以在某些情况下,拥有多个锁实际上会使事情变得更糟。
- 如果最终只有一个数据结构需要同步,您可以考虑使用单个
IORef
。它的原子操作非常快,可以弥补单一中央锁的不足。
- 在 Haskell 中,纯函数很难长时间阻塞原子
STM
或 IORef
事务。原因是懒惰:您只需要在这样的事务中创建 thunk,而不是评估它们。对于单个原子 IORef
尤其如此。 thunks 在此类事务之外进行评估(通过检查它们的线程,或者如果您需要更多控制,您可以决定在某个时候强制它们;这在您的情况下可能是理想的,就好像您的系统在没有任何人观察它的情况下发展一样,你可以很容易地积累未评估的thunks)。
如果事实证明拥有多个 TVar
s 确实很重要,那么我可能会在自定义 monad 中编写所有代码(正如我在写答案时@Cirdec 所描述的),其实现将从主要代码中隐藏起来,并提供读取(也可能写入)部分状态的功能。然后它将 运行 作为单个 STM
事务,只读取和写入需要的内容,并且您可以使用纯版本的 monad 进行测试。
我正在 Haskell 中编写一个 MUD 服务器(MUD = Multi User Dungeon:基本上,一个多用户文本 adventure/role-playing 游戏)。游戏世界 data/state 由大约 15 个不同的 IntMap
代表。我的 monad 转换器堆栈如下所示:ReaderT MudData IO
,其中 MudData
类型是包含 IntMap
s 的记录类型,每个都有自己的 TVar
(我使用的是 STM对于并发):
data MudData = MudData { _armorTblTVar :: TVar (IntMap Armor)
, _clothingTblTVar :: TVar (IntMap Clothing)
, _coinsTblTVar :: TVar (IntMap Coins)
...等等。 (我使用的是镜头,因此是下划线。)
一些功能需要特定的 IntMap
,而其他功能需要其他功能。因此,每个 IntMap
在其自己的 TVar
中提供了粒度。
但是,我的代码中出现了一种模式。在处理玩家命令的函数中,我需要读取(有时稍后写入)STM monad 中的 TVar
s。因此,这些函数最终在它们的 where
块中定义了一个 STM 助手。这些 STM 助手通常有很多 readTVar
操作,因为大多数命令需要访问少数 IntMap
s。此外,给定命令的函数可能会调用许多纯辅助函数,这些辅助函数也需要部分或全部 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 ...
MudData
和 MudData'
几乎完全相同。其中一个将其字段包装在 TVar
中,而另一个则没有。我们可以修改 MudData
以便它采用额外的类型参数(类型 * -> *
)来包装字段。MudData
将具有稍微不寻常的类型 (* -> *) -> *
,它与镜头密切相关,但没有太多的库支持。我称这种模式为 Model.
data MudData f = MudData { _armorTbl :: f (IntMap Armor)
, _clothingTbl :: f (IntMap Clothing)
, _coinsTbl :: f (IntMap Coins)
我们可以用MudData TVar
恢复原来的MudData
。我们可以通过包装 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 ...
这个MudData TVar
的STM
版本和我们刚刚为MudData Identity
写的纯版本几乎一模一样。它们的区别仅在于引用的类型(TVar
与 Identity
),我们使用什么函数从引用中获取值(readTVar
与 runIdentity
),以及如何返回结果(在 STM
中或作为普通值)。如果可以使用相同的功能来提供两者,那就太好了。我们将提取这两个函数之间的共同点。为此,我们将为 Monad
引入一个类型 class MonadReadRef r m
,我们可以从中读取某种类型的引用。 r
是引用的类型,readRef
是从引用中获取值的函数,m
是返回结果的方式。以下MonadReadRef
与MonadRef
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
中为 TVar
s 添加一个 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 TVar
s, I'd definitely do some measurements (see criterion吗) .您可以创建一个示例测试来模拟预期的并发线程数和数据更新频率,并通过多个 TVar
与单个 IORef
对比来检查您真正获得或损失的是什么。 =23=]
谨记:
- 如果在一个
STM
事务中有多个线程竞争公共锁,事务可能会在成功完成之前重新启动几次。所以在某些情况下,拥有多个锁实际上会使事情变得更糟。 - 如果最终只有一个数据结构需要同步,您可以考虑使用单个
IORef
。它的原子操作非常快,可以弥补单一中央锁的不足。 - 在 Haskell 中,纯函数很难长时间阻塞原子
STM
或IORef
事务。原因是懒惰:您只需要在这样的事务中创建 thunk,而不是评估它们。对于单个原子IORef
尤其如此。 thunks 在此类事务之外进行评估(通过检查它们的线程,或者如果您需要更多控制,您可以决定在某个时候强制它们;这在您的情况下可能是理想的,就好像您的系统在没有任何人观察它的情况下发展一样,你可以很容易地积累未评估的thunks)。
如果事实证明拥有多个 TVar
s 确实很重要,那么我可能会在自定义 monad 中编写所有代码(正如我在写答案时@Cirdec 所描述的),其实现将从主要代码中隐藏起来,并提供读取(也可能写入)部分状态的功能。然后它将 运行 作为单个 STM
事务,只读取和写入需要的内容,并且您可以使用纯版本的 monad 进行测试。