Монады

Пусть у нас есть задача: функция принимает целое число и делит его на 2. Если результат нечётный, функция не возвращает значения. Если результат чётный, она делит его на 2 ещё раз, после чего умножает на 10, если новый результат чётный, и на 5, если нечётный. Сначала напишем такую функцию на TypeScript.

function processNumber(value: number): number | undefined {
  const halved = Math.floor(value / 2);

  if (halved % 2 !== 0) {
    return undefined;
  }

  const halvedAgain = halved / 2;

  return halvedAgain % 2 === 0
    ? halvedAgain * 10
    : halvedAgain * 5;
}

Теперь перепишем этот код на Haskell в лоб. Сразу скажу, что на Haskell так не делают.

processNumber :: Int -> Maybe Int
processNumber x =
  let halved = x `div` 2
  in case even halved of
    False -> Nothing
    True  ->
      let halvedAgain = halved `div` 2
      in case even halvedAgain of
        True  -> Just (halvedAgain * 10)
        False -> Just (halvedAgain * 5)

В приведённом выше коде явно плохо смотрится вложенность. В TypeScript её нет, но, чтобы понять, что возвращает функция, приходится внимательно следить за return. При этом вложенность однозначно показывает направление вычислений. В императивном языке оно и так понятно: код выполняется сверху вниз. Давайте перепишем код, разделив вычисление на две функции:

halveEven :: Int -> Maybe Int
halveEven x =
  let halved = x `div` 2
  in case even halved of
    False -> Nothing
    True  -> Just halved

halveAndScale :: Int -> Maybe Int
halveAndScale x =
  let halved = x `div` 2
  in case even halved of
    True  -> Just (halved * 10)
    False -> Just (halved * 5)

processNumber :: Int -> Maybe Int
processNumber x =
  case halveEven x of
    Nothing     -> Nothing
    Just halved -> halveAndScale halved

Кажется, стало только хуже, но на самом деле это не так. В Haskell для этого уже есть готовый инструмент — класс Monad с его оператором связывания (bind):

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

processNumber :: Int -> Maybe Int
processNumber x = pure x >>= halveEven >>= halveAndScale

Красиво? То-то же. Существует ещё так называемая do-нотация. Вот тот же пример с её использованием:

processNumber :: Int -> Maybe Int
processNumber x = do
  halved <- halveEven x
  result <- halveAndScale halved
  pure result

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

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

pure a >>= f  ==  f a                           -- левая единица
m >>= pure    ==  m                             -- правая единица
(m >>= f) >>= g  ==  m >>= (\x -> f x >>= g)    -- ассоциативность

С оператором связывания разобрались. Теперь посмотрим, какие монады встречаются в реальном коде. Почти у каждого привычного «императивного» приёма — исключений, общего конфига, лога, изменяемого состояния или ввода-вывода — есть чистый монадный двойник.

МонадаКонтекст / эффектИмперативный аналог
Maybe\mathtt{Maybe}значения может не бытьnull + ранний выход
Either  e\mathtt{Either\;e}ошибка с причинойисключения
[]\mathtt{[\,]}недетерминизм: ноль или несколько результатоввложенные циклы
Reader  r\mathtt{Reader\;r}общее окружение только на чтениеконфиг
Writer  w\mathtt{Writer\;w}накопление логалогирование
State  s\mathtt{State\;s}изменяемое состояниепеременные
IO\mathtt{IO}внешний мирсистемные вызовы: файлы, сеть, консоль

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

newtype Reader r a = Reader (r -> a)        -- окружение -> значение
newtype Writer w a = Writer (a, w)          -- значение + лог
newtype State  s a = State  (s -> (a, s))   -- старое состояние -> (значение, новое)

Оператор связывания монады State сам протаскивает состояние сквозь цепочку — вручную писать s1,s2,s3s_1, s_2, s_3\ldots больше не нужно. Примитивы: get::State  s  s\mathtt{get} \mathbin{\mathtt{::}} \mathtt{State}\;s\;s, put::s\mathtt{put} \mathbin{\mathtt{::}} s State  s  ()\to \mathtt{State}\;s\;().

Любая исполняемая программа на Haskell работает внутри монады IO: её точка входа имеет тип main::IO  ()\mathtt{main} \mathbin{\mathtt{::}} \mathtt{IO}\;(), а всё взаимодействие с внешним миром выполняется как IO-действия. Значение IO  a\mathtt{IO}\;a описывает действие, которое при выполнении вернёт aa. Из IO нельзя просто извлечь чистое значение: функции IO  a\mathtt{IO}\;a a\to a не существует.

Итак, у всех этих монад один интерфейс — поэтому один и тот же do-код работает с разными эффектами. При этом Haskell остаётся чистым: эффекты не выполняются незаметно, а явно отражаются в типах и связываются в контролируемую последовательность вычислений.