Как избежать иерархии иерархии ООП при переключении на Haskell?

Игра, написанная на языке C++, обычно имеет иерархию классов, такую ​​как

  • CEntity
    • CMoveable
      • CCAR
      • CTank
      • CJetPack
    • CHuman
      • CPedestrian
      • CPlayer
      • CAlien
    • CRigid
      • CROCK
      • CGrenade
    • CMissile
    • CGun
    • CMedkit

Теперь я прочитал, что некоторые из них утверждали, что иерархия классов является неправильной архитектурой даже при использовании С++. Но, по крайней мере, он пытается повторно использовать код. И это очевидный способ загнать все в один управляющий контейнер, поскольку все тривиально вписывается в список CEntity.

Но в любом случае, для кого-то, кто пытается переключиться с С++ на Haskell для создания игр, как изменить архитектуру в соответствии с функциональной парадигмой Haskell?

Ответы

Ответ 1

Я бы сказал, что это ошибка перевода кода OO в Haskell, но вместо этого напишите свою игру с нуля в Haskell.

На мой взгляд, наиболее подходящим инструментом для программирования игр является Функциональное реактивное программирование. Это заставляет вас думать с точки зрения Behaviors и Events - ваши игровые элементы меняются со временем, и вы их объединяете и определяете отношения между ними.

(Вам не нужно вставлять все в один контейнер, если вам не хватает расширенного способа управления обновлениями на мировом уровне и вынуждены выполнять итерацию по некоторой коллекции с применением метода .update(). Функциональное реактивное программирование - это продвинутый способ управления обновлениями.)

Требуется время, чтобы научиться думать о FRP, но это стоит того.

Повторное использование кода (как обычно в Haskell) через

  • подписи с очень общим типом - полиморфные или основанные на классных таблицах.
  • функции более высокого порядка
  • большие абстракции, такие как Functor, Applicative, Foldable, Traversable, Monad
  • избегая ненужного создания нечистого кода - чистый код проще всего комбинировать и повторно использовать
  • выявление сходства в том, как вещи ведут себя или объединяются - не пишите один и тот же код дважды
  • Ломайте свою котельную.
  • Шаблон Haskell

они имеют тенденцию применяться гораздо шире, чем политип подтипа.

Ответ 2

Позвольте мне сначала сказать, что я ничего не знаю о разработке игр, поэтому мой ответ может не соответствовать вашему вопросу.

Говоря, я думаю, что ключ должен задать вопрос: почему вы используете иерархию классов на языке С++? Я думаю, что ответ двоякий: подтип-полиморфизм и повторное использование кода.

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

Это оставляет нам второе преимущество: подтип-полиморфизм. Haskell не поддерживает подтипы или подтипы polymorphsim, но с классами типов он имеет еще один вид ad-hoc-полиморфизма, который является еще более общим (в этом случае не обязательно иметь отношение подтипа для реализации тех же функций).

Итак, мой ответ: подумайте о том, почему вам нужен класс hierachy. Если вы хотите повторное использование кода, то просто повторно используйте код, факторизуя его на достаточно общие функции и повторно используя их, если вы хотите, чтобы полиморфизм использовал классы типов.

Есть некоторые примеры, когда подтипирование действительно полезно, и поэтому иногда это недостаток, который Haskell не поддерживает, но по моему опыту это довольно редко. С другой стороны, наследование, как правило, чрезмерно используется в таких языках, как С++ или Java, потому что это инструмент одного размера для всех, который они предоставляют.

В общем, я согласен с ответом @enoughreptocomment, а именно, что ошибочно воспроизводить проекты OO в Haskell - вы обычно можете сделать гораздо лучше! Я просто пытался указать на то, что дают вам классные иерархии, и как подобные вещи могут быть достигнуты в Haskell.

Изменить (в ответ на комментарий Zeta):

Верно, что в классах типов не допускаются гетерогенные типы в типах данных, таких как списки, однако с дополнительным вспомогательным типом данных это также может быть достигнуто (украдено из Haskell wikibook):

{-# LANGUAGE ExistentialQuantification #-}

data ShowBox = forall s. Show s => SB s

heteroList :: [ShowBox]
heteroList = [SB (), SB 5, SB True]

instance Show ShowBox where
  show (SB s) = show s

f :: [ShowBox] -> IO ()
f xs = mapM_ print xs

main = f heteroList

Ответ 3

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

Компонентная архитектура, с которой вы связаны, так же хороша для повторного использования кода и более легко переводится в Haskell, так как нет иерархии классов.

Например, в С++ у вас будет компонент рендеринга. В С++ вы представляете это как абстрактный интерфейс визуализации и некоторые конкретные классы Render.

class Renderer {
    virtual void draw(double x, double y) = 0;
    virtual void frobnicate(int n) = 0;
};

class HumanRenderer: public Renderer {
  //render Players and Pedestrians...
  //(code reuse!)

  //constructor:
  HumanRenderer(int age);
};

class MedkitRenderer: public Renderer{
  //render the medkit

  //constructor:
  HumanRenderer(Color color);
};

В Haskell вы сделали бы что-то подобное без подтипирования. Тип родительского интерфейса - это всего лишь запись функций:

data Renderer = Renderer {
  rendererDraw :: Double -> Double -> IO (),
  rendererFrobnicate :: Int -> IO ()
}

-- I'm putting everything in the IO monad so the code is side effecting like
--in the C++ version. If you want to avoid this mutation then this is where that 
--functional reactive programming stuff would come in.

а конструкторы для конкретных классов - это просто функции, возвращающие одну из этих записей.

humanRenderer :: Int -> Renderer
humanRenderer age = -- ...

medkitRenderer :: Color -> Renderer
medkitRenderer rgb = -- ...

Обратите внимание, что поскольку существует один тип "Renderer", вы можете поместить различные типы рендеринга в однородный список, как и в cpp (это было бы намного сложнее сделать в подходе типа "тип" ):

renderers :: [Renderer]
renderers = [ humanRenderer 10, humanRenderer 20, medKitRenderer Red ]

Ответ 4

Вопрос задает для Haskell кодировку иерархии классов с двумя целями:

  • возможность переместить все в один управляющий контейнер
  • повторное использование кода

Я использую меньший вариант иерархии классов из вопроса для моих примеров. Самый простой способ достижения цели 1 - иметь один тип данных алгебры для сущностей. Затем мы можем использовать списки или массивы или любой контейнер, который мы хотим, который содержит объекты. Поэтому мы хотим:

data Entity = ...
type ExampleContainer = [Entity]

Как мы должны заполнить ...? Сначала я наивный подход, проанализирую, почему он не может обеспечить повторное использование, а затем превратить это понимание в более сложный подход, который обеспечивает повторное использование.

Наивный подход, плохое повторное использование: (

В иерархии классов CEntity существует несколько типов объектов, поэтому мы можем использовать несколько конструкторов для типа данных Entity:

data Entity
  = Car     Position Velocity Color
  | Player  Position Velocity Gun
  | Door    Position Key
  | Rock    Position

Каждый лист иерархии классов соответствует конструктору, но промежуточные классы не отображаются. Это приводит к дублированию в объявлении типа данных: повторяем Position и Velocity несколько раз. Это дублирование на уровне типа также влияет на остальную часть нашей программы: например, функция, которая перемещает объекты со скоростью на один шаг вперед, будет выглядеть так:

move :: Entity -> Entity
move (Car    position velocity color) = Car  (position + velocity) velocity color
move (Player position velocity gun)   = Tank (position + velocity) velocity gun
move (Door   poosition key)           = Door position key
move (Rock   position)                = Rock position

Дублирование полей Position и Velocity действительно приводит к дублированию формулы position + velocity. Возможно, если мы повторно используем поля Position и Velocity в алгебраическом типе данных, мы также можем повторно использовать формулу position + velocity?

Сложный подход, лучшее повторное использование:)

Мы реструктурируем наши алгебраические данные, чтобы общие поля были разделены. Все объекты имеют позицию, но другие поля различаются в зависимости от того, что у нас есть:

data Entity
  = Entity Position EntityInfo

Перемещение объектов имеет скорость, но неподвижных объектов нет:

data EntityInfo
  = Moving Velocity Moving
  | Fixed Fixed

движущимся объектом может быть автомобиль или игрок:

data Moving
  = Car Color
  | Player Name

И неподвижным объектом может быть дверь или камень:

data Fixed
  = Door Key
  | Rock

Таким образом, у нас все еще есть четыре конструктора Car, Player, Door и Rock, но кроме того, у нас есть конструкторы Entity, Moving и Fixed для хранения информации, доступной для нескольких видов сущностей. Эти дополнительные конструкторы соответствуют промежуточным классам в иерархии классов. Обратите внимание, что мы упоминаем только Position и Velocity один раз, поэтому, надеюсь, дублирование кода в функции move должно исчезнуть. И действительно:

move :: Entity -> Entity
move (Entity position (Moving velocity info))
  = Entity (position + velocity) (Moving velocity info)
move (Entity position (Fixed info)) = Entity position (Fixed info)

Теперь формула position + velocity появляется только один раз, как мы надеялись.

Резюме

Один подход для кодирования иерархии глубоких классов - это алгебраические типы данных. Каждому классу соответствует конструктор, и каждый класс, который имеет подклассы, также соответствует типу данных. Если мы избегаем дублирования полей в этих типах данных, мы также избегаем дублирования кода в коде, который управляет значениями типов данных.

Ответ 5

Это, я думаю, довольно сложный вопрос, потому что он может сильно зависеть от стиля персонального кодирования. Я перешел от больших иерархий наследования к различным проектам, прежде чем я написал что-нибудь в Haskell.

И по моим наблюдениям повторное использование кода в Haskell намного проще, чем на С++ или Java, так как почти все, что вы используете, каждый примитив и элемент ведут себя предсказуемым, аналогичным образом и могут работать при использовании небольшого набора очень общие функции. Это означает, что до тех пор, пока вы придерживаетесь правил, регулирующих идиоматические конструкции Haskell при создании ваших сущностей, тот факт, что код не дублирует, должен прийти естественным образом.

В качестве примера рассмотрим fmap. Это чрезвычайно простой, но чрезвычайно мощный инструмент. Я могу представить, что вы используете своего игрока в качестве Functor и сопоставляете предметы над ним, чтобы они имели эффект. Вы только записываете фактический эффект и определяете игрока; вам не нужно беспокоиться о том, как им придется взаимодействовать, потому что есть "стандартные" способы сделать это.

TL; DR Функции более высокого порядка и классные классы оказываются действительно хорошими при работе с более сложной логикой. Объективы упрощают операции по вложенным данным. Вы должны переосмыслить некоторые вещи, и, вероятно, вы не найдете прямых коллег, но, безусловно, можно написать хороший игровой код в Haskell.