Собираемся с духом и перестаем бояться монад

14 августа 2013

Когда я впервые увидел код в стиле f1 >>= \x -> f2 >>= \y -> Right (x, y) моя реакция была «Ааа! Что тут происходит? Как вообще кто-то может писать на таком языке?». Но, как выяснилось, если сесть и спокойно во всем разобраться, в монадах нет абсолютно ничего сложного. Кроме того, оказывается, монады имеют множество важных практических применений и способны существенно облегчить выполнение нашей с вами повседневной работы.

Что такое монада?

В Haskell монада — это совершенно обычный класс типов:

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

С тем же успехом мы можем объявить интерфейс в Java или абстрактный класс в C++. В большинстве случаев для превращения некого типа в монаду достаточно определить только функции (>>=) (произносится «bind») и return, потому что остальные функции имеют разумную реализацию по умолчанию.

Функции (>>=), (>>), return и fail

Давайте временно забудем о монадах вообще и подумаем об одной конкретной, всем хорошо знакомой, монаде IO. Если заменить в перечисленных ранее функциях «m» на «IO», получим:

(>>=)  :: IO a -> (a -> IO b) -> IO b
(>>)   :: IO a -> IO b -> IO b
return :: a -> IO a
fail   :: String -> IO a

Вспомним тип функции putStrLn:

ghci> :t putStrLn
putStrLn :: String -> IO ()
ghci> :t putStrLn "hello"
putStrLn "hello" :: IO ()

Смотрите-ка! Функция putStrLn, примененная к строке, имеет тип IO (), прямо как аргументы функции (>>), если заменить a или b на конкретный тип (). Давайте попробуем вызвать (>>) и посмотрим, что будет:

ghci> putStrLn "hello" >> putStrLn "world"
hello
world
ghci> :t putStrLn "hello" >> putStrLn "world"
putStrLn "hello" >> putStrLn "world" :: IO ()

Как интересно! Сначала была выведена первая строка, а потом вторая. При этом тип всего выражения оказался IO (), что позволяет снова передать его в качестве аргумента функции (>>):

ghci> putStrLn "hello" >> putStrLn "world" >> putStrLn "of monads"
hello
world
of monads

Получается, (>>) позволяет создавать что-то вроде цепочек последовательно выполняемых команд. Есть, правда, одна проблема. Что, если я хочу передать второй функции в цепочке значение, возвращенное первой функцией? Например, сначала получить от пользователя строку с помощью getLine, а затем вывести эту строку с помощью putStrLn. Как раз для этого и нужна функция (>>=). Давайте посмотрим на типы:

getLine  :: IO String
putStrLn :: String -> IO ()
(>>=)    :: IO a -> (a -> IO b) -> IO b

Получается, функцию getLine можно связать с putStrLn при помощи функции (>>=), при этом a будет типом String, а b будет ():

ghci> :t getLine >>= putStrLn
getLine >>= putStrLn :: IO ()
ghci> getLine >>= putStrLn
Hello
Hello
ghci> getLine >>= (\s -> putStrLn $ "Hello, " ++ s)
Alex
Hello, Alex

Очень, очень хорошо! А что, если у нас есть чистая функция, возвращающая строку, и мы хотим вывести ее при помощи putStrLn? Мы не можем просто поместить функцию в цепочку, потому что после применения к своим аргументам она будет иметь тип String, а нам нужен IO String. Вот для этого и нужна функция return, которая «оборачивает» произвольный тип в монаду:

ghci> let f x y = x ++ ", " ++ y ++ "!"
ghci> f "Hello" "World"
"Hello, World!"
ghci> return (f "Hello" "World") >>= putStrLn
Hello, World!

Наконец, функция fail нужна для обработки исключительных ситуаций. В контексте монады IO она бросает исключение IOError. Хотя, в зависимости от конкретной монады, она может делать и что-то более осмысленное.

do-нотация

Все это замечательно, но при работе с монадой IO мы же просто пишем:

main :: IO ()
main = do
  putStrLn "What's your name?"
  name <- getLine
  putStrLn $ "Hello, " ++ name ++ "!"

Никаких стрелочек! Как же так? В действительности, приведенный выше код полностью аналогичен следующему:

main :: IO ()
main =
  putStrLn "What's your name?" >>
  getLine >>=
  \name -> putStrLn $ "Hello, " ++ name ++ "!"

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

Кстати, при работе в ghci мы на самом деле находимся внутри монады и используем do-нотацию.

Монадные законы

Когда вы работаете с монадами, предполагается, что выполняются следующие правила:

return a >>= k           =  k a
m >>= return             =  m
m >>= (\x -> k x >>= h)  =  (m >>= k) >>= h

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

Первый закон:

ghci> let k = (\x -> return $ x * x + 1) :: Int -> IO Int
ghci> let a = 2 :: Int
ghci> return a >>= k
5
ghci> k a
5

Второй закон:

ghci> let m = return 2 :: IO Int
ghci> m >>= return
2
ghci> m
2

Третий:

ghci> let h = k
ghci> m >>= (\x -> k x >>= h)
26
ghci> (m >>= k) >>= h
26

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

Монада Maybe

В заметке Пишем простой RESTful сервис с использованием Scotty мы видели такую вот странную функцию:

extractNamePhone :: M.Map String String -> Maybe (String, String)
extractNamePhone m =
            M.lookup "name"  m >>=
  \name  -> M.lookup "phone" m >>=
  \phone -> Just (name, phone)

Давайте разберемся, как это работает. Экземпляр класса Monad для типа Maybe определен следующим образом:

instance Monad Maybe where
  (Just x) >>= k      = k x
  Nothing  >>= _      = Nothing

  (Just _) >>  k      = k
  Nothing  >>  _      = Nothing

  return              = Just
  fail _              = Nothing

Обратите внимание на функцию (>>=). Благодаря ей мы можем определить extractNamePhone всего лишь в три строчки кода вместо того, чтобы писать:

extractNamePhone m =
  case M.lookup "name" m of
    Nothing -> Nothing
    Just name ->
      case M.lookup "phone" m of
        Nothing -> Nothing
        Just phone -> Just (name, phone)

Если бы Maybe не был монадой, а нам нужно было бы извлечь из Map’а десяток значений, пришлось бы написать десяток вложенных case of. Кроме того, что монады в данном случае позволяют писать меньше кипятильно-тарелочного кода, также мы фактически получаем что-то вроде механизма исключений для чистых функций.

Благодаря do-нотации мы можем записать это выражение еще проще. А при помощи аппликативных функторов можно записать его вообще в одну строку. Однако об аппликативных функторах речь пойдет как-нибудь в другой раз.

Монада Either

В модуле Control.Monad.Error объявлен экземпляр класса Monad для типа Either:

ghci> :m + Control.Monad.Error
ghci> let f x = Right x
ghci> Right 1 >>= f :: Either String Int
Right 1
ghci> Left "Error 123" >> Right 1 >>= f
Left "Error 123"
ghci> Left 1.0 >> Right 1 >>= f
Left 1.0

Здесь все работает аналогично Maybe, только вместо того, чтобы в случае неудачи просто возвращать Nothing, мы можем как-то уточнить причину ошибки.

Монада State

Монада State бывает полезна, когда имеется некоторое состояние, которое мы постоянно изменяем. Например, если у нас есть Map, в который мы хотим поместить десять значений, возвращаемых разными функциями, нам придется ввести множество переменных m0, m1, m2, … m9. Благодаря монаде State мы можем создать изменяемое состояние, не потеряв при этом чистоты функций, и избежать тем самым описанной проблемы.

Работает это как-то так:

ghci> :m + Control.Monad.State
ghci> let f = (\x y -> modify (+1) >>= \_ -> get >>= \t -> return $ x ++ y ++ show t) :: String -> String -> State Int String
ghci> evalState (f "aaa" "bbb") 0
"aaabbb1"
ghci> execState (f "aaa" "bbb") 0
1
ghci> runState (f "aaa" "bbb") 0
("aaabbb1",1)

Здесь Int — это наше состояние, а String — как бы значение, возвращаемое функцией. Получить текущее состояние можно с помощью функции get, сохранить — при помощи put, а изменить — при помощи modify. Для выполнения функции и получения возвращаемого ею значения используйте evalState. Если же вы хотите получить конечное состояние, воспользуйтесь execState. Если вам нужно как возвращаемое значение, так и конечное состояние, скажите runState.

В do-нотации монада State, конечно, выглядит намного красивее.

Монада Reader

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

ghci> :m + Control.Monad.Reader
ghci> let f = (\x y -> local (+1) (asks (+1) >>= \t1 -> ask >>= \t2 -> return $ x ++ y ++ show t1 ++ show t2) ) :: String -> String -> Reader Int String
ghci> runReader (f "aaa" "bbb") 0
"aaabbb21"

С помощью функции local мы можем создать скоп с измененным окружением. Это намного удобнее, чем читать окружение и создавать на его основе новое. Функция ask возвращает текущее окружение. Функция asks применяет заданную функцию к текущему окружению и возвращает результат. Ее удобно использовать в сочетании с селекторами.

По понятным причинам функций execReader и evalReader не предусмотрено.

Монада Writer

Если Reader — это State, из которого можно только читать, то Writer — это State, в который можно только писать. Монада Writer часто применяется для логирования и трассировки без потери чистоты функций.

ghci> let f = (\x y -> tell ["111"] >>= \_ -> tell ["222"] >>= \_ -> return $ x ++ y ) :: String -> String -> Writer [String] String
ghci> runWriter (f "aaa" "bbb")
("aaabbb",["111","222"])
ghci> execWriter (f "aaa" "bbb")
["111","222"]
ghci> runWriter (censor (map (++ "!")) $ f "aaa" "bbb")
("aaabbb",["111!","222!"])
ghci> runWriter (listen $ f "aaa" "bbb")
(("aaabbb",["111","222"]),["111","222"])
ghci> runWriter (listens (filter (/= "111")) $ f "aaa" "bbb")
(("aaabbb",["222"]),["111","222"])

Функция tell производит запись в лог. Интересно, что лог при этом должен быть моноидом. В рамках данного поста будет достаточно сказать, что списки являются моноидами. Функция censor применяется для модификации записей в логе. Функция listen превращает Writer, который возвращает a и пишет w, во Writer, который возвращает (a, w) и пишет w. Функция listens позволяет делать с логом что угодно. Мы можем отфильтровать записи в нем, добавить новые, привести их к другому типу или сделать все это одновременно.

Монада [] (список)

Список тоже являются монадой. Вот классический пример:

-- почему при игре в кости выгодно ставить на семь

combinations :: Int -> [(Int, Int)]
combinations n = do
  a <- [1..6]
  b <- [1..6]
  if a + b == n then return (a,b) else []
 
solve :: [(Int, Int)]
solve = do
  n <- [2..12]
  return (n, length $ combinations n)

main = do
  putStrLn $ show solve

Вызываем функцию main:

ghci> main
[(2,1),(3,2),(4,3),(5,4),(6,5),(7,6),(8,5),(9,4),(10,3),(11,2),(12,1)]

Можно сказать, что это другой способ записи генераторов списков.

Некоторые функции для работы с монадами

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

(=<<)       :: Monad m => (a -> m b) -> m a -> m b
(>=>)       :: Monad m => (a -> m b) -> (b -> m c) -> a -> m c
(<=<)       :: Monad m => (b -> m c) -> (a -> m b) -> a -> m c
mapM        :: Monad m => (a -> m b) -> [a] -> m [b]
mapM_       :: Monad m => (a -> m b) -> [a] -> m ()
forM        :: Monad m => [a] -> (a -> m b) -> m [b]
forM_       :: Monad m => [a] -> (a -> m b) -> m ()
foldM       :: Monad m => (a -> b -> m a) -> a -> [b] -> m a
foldM_      :: Monad m => (a -> b -> m a) -> a -> [b] -> m ()
filterM     :: Monad m => (a -> m Bool) -> [a] -> m [a]
zipWithM    :: Monad m => (a -> b -> m c) -> [a] -> [b] -> m [c]
zipWithM_   :: Monad m => (a -> b -> m c) -> [a] -> [b] -> m ()
liftM       :: Monad m => (a1 -> r) -> m a1 -> m r
liftM2      :: Monad m => (a1 -> a2 -> r) -> m a1 -> m a2 -> m r
replicateM  :: Monad m => Int -> m a -> m [a]
replicateM_ :: Monad m => Int -> m a -> m ()
sequence    :: Monad m => [m a] -> m [a]
sequence_   :: Monad m => [m a] -> m ()
when        :: Monad m => Bool -> m () -> m ()
unless      :: Monad m => Bool -> m () -> m ()
forever     :: Monad m => m a -> m b
join        :: Monad m => m (m a) -> m a
ap          :: Monad m => m (a -> b) -> m a -> m b

Поэкспериментируйте с ними на досуге в ghci с конкретными монадами, например, IO, Maybe и [], чтобы разобраться, как эти функции работают. Если хотите, можете считать это своим домашним заданием.

Заключение

Помимо перечисленных в данной заметке монад есть и другие. Например, уже знакомая нам монада STM, а также Indentity, монада цитирования Q, Error, ST, Cont, Eval, Par, ActionM и многие другие. В языке Scala много, так сказать, завуалированных монад, среди которых особый интерес представляет Future. Еще в Haskell есть моноиды, MonadPlus, функторы, аппликативные функторы, monad trasformer’ы и другие интересные вещи. Но эти вопросы выходят за рамки поста.

В качестве источников дополнительной информации я бы советовал:

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

Дополнение: Вот отличный пример для медитации:

ghci> liftM3 (,,) (+1) (> 0) show 23
(24,True,"23")

Попробуйте посмотреть на типы в REPL и понять, как это работает.

Дополнение: Пришло время осмелиться использовать функторы, аппликативные функторы и моноиды!

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.