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

Семейства типов - довольно продвинутое понятие. Но если вы новичок, у нас есть много других ресурсов, которые могут вам помочь! Взгляните на наш Контрольный список для начала работы или нашу серию отрывков!

Различные типы типовых отверстий

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

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

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

Базовый регистратор

Во-первых, вот надуманный пример для использования в этой статье. Мы хотим иметь класс типов журналирования. Назовем это MyLogger. В этом классе у нас будет две основные функции. Мы должны иметь возможность получать все сообщения в журнале в хронологическом порядке. Тогда мы сможем регистрировать новое сообщение, отправляя какой-то эффект. Первый проход в этом классе может выглядеть так:

class MyLogger logger where
  prevMessages :: logger -> [String]
  logString :: String -> logger -> logger

Мы можем сделать небольшое изменение, которое будет использовать монаду State вместо передачи логгера в качестве аргумента:

class MyLogger logger where
  prevMessages :: logger -> [String]
  logString :: String -> State logger ()

Но этот класс имеет существенный недостаток. У нас не будет никаких эффектов, связанных с нашим ведением журнала. Что, если мы хотим сохранить сообщение журнала в базе данных, отправить его через сетевое соединение или записать его в консоль? Мы могли бы допустить это, сохранив prevMessages чистоту вот так:

class MyLogger logger where
  prevMessages :: logger -> [String]
  logString :: String -> StateT logger IO ()

Теперь наша logString функция может использовать произвольные эффекты. Но у этого есть очевидный недостаток: он вынуждает нас вводить IO монадные места там, где они нам не нужны. Если нашему регистратору не нужен IO, он нам и не нужен. Так что же нам делать?

Основы семейства типов

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

{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE AllowAmbiguousTypes #-}

Теперь мы создадим type в нашем классе, который относится к монадическому типу эффекта logStringфункции. Мы должны описать «вид» типа с помощью определения. Поскольку это монада, ее вид * -> *. Это означает, что требуется другой параметр типа. Вот как выглядит наше определение:

class MyLogger logger where
  type LoggerMonad logger :: * -> *
  prevMessages :: logger -> [String]
  logString :: String -> (LoggerMonad logger) ()

Некоторые простые примеры

Теперь, когда у нас есть класс, давайте создадим экземпляр, НЕ использующий IO. Мы будем использовать простой тип оболочки для нашего регистратора. Наша «монада» будет содержать регистратор в State. Затем все, что мы делаем при записи строки, - это меняем ее состояние!

newtype ListWrapper = ListWrapper [String]
instance MyLogger ListWrapper where
  type (LoggerMonad ListWrapper) = State ListWrapper
  prevMessages (ListWrapper msgs) = reverse msgs
  logString s = do
    (ListWrapper msgs) <- get
    put $ ListWrapper (s : msgs)

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

newtype StampedMessages = StampedMessages (Data.Map.Map UTCTime String)
instance MyLogger StampedMessages where
  type (LoggerMonad StampedMessages) = StateT StampedMessages IO
  prevMessages (StampedMessages msgs) = Data.Map.elems msgs
  logString s = do
    (StampedMessages msgs) <- get
    currentTime <- lift getCurrentTime
    put $ StampedMessages (Data.Map.insert currentTime s msgs)

Больше ввода-вывода

Теперь пара примеров, которые используют IO в традиционном способе ведения журнала, а также сохраняют сообщения. Наш первый пример - это ConsoleLogger. Он сохранит сообщение в своем State, но также запишет сообщение в консоль.

newtype ConsoleLogger = ConsoleLogger [String]
instance MyLogger ConsoleLogger where
  type (LoggerMonad ConsoleLogger) = StateT ConsoleLogger IO
  prevMessages (ConsoleLogger msgs) = reverse msgs
  logString s = do
    (ConsoleLogger msgs) <- get
    lift $ putStrLn s
    put $ ConsoleLogger (s : msgs)

Другой вариант - записать наши сообщения в файл! Мы сохраним имя файла как часть нашего состояния, хотя при желании могли бы использовать Handle.

newtype FileLogger = FileLogger (String, [String])
instance MyLogger FileLogger where
  type (LoggerMonad FileLogger) = StateT FileLogger IO
  prevMessages (FileLogger (_, msgs)) = reverse msgs
  logString s = do
    (FileLogger (filename, msgs)) <- get
    handle <- lift $ openFile filename AppendMode
    lift $ hPutStrLn handle s
    lift $ hClose handle
    put $ FileLogger (filename, s : msgs)

И мы можем представить, что у нас была бы аналогичная ситуация, если бы мы хотели отправлять журналы по сети. Мы бы использовали наш State для хранения информации о конечном сервере. Или мы могли бы добавить что-то вроде монады Слуги ClientM в наш стек в определении type.

Использование нашего регистратора

Определив наш класс таким образом, мы теперь можем написать полиморфную функцию, которая будет работать с любым из наших регистраторов!

runComputations :: (Logger logger, Monad (LoggerMonad logger)) => InputType -> (LoggerMonad logger) ResultType
runComputations input = do
  logString "Starting Computation!"
  let x = firstFunction input
  logString "Finished First Computation!"
  let y = secondFunction x
  logString "Finished Second Computation!"
  return y

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

Сравнение с другими языками

Честно говоря, это одна из областей системы типов Haskell, которая делает ее немного сложнее в использовании, чем другие языки. Произвольные эффекты могут происходить где угодно в Java или Python. Благодаря этому нам не нужно беспокоиться о сопоставлении эффектов с типами.

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

И типовые семьи дают нам лучшее из обоих миров! Они позволяют нам писать полиморфный код, который может работать как с IO эффектами, так и без них!

Вывод

Это все, что касается нашей серии статей о системе данных Haskell! Мы увидели широкий спектр элементов, от простых до сложных. Мы сравнили Haskell с другими языками. Опять же, простота, с которой можно объявлять данные в Haskell и использовать их полиморфно, была для меня ключевым аргументом в пользу продаж!

Надеюсь, эта серия статей вдохновила вас начать работу с Haskell, если вы еще этого не сделали! Загрузите наш Контрольный список для начала работы или прочтите нашу Серии отрывков, чтобы приступить к работе!