Полиморфные функции Haskell с записями и типами классов

этот пост следует за этим.

Я реализую простую боевую систему как игрушечный проект, типичную систему, которую вы можете найти в таких играх, как Final Fantasy и подобных. Я решил пресловутую проблему «Загрязнение пространства имен» с помощью типа класса + пользовательских экземпляров. Например:

type HitPoints = Integer
type ManaPoints = Integer

data Status = Sleep | Poison | .. --Omitted
data Element = Fire | ... --Omitted

class Targetable a where
    name :: a -> String
    level :: a -> Int
    hp :: a -> HitPoints
    mp :: a -> ManaPoints
    status :: a -> Maybe [Status]

data Monster = Monster{monsterName :: String,
                       monsterLevel :: Int,
                       monsterHp :: HitPoints,
                       monsterMp :: ManaPoints,
                       monsterElemType :: Maybe Element,
                       monsterStatus :: Maybe [Status]} deriving (Eq, Read)

instance Targetable Monster where
    name = monsterName
    level = monsterLevel
    hp = monsterHp
    mp = monsterMp
    status = monsterStatus


data Player = Player{playerName :: String,
                     playerLevel :: Int,
                     playerHp :: HitPoints,
                     playerMp :: ManaPoints,
                     playerStatus :: Maybe [Status]} deriving (Show, Read)

instance Targetable Player where
    name = playerName
    level = playerLevel
    hp = playerHp
    mp = playerMp
    status = playerStatus

Теперь проблема: у меня есть тип заклинания, и заклинание может нанести урон или наложить статус (например, Яд, Сон, Замешательство и т. д.):

--Essentially the result of a spell cast
data SpellEffect = Damage HitPoints ManaPoints
                 | Inflict [Status] deriving (Show)


--Essentially a magic
data Spell = Spell{spellName :: String,
                   spellCost :: Integer,
                   spellElem :: Maybe Element,
                   spellEffect :: SpellEffect} deriving (Show)

--For example
fire   = Spell "Fire"   20 (Just Fire) (Damage 100 0)
frogSong = Spell "Frog Song" 30 Nothing (Inflict [Frog, Sleep])

Как было предложено в связанной теме, я создал общую функцию «приведения», например:

--cast function
cast :: (Targetable t) => Spell -> t -> t
cast s t =
    case spellEffect s of
        Damage hp mana -> t
        Inflict statList -> t

Как видите, тип возвращаемого значения — t, здесь он показан только для согласованности. Я хочу иметь возможность вернуть новую цель (например, монстра или игрока) с некоторым измененным значением поля (например, нового монстра с меньшим количеством здоровья или с новым статусом). Проблема в том, что я не могу просто сделать следующее:

--cast function
cast :: (Targetable t) => Spell -> t -> t
cast s t =
    case spellEffect s of
        Damage hp' mana' -> t {hp = hp', mana = mana'}
        Inflict statList -> t {status = statList}

потому что хп, мана и статус "не являются допустимым селектором записей". Проблема в том, что я априори не знаю, будет ли это монстр или игрок, и я не хочу указывать «monsterHp» или «playerHp», я хочу написать довольно общую функцию. Я знаю, что Haskell Records неуклюжи и не очень расширяемы...

Есть идеи?

До свидания и счастливого кодирования,

Альфредо


person Alfredo Di Napoli    schedule 17.09.2011    source источник
comment
Почему игроки и монстры должны быть разных типов? Кажется, у них много общего. В чем разница между ними?   -  person hammar    schedule 17.09.2011
comment
На данный момент это одно и то же, но в качестве дизайна я решил разделить их. Я не могу исключить, что у них будет другое поле... просто чтобы назвать одно, у монстра может быть элемент (например, пирос - огненный монстр), а у игрока - нет :)   -  person Alfredo Di Napoli    schedule 17.09.2011


Ответы (3)


Лично я думаю, что Хаммар находится на правильном пути, указывая на сходство между Player и Monster. Я согласен, что вы не хотите делать их одинаковыми, но подумайте вот о чем: возьмите тот тип шрифта, который у вас есть...

class Targetable a where
    name :: a -> String
    level :: a -> Int
    hp :: a -> HitPoints
    mp :: a -> ManaPoints
    status :: a -> Maybe [Status]

... и замените его типом данных:

data Targetable = Targetable { name   :: String
                             , level  :: Int
                             , hp     :: HitPoints
                             , mp     :: ManaPoints
                             , status :: Maybe [Status]
                             } deriving (Eq, Read, Show)

Затем вынесите общие поля из Player и Monster:

data Monster = Monster { monsterTarget   :: Targetable
                       , monsterElemType :: Maybe Element,
                       } deriving (Eq, Read, Show)

data Player = Player { playerTarget :: Targetable } deriving (Eq, Read, Show)

В зависимости от того, что вы с ними делаете, может иметь смысл вывернуть его наизнанку:

data Targetable a = Targetable { target :: a
                               , name   :: String
                               -- &c...
                               }

...а затем есть Targetable Player и Targetable Monster. Преимущество здесь в том, что любые функции, которые работают с любым из них, могут принимать объекты типа Targetable a — точно так же, как функции, которые принимали бы любой экземпляр класса Targetable.

Мало того, что этот подход почти идентичен тому, что у вас уже есть, он также содержит намного меньше кода и упрощает типы (за счет отсутствия везде ограничений классов). Фактически, приведенный выше тип Targetable примерно соответствует тому, что GHC создает за кулисами для класса типов.

Самым большим недостатком этого подхода является то, что он делает доступ к полям более громоздким — в любом случае, некоторые вещи оказываются двухуровневыми, и распространение этого подхода на более сложные типы может привести к их еще более глубокому вложению. Во многом это делает неудобным тот факт, что методы доступа к полям не являются «первоклассными» в языке — вы не можете передавать их как функции, абстрагироваться от них или что-то в этом роде. Самое популярное решение - использовать «линзы», о которых уже упоминалось в другом ответе. Я обычно использовал для этого пакет fclabels, так что это моя рекомендация.

Предлагаемые мной факторизованные типы в сочетании со стратегическим использованием линз должны дать вам что-то более простое в использовании, чем подход с классами типов, и не загрязняющий пространство имен, как это делает наличие большого количества типов записей.

person C. A. McCann    schedule 17.09.2011
comment
Спасибо за предложения. Я уже ознакомился с пакетом fclabels, но его применение кажется довольно спорным Одно из препятствий для принятия fclabels заключается в том, что основной пакет включает в себя сантехнику template-haskell, поэтому пакет не является Haskell 98, и он также требует ( довольно непротиворечивый) расширение TypeOperators. - person Alfredo Di Napoli; 18.09.2011
comment
пс. А пока я постараюсь сделать свой код как можно более чистым, избегая fclabels и объединяя Monster и Player в один общий тип :) - person Alfredo Di Napoli; 18.09.2011
comment
@Alfredo Di Napoli: Я бы не слишком беспокоился о fclabels. Расширение TypeOperators на самом деле не имеет большого значения, а Template Haskell просто используется для автоматической генерации линз, а не для их самостоятельного определения, что означает, что ваш фактический код вообще не будет зависеть от TH. Однако есть и другие пакеты объективов, так что, может быть, один из них подойдет вам больше? - person C. A. McCann; 18.09.2011

Я могу предложить три возможных решения.

1) Ваши типы очень похожи на OO, но Haskell также может выражать типы "sum" с параметрами:

data Unit = UMon Monster | UPlay Player

cast :: Spell -> Unit -> Unit
cast s t =
case spellEffect s of
    Damage hp' mana' -> case t of
                          UMon m -> UMon (m { monsterHp = monsterHp m - hp', monsterMana = undefined})
                          UPluy p -> UPlay (p { playerHp = playerHp p - hp'})
    Inflict statList -> undefined

Вещи, похожие в объектно-ориентированном дизайне, часто становятся типами "сумма" с параметрами в Haskell.

2) Вы можете сделать то, что предлагает Карстон, и добавить все свои методы в классы типов.

3) Вы можете изменить свои методы только для чтения в Targetable, чтобы они были «линзами», которые раскрывают как получение, так и настройку. См. обсуждение переполнения стека. Если бы ваш тип класса возвращал линзы, это позволяло бы применить урон от заклинаний.

person Chris Kuklewicz    schedule 17.09.2011
comment
Что ж, мне очень нравится ваше первое решение, кажется, оно короче, чем у Карстена. Как обычно, это своего рода хак/обходной путь для заполнения дыр в записи :) Я также хотел бы взглянуть на линзы и их аналоги, несмотря на то, что я хочу избежать механизма геттеров/сеттеров, который я ненавижу в Java :) - person Alfredo Di Napoli; 17.09.2011
comment
@Alfredo Di Napoli: геттеры и сеттеры плохи в Java в значительной степени потому, что они идут вразрез со всей концепцией хорошего объектно-ориентированного проектирования, которая заключается в абстрагировании от поведения и внутренних реализаций. С другой стороны, типы данных здесь предназначены для предоставления вам доступа к их внутреннему содержанию. Наличие большого количества методов получения и установки возникает из-за попытки навязать способ, которым вы обычно делаете это в Haskell, в Java. Если мне не изменяет память, Scala фактически объединяет их напрямую, используя синтаксический сахар для разных способов использования типа. - person C. A. McCann; 17.09.2011

Почему бы вам просто не включить такие функции, как

InflicteDamage :: a -> Int -> a
AddStatus :: a -> Status -> a

в ваш тип-класс?

person Random Dev    schedule 17.09.2011
comment
Да, это возможное решение, но вынуждает меня писать две почти равные функции в моих экземплярах, где единственная разница будет в именах полей.. скучно и так много стандартного кода: P - person Alfredo Di Napoli; 17.09.2011
comment
затем перейдите к ответу Криса - но это старая дискуссия - в этом случае это проще, но становится сложнее, если вы добавите другой тип юнита. - person Random Dev; 17.09.2011
comment
Да, у обоих решений есть плюсы и минусы, я оставлю вопрос открытым на некоторое время, а затем перейду к ответу. Спасибо всем, я знаю, что это не ваша вина, а корявые записи Haskell :) - person Alfredo Di Napoli; 17.09.2011
comment
Я думаю, что объектно-ориентированный дизайн просто неуклюж в Haskell, поскольку проектирование FP (с классами типов) может быть очень сложным или невозможным в других языках. Но продолжайте - в любом случае мне любопытен результат вашей работы ;) - person Random Dev; 17.09.2011
comment
Просто чтобы быть ясным, последнее, что я хочу, это эмулировать объектно-ориентированный дизайн / объектно-ориентированные языки в Haskell, но я не могу найти лучшего способа инкапсулировать такое поведение с помощью FP (например, каждое заклинание имеет свой эффект) и Я постоянно конфликтую на Records :P Оставайтесь с нами и помогите мне :D - person Alfredo Di Napoli; 17.09.2011