Код Haskell, заваленный операциями и функциями TVar, принимающими много аргументов: запах кода?
Я пишу MUD-сервер в Haskell (MUD = Multi User Dungeon: в основном, многопользовательская текстовая приключенческая/ролевая игра). Данные/состояние игрового мира представлены примерно в 15 различных IntMap
s. Модный блок трансформатора выглядит следующим образом: ReaderT MudData IO
, где тип MudData
- это тип записи, содержащий IntMap
s, каждый в своем собственном TVar
(я использую STM для concurrency):
data MudData = MudData { _armorTblTVar :: TVar (IntMap Armor)
, _clothingTblTVar :: TVar (IntMap Clothing)
, _coinsTblTVar :: TVar (IntMap Coins)
... и так далее. (Я использую объективы, поэтому подчеркивания.)
Некоторые функции нуждаются в определенных IntMap
s, тогда как другим функциям нужны другие. Таким образом, наличие каждого IntMap
в своем собственном TVar
обеспечивает детализацию.
Однако в моем коде появился шаблон. В функциях, которые обрабатывают команды игроков, мне нужно читать (а иногда и позже писать) на мой TVar
в монаде STM. Таким образом, эти функции заканчиваются наличием помощника STM, определенного в их блоках where
. У этих помощников STM часто есть несколько операций readTVar
, поскольку большинству команд необходимо получить доступ к нескольким IntMap
s. Кроме того, функция для данной команды может вызывать несколько чистых вспомогательных функций, которые также нуждаются в некоторых или всех IntMap
s. Эти чистые вспомогательные функции, таким образом, иногда приводят к множеству аргументов (иногда более 10).
Итак, мой код стал "замусорен" множеством выражений и функций readTVar
, которые принимают большое количество аргументов. Вот мои вопросы: это запах кода? Не хватает ли какой-либо абстракции, которая сделает мой код более элегантным? Есть ли более идеальный способ структурирования моих данных/кода?
Спасибо!
Ответы
Ответ 1
Да, это, очевидно, делает ваш код сложным и загромождает важный код с большим количеством подробных подробностей. И функции с более чем 4 аргументами являются признаком проблем.
Я задал бы вопрос: Вы действительно получаете что-либо, имея отдельный TVar
s? Разве это не случай преждевременной оптимизации? Прежде чем принять такое конструктивное решение, как разделение структуры данных между несколькими отдельными TVar
s, я определенно сделаю некоторые измерения (см. criterion). Вы можете создать образец теста, который моделирует ожидаемое количество одновременных потоков и частоты обновлений данных и проверяет, что вы действительно набираете или теряете, имея несколько TVar
против единственного vs IORef
.
Имейте в виду:
- Если существует несколько потоков, конкурирующих за общие блокировки в транзакции
STM
, транзакции могут быть перезапущены несколько раз, прежде чем они смогут успешно завершить работу. Поэтому при некоторых обстоятельствах наличие нескольких блокировок может действительно ухудшить ситуацию.
- Если вам нужна только одна структура данных, которую вам нужно синхронизировать, вы можете вместо этого использовать один
IORef
. Это атомные операции очень быстрые, что может компенсировать наличие единого центрального замка.
- В Haskell удивительно сложно для чистой функции блокировать атомную транзакцию
STM
или IORef
в течение длительного времени. Причина - лень: вам нужно только создавать трюки внутри такой транзакции, а не оценивать их. Это справедливо, в частности, для одного атома IORef
. Тунки оцениваются вне таких транзакций (потоком, который их проверяет, или вы можете решить принудительно их в какой-то момент, если вам нужно больше контроля, это может быть желательным в вашем случае, как если бы ваша система эволюционировала, не наблюдая за ней, вы можете легко накапливать неоцененные трюки).
Если окажется, что наличие нескольких TVar
действительно имеет решающее значение, я бы, вероятно, написал весь код в пользовательской монаде (как описано @Cirdec во время написания моего ответа), реализация которого будет скрыта от основной код и который будет обеспечивать функции для чтения (и, возможно, написания) частей состояния. Затем он запускается как одна транзакция STM
, считывая и записывая только то, что нужно, и вы можете иметь чистую версию монады для тестирования.
Ответ 2
Решение этой проблемы заключается в изменении чистых вспомогательных функций. Мы не хотим, чтобы они были чистыми, мы хотим пропустить один побочный эффект - читают ли они определенные части данных.
Скажем, у нас есть чистая функция, которая использует только одежду и монеты:
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
будет иметь слегка необычный вид (* -> *) -> *
, который тесно связан с объективами, но не делает У меня много поддержки библиотеки. Я называю этот шаблон моделью.
data MudData f = MudData { _armorTbl :: f (IntMap Armor)
, _clothingTbl :: f (IntMap Clothing)
, _coinsTbl :: f (IntMap Coins)
Мы можем восстановить исходный MudData
с помощью MudData 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
. Они отличаются только типом ссылки (TVar
vs. Identity
), какую функцию мы используем, чтобы получить значения из ссылок (readTVar
vs runIdentity
) и как результат возвращается (в STM
или как простое значение). Было бы неплохо, если бы одна и та же функция могла использоваться для обеспечения обоих. Мы собираемся извлечь то, что является общим для двух функций. Для этого мы представим класс типа MonadReadRef r m
для Monad
, с которого мы можем прочитать некоторый тип ссылки. 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 m
s, он чист. Мы можем это увидеть, выполнив его со следующим экземпляром MonadReadRef
для обычных значений, хранящихся в Identity
. id
в 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 ...
Когда мы добавляем экземпляр MonadReadRef
для TVar
в STM
, мы можем использовать эти "чистые" вычисления в STM
, но утечка побочного эффекта, из которого TVar
были прочитаны.
instance MonadReadRef TVar STM where
readRef = readTVar