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

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

Для более подробного обсуждения монад ознакомьтесь с нашей Серией функциональных структур данных!

Общая схема синтаксиса Do

Использование синтаксиса do - один из ключей к пониманию того, как на самом деле использовать монады. Оператор связывания затрудняет отслеживание ваших аргументов. Синтаксис Do сохраняет структуру в чистоте и позволяет с легкостью передавать результаты. Давайте посмотрим, как это работает с IO, первой монадой, которую изучают многие хаскеллеры. Вот пример, в котором мы читаем вторую строку из файла:

readLineFromFile :: IO String
readLineFromFile = do
  handle <- openFile “myFile.txt” ReadMode
  nextLine <- hGetLine handle
  secondLine <- hGetLine handle
  _ <- hClose handle
  return secondLine

Помня сигнатуры типов всех IO функций, мы можем начать видеть общий образец синтаксиса do. Давайте заменим каждое выражение его типом:

openFile :: FilePath -> IOMode -> IO Handle
hGetLine :: Handle -> IO String
hClose :: Handle -> IO ()
return :: a -> IO a
readLineFromFile :: IO String
readLineFromFile = do
  (Handle) <- (IO Handle)
  (String) <- (IO String)
  (String) <- (IO String)
  () <- (IO ())
  IO String

Каждая строка в выражении do (кроме последней) использует оператор присваивания <-. Затем он имеет выражение IO a с правой стороны, которому он присваивает значение a с левой стороны. Затем тип последней строки соответствует окончательному возвращаемому значению этой функции. Сейчас важно понять, что мы можем обобщить эту структуру на ЛЮБУЮ монаду:

monadicFunction :: m c
monadicFunction = do
  (_ :: a) <- (_ :: m a)
  (_ :: b) <- (_ :: m b)
  (_ :: m c)

Так, например, если у нас есть функция в монаде Maybe, мы можем использовать ее и подключить ее для m выше:

myMaybeFunction :: a -> Maybe a
monadicMaybe :: a -> Maybe a
monadicMaybe x = do
  (y :: a) <- myMaybeFunction x
  (z :: a) <- myMaybeFunction y
  (Just z :: Maybe a)

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

Список Монада

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

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

runTurn :: Int -> [Int]
runTurn x = [x - 1, x, x + 1]

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

runGame :: Int -> [Int]
runGame x = do
  (m1 :: Int) <- (runTurn x :: [Int])
  (m2 :: Int) <- (runTurn m1 :: [Int])
  (m3 :: Int) <- (runTurn m2 :: [Int])
  (m4 :: Int) <- (runTurn m3 :: [Int])
  (m5 :: Int) <- (runTurn m4 :: [Int])
  return m5

Справа каждое выражение имеет тип [Int]. Затем с левой стороны достаем наш Int. Таким образом, каждое из m выражений представляет собой одно из множества решений, которые мы получим от runTurn. Затем мы запускаем оставшуюся часть функции, представляя, что используем только один из них. На самом деле мы запустим их все из-за того, как монада списка определяет свой оператор связывания. Этот мысленный прыжок немного сложен. И часто бывает более интуитивно понятным просто придерживаться where выражений при вычислении списков. Но здорово видеть, как подобные шаблоны появляются в неожиданных местах.

Функциональная монада

Функциональная монада - это еще одна, которую я некоторое время пытался понять. В некотором смысле это то же самое, что Reader монада. Он инкапсулирует контекст наличия одного аргумента, который мы можем передать разным функциям. Но он определяется не так, как Reader. Когда я попытался найти определение, оно не имело для меня особого смысла:

instance Monad ((->) r) where
  return x = \_ -> x
  h >>= f = \w -> f (h w) w

Определение return имеет смысл. У нас будет функция, которая принимает аргумент, игнорирует этот аргумент и выдает значение в качестве вывода. Оператор связывания немного сложнее. Когда мы свяжем две функции вместе, мы получим новую функцию, которая принимает некоторый аргумент w. Мы применим этот аргумент против нашей первой функции ((h w)). Затем мы возьмем результат, применим его к f, а ЗАТЕМ снова применим аргумент w. Это немного сложно уследить.

Но давайте подумаем об этом в контексте синтаксиса do. Каждое выражение справа будет функцией, которая принимает наш тип в качестве единственного аргумента.

myFunctionMonad :: a -> (x, y, z)
myFunctionMonad = do
  x <- :: a -> b
  y <- :: a -> c
  z <- :: a -> d
  return (x, y, z)

А теперь представим, что мы передадим Int и воспользуемся несколькими различными функциями, которые могут принимать Int. Вот что мы получим:

myFunctionMonad :: Int -> (Int, Int, String)
myFunctionMonad = do
  x <- (1 +)
  y <- (2 *)
  z <- show
  return (x, y, z)

И теперь у нас есть действующий синтаксис do! Итак, что происходит, когда мы запускаем эту функцию? Мы будем вызывать наши разные функции на одном и том же входе.

>> myFunctionMonad 3
(4, 6, "3")
>> myFunctionMonad (-1)
(0, -2, "-1")

Когда мы передаем 3 в первом примере, мы добавляем к нему 1 в первой строке, умножаем его на 2 во второй строке и show в третьей строке. И все это мы делаем без явной аргументации! Сложность заключается в том, что все ваши функции должны принимать входной аргумент в качестве своего последнего аргумента. Так что вам, возможно, придется немного перевернуть аргумент.

Вывод

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

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