Семьи типов и Покемоны

в 6:45, , рубрики: associated type synonyms, haskell, type class, type families, переводы, функциональное программирование

Предисловие

Когда я начал изучать Хаскель, я был почти сразу поражён. Для начала, нырнув с головой в актуальные рабочие проекты, открыл, что большинство настоящих библиотек используют языковые расширения, присутствующие только в GHC (Glasgow Haskell Compiler). Это меня покоробило слегка, прежде всего потому, кто захочет использовать язык настолько немощный, что будет необходимо использовать расширения, присутствующие лишь у одного поставщика. Ведь так?
Хорошо, я решился снова это осилить и узнать всё об этих расширениях, и я вывел три горячих топика для общества Хаскеля, которые решали похожие проблемы: Обобщённые Алгебраические Типы Данных, Семьи Типов и Функциональные Зависимости. Пытаясь найти ресурсы, которые обучают о них, я смог найти только статьи, описывающие, что это такое, и как их использовать. Но никто, на самом деле, не объяснял зачем они нужны!.. Поэтому я решил написать эту статью, используя дружественный пример, пытаясь объяснить зачем всё-таки нужны Семьи Типов.

Семьи типов и Покемоны

Вы когда-нибудь слышали про Покемонов? Это замечательные существа, которые населяют Мир Покемонов. Вы можете считать, что они как животные с экстраординарными способностями. Все покемоны владеют стихией, и все их возможности зависят от этой стихии. Например, покемон Огненной стихии может дышать огнём, в то время как покемон Водной стихии может брызгать струями воды.
Покемоны принадлежат людям, и их специальные способности могут быть использованы во благо для продуктивной деятельности, но некоторые люди всего лишь используют своих покемонов для борьбы с другими покемонами других людей. Эти люди называют себя Тренерами Покемонов. Это может сначала звучать как жестокое обращение с животными, но это очень даже весело и все, похоже, рады, включая покемонов. Имейте в виду, что в мире покемонов, кажется, всё в порядке, даже если 10-летние покидают дом, дабы рисковать своими жизнями ради того, чтобы стать самыми лучшими Тренерами Покемонов, как будто никто и никогда таковыми не становился.

Мы собираемся использовать Хаскель для того, что бы представить ограниченную (и даже несколько упрощённую, да простят меня фанаты) часть мира покемонов. А именно:

  • Покемон имеет тип или стихию, в нашем случае урезанную до Огня, Воды и Травы
  • Существуют три покемона каждой стихии:
    • Чармандер, Чармелион и Чаризард — Огненные покемоны,
    • Сквиртл, Вартортл и Блестойз — Водной стихии,
    • Бульбазавр, Ивизавр и Венозавр — Травяной стихии
  • Каждая стихия имеет свои собственные способности, называемые движениями или ударами: водные покемоны выполняют водные удары, огненные покемоны — огненные удары, травяные — травяные удары
  • Когда бьются, огненный покемон всегда побеждает травяного покемона, травяной покемон всегда побеждает водного покемона, а водный покемон всегда побеждает огненного покемона
  • Никогда не дерутся покемоны одной стихии, поскольку нельзя определить, кто из них выиграет
  • Другие люди должны быть способны пополнять программу своими покемонами в своих модулях
  • Проверщик Типов (часть интерпретатора и компилятора) должен помочь нам в соблюдении правил

Первая попытка

Для начала попытаемся реализовать правила без использования классов типов и семей типов.
Начнём с нескольких стихий покемонов и их движений. Мы будем реализовывать отдельно, поскольку это поможет нам отличить их движения от типов покемонов.
Для этой цели мы определим функции для каждого покемона, выбрав его движение.

data Fire = Charmander | Charmeleon | Charizard deriving Show 
data Water = Squirtle | Wartortle | Blastoise deriving Show
data Grass = Bulbasaur | Ivysaur | Venusaur deriving Show

data FireMove = Ember | FlameThrower | FireBlast deriving Show 
data WaterMove = Bubble | WaterGun deriving Show
data GrassMove = VineWhip deriving Show

pickFireMove :: Fire -> FireMove
pickFireMove Charmander = Ember
pickFireMove Charmeleon = FlameThrower
pickFireMove Charizard = FireBlast

pickWaterMove :: Water -> WaterMove
pickWaterMove Squirtle = Bubble
pickWaterMove _ = WaterGun

pickGrassMove :: Grass -> GrassMove
pickGrassMove _ = VineWhip

Пока всё хорошо, проверщик типов помогает разобраться, где какой покемон правильно использует свою стихию.

Семьи типов и Покемоны
6 из описываемых нами 9 покемонов всех трёх стихий. По 2 на каждый тип

Теперь мы должны реализовать бой. Бои будут представлять собой вывод сообщения, где описано как каждый покемон бьёт, затем указываем победителя, например, так:

printBattle :: String -> String -> String -> String -> String -> IO ()
printBattle pokemonOne moveOne pokemonTwo moveTwo winner = do
  putStrLn $ pokemonOne ++ " used " ++ moveOne
  putStrLn $ pokemonTwo ++ " used " ++ moveTwo
  putStrLn $ "Winner is: " ++ winner ++ "n"

Это всего лишь отображение движений, мы сами должны найти победителя, на основании стихии покемона и его ударов. Вот пример функции боя между Огненной и Водной стихиями:

battleWaterVsFire :: Water -> Fire -> IO ()
battleWaterVsFire water fire = do
  printBattle (show water) moveOne (show fire) moveTwo (show water)
 where
  moveOne = show $ pickWaterMove water
  moveTwo = show $ pickFireMove fire

battleFireVsWater = flip battleWaterVsFire -- То же самое, что и выше, только с аргументами, которых поменяли местами

Если всё это объединим, и допишем другие функции драк, мы получим программу.

Введение в Классы типов

Сколько в этом повторенного кода: представьте себе, что кто-то захотел добавить Электрической стихии покемонов, например Пикачу, тогда придётся дописывать собственные функции драк battleElectricVs(Grass|Fire|Water). Есть несколько шаблонов, которые помогут нам формализовать и помочь людям получить большее понимание, что такое покемоны и как добавлять новые.
Что мы имеем:

  • покемоны используют функции, чтобы выбрать движение
  • Битвы находят победителя и печатают описание битвы

Мы определим несколько классов типов для формализации, и раз мы будем править, мы так же переименуем необычную ныне схему имён, где каждая функция включает стихию, с которой оперирует.

Класс покемонов

Класс покемонов отображает знания, что покемон выбрал своё движение. Это позволит нам определить pickMove, пере-используя так, что одна и та же функция может оперировать разными стихиями, для которых определён класс.

В отличие от «ванильных» классов, наш класс покемонов будет нуждаться в 2х типах: стихии покемона и типа урона им используемым, а позже одно будет зависеть от другого. Мы должны включить языковое расширение, разрешающее иметь 2 параметра в классе: MultiParamTypeClasses
Заметьте, что мы должны добавить ограничения, такие, что покемоны и их удары должны иметь возможность быть выведенными на экран.
Вот определение, наряду с несколькими экземплярами для существующих стихий покемонов.

class (Show pokemon, Show move) => Pokemon pokemon move where
  pickMove :: pokemon -> move

instance Pokemon Fire FireMove where
  pickMove Charmander = Ember
  pickMove Charmeleon = FlameThrower
  pickMove Charizard = FireBlast

instance Pokemon Water WaterMove where
  pickMove Squirtle = Bubble
  pickMove _ = WaterGun

instance Pokemon Grass GrassMove where
  pickMove _ = VineWhip

и сможем использовать функцию так

pickMove Charmander :: FireMove

Заметьте, как вещи начинают выглядеть неопрятно. Из-за того, что стихии покемонов и типы движения обрабатываются независимо классами типов. Говоря, что мы выбираем Огненный удар, мы даём проверщику типов всю информацию, для того, что бы он решил, какой использовать класс и удар.

Класс битвы

У нас уже есть покемоны, которые могут выбирать себе удары, теперь нам необходима абстракция, которая будет представлять битву двух покемонов, для того, что бы избавиться от функций типа battle*family*Vs*family
Нам бы очень хотелось написать код так:

class (Pokemon pokemon move, Pokemon foe foeMove) => Battle pokemon move foe foeMove where
  battle :: pokemon -> foe -> IO ()
  battle pokemon foe = do
    printBattle (show pokemon) (show move) (show foe) (show foeMove) (show pokemon)
   where
    move = pickMove pokemon
    foeMove = pickMove foe

instance Battle Water WaterMove Fire FireMove

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

battle :: pokemon -> foe -> IO (move, foeMove)

Введение Семей типов, наконец-то!

Ну вот, наша программа выглядит удручающе. Мы должны заботится обо всех подписях типов, и мы даже обязаны изменять внутреннее поведение наших функций (battle) только для того, чтобы мы могли использовать подписи типов для того, дабы помочь компилятору. Я могу пойти значительно дальше и сказать, что наш нынешний рефакторинг программы — лишь чуть более формальный и менее повторяемый, не настолько уж и большое достижение, после того, как мы ввели столько безобразия в код.
Теперь мы можем оглянутся назад, на наше определение класса Покемон. Он имеет стихию покемонов и тип ударов как два отдельных переменных класса. Проверщик типов не знает о существовании связи между стихиями покемонов и типами ударов. Он даже позволяет определить экземпляр Покемонов, когда Водный покемон создаёт Огненные удары!
Именно тут семьи типов вступают в игру: они позволяют сказать проверщику типов, что Огненный покемон может работать только с Огненными ударами и так далее.

Класс Покемон, используя семьи типов

Для того, что бы использовать Семьи типов нам необходимо включить расширение TypeFamilies. Как только мы подключим, мы сможем попробовать написать наш класс в стиле:

{-# LANGUAGE TypeFamilies, FlexibleContexts #-}
class (Show p, Show (Move p)) => Pokemon p where
  data Move p :: *
  pickMove :: p -> Move p

Мы определили наш класс Покемон таким образом, что он имеет один аргумент и один ассоциированный тип Движения. Тип Движения становится «функцией типа», возвращающей тип удара, который будет использован. Это означает, что мы будем вместо FireMove использовать Move Fire, вместо WaterMoveMove Water и т.д.
Заметим, что зависимость выглядит почти как в предыдущем случае, только вместо Show move мы используем Show (Move a)). Нам необходимо включить ещё одно дополнение: FlexibleContexts, что бы работать с этим.
Теперь Хаскель обеспечивает нас отличным синтаксическим сахаром, поэтому мы можем определить актуальный ассоциированный конструктор данных справа, когда мы определяем наш экземпляр.
Давайте переопределим все наши типы данных и создадим необходимые экземпляры класса, используя семьи типов.

data Fire = Charmander | Charmeleon | Charizard deriving Show
instance Pokemon Fire where
  data Move Fire = Ember | FlameThrower | FireBlast deriving Show
  pickMove Charmander = Ember
  pickMove Charmeleon = FlameThrower
  pickMove Charizard = FireBlast

data Water = Squirtle | Wartortle | Blastoise deriving Show
instance Pokemon Water where
  data Move Water = Bubble | WaterGun deriving Show
  pickMove Squirtle = Bubble
  pickMove _ = WaterGun

data Grass = Bulbasaur | Ivysaur | Venusaur deriving Show
instance Pokemon Grass where
  data Move Grass = VineWhip deriving Show
  pickMove _ = VineWhip

Теперь мы можем спокойно писать

pickMove Squirtle

и получить результат.
Это красиво, правда же? Нет больше необходимости писать подписи, для того, чтобы выбрать удар.
Однако ещё рано сравнивать с начальным вариантом. Лучше сравнить финальный результат, чтобы получить полный эффект от увиденного.

Новый класс Битвы

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

class (Pokemon pokemon, Pokemon foe) => Battle pokemon foe where
  battle :: pokemon -> foe -> IO ()
  battle pokemon foe = do
    printBattle (show pokemon) (show move) (show foe) (show foeMove) (show pokemon)
   where
    foeMove = pickMove foe
    move = pickMove pokemon

И, заметьте, теперь Битве более нет необходимости знать что-либо про удары. И бьющиеся покемоны выглядят почти так же, как наивная имплементация.

instance Battle Water Fire
instance Battle Fire Water where
  battle = flip battle

instance Battle Grass Water
instance Battle Water Grass where
  battle = flip battle

instance Battle Fire Grass
instance Battle Grass Fire where
  battle = flip battle

Использовние тоже просто:

  battle Squirtle Charmander

Это всё! Наша программа наконец приобрела отличный вид, мы улучшили её, и проверщик типов проверяет больше, меньше повторяем и имеем чистую API для того, что бы предлагать её другим разработчикам.

Классно! Мы сделали это! Надеюсь, вам понравилось!
Ладно-ладно. Я понял, что вам весело и вы не можете поверить, что всё уже закончилось, потому что ваш скролбар в браузере показывает, что ещё есть место пониже этой фразы.

Что же, давайте добавим ещё одну вещь в Мир Покемонов.
Сейчас мы определили наши экземпляры Битвы для стихий Water и Fire как Battle Water Fire, и затем Battle Water Fire таким же самым как и предыдущий, с аргументами поменянными местами. Первый покемон всегда выигрывает, и выводится всегда следующее:

-- Winner Pokemon move
-- Loser Pokemon move
-- Winner pokemon Wins.

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

-- Loser Pokemon move
-- Winner Pokemon move
-- Winner pokemon Wins

Ассоциированные Синонимы типов

Когда мы решаем возвращать выбор двух типов, мы обычно используем Either a b, но это в ран-тайме, мы же хотим, что бы проверщик типов был уверен, что когда будут драться стихии Огонь и Вода, Вода будет всегда победителем.
Поэтому мы добавим новую функцию в Битву и назовём её победитель, которая будет получать 2 аргумента в том же самом прядке, которые были получены функцией битвы, и решим кто будет выигрывать.
Однако возвращать одно из нескольких вариантов вызывает неопределённость выбора подписи у победителя.

class Battle pokemon foe where
..
winner :: pokemon -> foe -> ??? -- Так что же, 'pokemon' или 'foe'?

instance Battle Water Fire where
  winner :: Water -> Fire -> Water -- Water первая переменная класса : pokemon
  winner water _ = water

instance Battle Fire Water where
  winner :: Fire -> Water -> Water -- Water вторая переменная класса: foe
  winner _ water = water

Видите, для Battle Water Fire экземпляра возвращается тип победителя такого же, как и pokemon, а у Battle Fire Water это уже будет foe.

К счастью, семьи типов так же поддерживают ассоциированные синонимы типов. В классе Битвы мы будет иметь Winner pokemon foo, а в экземплярах будем определять, кто же из них им будет. Мы используем тип, а не данные, потому что это всего лишь синоним pokemon или foe.
Самостоятельно Winner является функцией типа с подписью видов * -> * -> *, которая получает обоих pokemon и foo, и возвращает одного из них.
Мы так же определим реализацию по умолчанию, которая будет выбирать pokemon

class (Show (Winner pokemon foe), Pokemon pokemon, Pokemon foe) => Battle pokemon foe where
  type Winner pokemon foe :: *      -- это ассоцированный тип
  type Winner pokemon foe = pokemon -- это его имплементация по умолчанию

  battle :: pokemon -> foe -> IO ()
  battle pokemon foe = do
    printBattle (show pokemon) (show move) (show foe) (show foeMove) (show winner)
   where
    foeMove = pickMove foe
    move = pickMove pokemon
    winner = pickWinner pokemon foe
  
  pickWinner :: pokemon -> foe -> (Winner pokemon foe)

Экземпляры создаются так:

instance Battle Water Fire where
  pickWinner pokemon foe = pokemon

instance Battle Fire Water where
  type Winner Fire Water = Water
  pickWinner = flip pickWinner

Теперь уж точно всё.
Окончательный вариант программы таков:

Окончательный вариант битв покемонов

{-# LANGUAGE TypeFamilies, MultiParamTypeClasses, FlexibleContexts #-}
class (Show pokemon, Show (Move pokemon)) => Pokemon pokemon where
  data Move pokemon :: *
  pickMove :: pokemon -> Move pokemon

data Fire = Charmander | Charmeleon | Charizard deriving Show
instance Pokemon Fire where
  data Move Fire = Ember | FlameThrower | FireBlast deriving Show
  pickMove Charmander = Ember
  pickMove Charmeleon = FlameThrower
  pickMove Charizard = FireBlast

data Water = Squirtle | Wartortle | Blastoise deriving Show
instance Pokemon Water where
  data Move Water = Bubble | WaterGun deriving Show
  pickMove Squirtle = Bubble
  pickMove _ = WaterGun

data Grass = Bulbasaur | Ivysaur | Venusaur deriving Show
instance Pokemon Grass where
  data Move Grass = VineWhip deriving Show
  pickMove _ = VineWhip

printBattle :: String -> String -> String -> String -> String -> IO ()
printBattle pokemonOne moveOne pokemonTwo moveTwo winner = do
  putStrLn $ pokemonOne ++ " used " ++ moveOne
  putStrLn $ pokemonTwo ++ " used " ++ moveTwo
  putStrLn $ "Winner is: " ++ winner ++ "n"

class (Show (Winner pokemon foe), Pokemon pokemon, Pokemon foe) => Battle pokemon foe where
  type Winner pokemon foe :: *
  type Winner pokemon foe = pokemon

  battle :: pokemon -> foe -> IO ()
  battle pokemon foe = do
    printBattle (show pokemon) (show move) (show foe) (show foeMove) (show winner)
   where
    foeMove = pickMove foe
    move = pickMove pokemon
    winner = pickWinner pokemon foe
  
  pickWinner :: pokemon -> foe -> (Winner pokemon foe)

instance Battle Water Fire where
  pickWinner pokemon foe = pokemon

instance Battle Fire Water where
  type Winner Fire Water = Water
  pickWinner = flip pickWinner

instance Battle Grass Water where
  pickWinner pokemon foe = pokemon

instance Battle Water Grass where
  type Winner Water Grass = Grass
  pickWinner = flip pickWinner

instance Battle Fire Grass where
  pickWinner pokemon foe = pokemon

instance Battle Grass Fire where
  type Winner Grass Fire = Fire
  pickWinner = flip pickWinner
    
main :: IO ()
main = do
  battle Squirtle Charmander
  battle Charmeleon Wartortle
  battle Bulbasaur Blastoise
  battle Wartortle Ivysaur
  battle Charmeleon Ivysaur
  battle Venusaur Charizard

Теперь можно своего Электрического покемона очень просто добавить! Попробуйте!

P.S. Оригинал статьи Type Families and Pokemon
Статья печатается с сокращениями, поскольку предназначена для интерактивного взаимодействия.

Автор: Vitter

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js