Эта статья изначально была размещена в блоге Monday Morning Haskell 13 февраля 2017 года. Посетите блог, чтобы узнать больше о Haskell!

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

Итак, без лишних слов, вот мое определение: монада обертывает значение или вычисление с определенным контекстом. Монада должна определять как средство обертывания нормальных значений в контексте, так и способ объединения вычислений в контексте.

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

Класс типов Monad

Так же, как с функторами и аппликативными функторами, Haskell представляет монады с классом типа. Он выполняет две функции:

class Monad m where
  return :: a -> m a
  (>>=) :: m a -> a -> m b -> m b

Эти две функции соответствуют двум идеям сверху. Функция return указывает, как обернуть значения в контексте монады. Оператор >>=, который мы называем функцией «привязки», указывает, как объединить две операции в контексте. Давайте проясним это дальше, исследуя несколько конкретных экземпляров монад.

Может быть монада

Подобно тому, как Maybe является функтором и аппликативным функтором, это также монада. Чтобы мотивировать монаду Maybe, давайте рассмотрим этот код.

maybeFunc1 :: String -> Maybe Int
maybeFunc1 “” = Nothing
maybeFunc1 str = Just $ length str
maybeFunc2 :: Int -> Maybe Float
maybeFunc2 i = if i `mod` 2 == 0
  then Nothing
  Else Just ((fromIntegral i) * 3.14159)
maybeFunc3 :: Float -> Maybe [Int]
maybeFunc3 f = if f > 15.0
  then Nothing
  else $ Just [floor f, ceil f]
runMaybeFuncs :: String -> Maybe [Int]
runMaybeFuncs input = case maybeFunc1 input of
  Nothing -> Nothing
  Just i -> case maybeFunc2 i of
    Nothing -> Nothing
    Just f -> maybeFunc3 f

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

instance Monad Maybe where
  return = Just
  Nothing >>= _ = Nothing
  Just a >>= f = f a

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

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

runMaybeFuncs :: String -> Maybe [Int]
runMaybeFuncs input = maybeFunc1 input >>= maybeFunc2 >>= maybeFunc3

Это выглядит намного чище! Давайте посмотрим, почему типы работают. Результат maybeFunc1 input просто Maybe Int. Затем оператор связывания позволяет нам взять это значение Maybe Int и объединить его с maybeFunc2, тип которого Int -> Maybe Float. Оператор привязки преобразует их в Maybe Float. Затем мы передаем это аналогичным образом через оператор привязки в maybeFunc3, в результате чего получаем наш последний тип, Maybe [Int].

Однако ваши функции не всегда будут сочетаться так чисто. Здесь в игру вступает do нотация. Мы можем переписать приведенное выше как:

runMaybeFuncs :: String -> Maybe [Int]
runMaybeFuncs input = do
  i <- maybeFunc1 input
  f <- maybeFunc2 f
  maybeFunc3 f

Оператор <- особенный. Он эффективно разворачивает значение справа от монады. Это означает, что значение i имеет тип Int, хотя результат maybeFunc1 равен Maybe Int. Операция связывания происходит под капотом, и если функция возвращает Nothing, тогда вся функция runMaybeFuncs вернет Nothing.

На первый взгляд это выглядит сложнее, чем пример привязки. Однако это дает нам гораздо больше гибкости. Подумайте, хотим ли мы прибавить 2 к целому числу перед вызовом maybeFunc2. С этим легко справиться в do нотации, но сложнее при простом связывании:

runMaybeFuncs :: String -> Maybe [Int]
runMaybeFuncs input = do
  i <- maybeFunc1 input
  f <- maybeFunc2 (i + 2)
  maybeFunc3 f
-- Not so nice
runMaybeFuncsBind :: String -> Maybe [Int]
runMaybeFuncsBind input = maybeFunc1 input
  >>= (\i -> maybeFunc2 (i + 2))
  >>= maybeFunc3

Преимущества еще более очевидны, если мы хотим использовать несколько предыдущих результатов в вызове функции. Используя привязки, нам пришлось бы постоянно накапливать аргументы в анонимных функциях. Одно замечание по поводу нотации do: мы никогда не используем <-, чтобы развернуть последнюю операцию в do-блоке. Наш вызов maybeFunc3 имеет тип Maybe [Int]. Это наш последний тип (не [Int]), поэтому мы не разворачиваем его.

Либо монада

Теперь давайте рассмотрим монаду Either, которая очень похожа на монаду Maybe. Вот определение:

instance Monad (Either a) where
  return r = Right r
  (Left l) >>= _ = Left l
  (Right r) >>= f = f r

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

maybeFunc1 :: String -> Either String Int
maybeFunc1 “” = Left “String cannot be empty!”
maybeFunc1 str = Right $ length str
maybeFunc2 :: Int -> Either String Float
maybeFunc2 i = if i `mod` 2 == 0
  then Left “Length cannot be even!”
  else Right ((fromIntegral i) * 3.14159)
maybeFunc3 :: Float -> Either String [Int]
maybeFunc3 f = if f > 15.0
  then Left “Float is too large!”
  else $ Right [floor f, ceil f]
runMaybeFuncs :: String -> Either String [Int]
runMaybeFuncs input = do
  i <- maybeFunc1 input
  f <- maybeFunc2 i
  maybeFunc3 f

Раньше каждый сбой просто давал нам Nothing значение:

>> runMaybeFuncs ""
Nothing
>> runMaybeFuncs "Hi"
Nothing
>> runMaybeFuncs "Hithere"
Nothing
>> runMaybeFuncs "Hit"
Just [9,10]

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

>> runMaybeFuncs ""
Left "String cannot be empty!"
>> runMaybeFuncs "Hi"
Left "Length cannot be even!"
>> runMaybeFuncs "Hithere"
Left "Float is too large!"
>> runMaybeFuncs "Hit"
Right [9,10]

Обратите внимание, что мы параметризуем монаду Either по типу ошибки. Если у нас есть:

maybeFunc2 :: Either CustomError Float
…

Теперь эта функция находится в другой монаде. Совместить это с другими нашими функциями будет не так просто. Если вам интересно, как мы можем это сделать, ознакомьтесь с этим ответом на qurara.

Монада IO

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

Монада ввода-вывода обертывает вычисления в следующем контексте: «Это вычисление может считывать информацию или записывать информацию в терминал, файловую систему, операционную систему и / или сеть». Если вы хотите получить ввод пользователя, распечатайте сообщение для пользователя, прочтите информацию из файла или сделайте сетевой вызов , вам нужно будет сделать это в монаде ввода-вывода. Это «побочные эффекты». Мы не можем выполнить их из «чистого» кода Haskell.

Самая важная задача практически любой компьютерной программы - это каким-то образом взаимодействовать с внешним миром. По этой причине корнем всего исполняемого кода Haskell является функция main с типом IO (). Итак, каждая программа запускается в монаде ввода-вывода. Отсюда вы можете получить любой нужный вам ввод, вызвать относительно «чистый» код с вводом, а затем каким-то образом вывести результат. Обратное не работает. Вы не можете вызывать код ввода-вывода из чистого кода так же, как вы можете вызывать функцию Maybe из чистого кода.

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

main :: IO ()
main = do
  -- getLine :: IO String
  input <- getLIne
  let uppercased = map Data.Char.toUpper input
  -- print :: String -> IO ()
  print uppercased

Итак, мы снова видим, что каждая строка нашей программы имеет тип IO a. (Оператор let может встречаться в любой монаде). Точно так же, как мы могли бы развернуть i в примере «возможно», чтобы получить Int вместо Maybe Int, мы можем использовать <-, чтобы развернуть результат для getLine как String. Затем мы можем манипулировать этим значением с помощью строковых функций и передавать результат в функцию print.

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

Резюме

Монада оборачивает вычисления в конкретный контекст. Он определяет функции для обертывания значений в своем контексте и операций комбинирования в контексте. Может быть - это монада. Мы описываем его контекст, говоря, что его вычисления могут быть успешными или неудачными. Либо похож на вариант "Может быть", за исключением того, что он может добавлять информацию об ошибках к сбоям. Монада ввода-вывода чрезвычайно важна, поскольку инкапсулирует контекст операций чтения и записи в терминал, сеть и файловую систему. Самый простой способ изучить монадический код - использовать нотацию do. В этих обозначениях каждая строка имеет правое значение монады. Затем вы можете развернуть значение слева, используя оператор <-.

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

Надеюсь, эта статья начала вас с (наконец) понимания монад. Если вы еще не написали код Haskell и хотите приступить к работе, чтобы проверить свои знания в области монад, обязательно ознакомьтесь с нашим бесплатным контрольным списком для начала работы с Haskell!

Не совсем готовы к монадам, но хотите попробовать другие навыки Haskell? Ознакомьтесь с нашей рабочей тетрадью по рекурсии. Он включает в себя 2 главы материала по рекурсии и функциям высшего порядка, а также 10 практических задач с тестовой программой.

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

Чтобы узнать больше, прочтите нашу страницу о нас, поставьте лайк / напишите нам в Facebook или просто tweet / DM @HackerNoon.

Если вам понравился этот рассказ, мы рекомендуем прочитать наши Последние технические истории и Современные технические истории. До следующего раза не воспринимайте реалии мира как должное!