Сообщений 29    Оценка 1675        Оценить  
Система Orphus

Монады

Автор: Евгений Кирпичев aka jkff
Источник: RSDN Magazine #3-2008
Опубликовано: 28.12.2008
Исправлено: 10.12.2016
Версия текста: 1.0
Введение
Цель и целевая аудитория
Три знакомых примера
Последовательные вычисления (монада IO)
Вычисления с обработкой отсутствующих значений (монада Maybe)
Вычисления с несколькими результатами (монада List)
Что общего?
Законы монад
Определение монады
Стандартные монады
Монада Identity (тождественная монада)
Монада Maybe (монада вычислений с обработкой отсутствующих значений)
Монада List (вычисления, которые могут возвращать 0 или более результатов)
Монада State (монада вычислений с изменяемым состоянием)
IO (монада вычислений с побочными эффектами)
Прочие
do-синтаксис
Проектирование монад
Синтаксический разбор
Статистические распределения
Обобщенные монадные вычисления и монадные комбинаторы
Простейшие монадные комбинаторы
Зачем нужна эта общность?
Пример: SAX
Заключение
Благодарности
Ссылки
A. Основы Haskell
B. Другие учебные статьи о монадах
C. Научные статьи о монадах
D. Прочее

Введение

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

Цель и целевая аудитория

Предполагается, что читатель знаком с основами языка «Хаскелл» и с методологией функционального программирования в целом. Получить необходимые знания можно из статей, перечисленных в конце данного текста. Цель данной статьи – рассказать, что такое монады, и дать понимание этой концепции на интуитивном уровне с помощью нескольких примеров, стандартных и не очень. Этот текст не является практическим руководством по использованию тех или иных стандартных монад.

Три знакомых примера

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

Последовательные вычисления (монада IO)

Рассмотрим операцию ";", означающую "А затем":

(print "Hello") ; (print "Goodbye")

Эта строчка означает: «Написать на экране "Hello", а затем - написать "Goodbye"».

Исполнение этой программы выглядит так:

Запишем эту последовательность действий как систему уравнений:

world(t=0) = <empty screen>
world(t=1) = world(t=0) + (print "Hello")
world(t=2) = world(t=1) + (print "Goodbye")

Этот способ программирования, выражение решения задачи через последовательность действий, самый распространенный, и является основным в традиционных языках: Си, Java и т.п.

ПРИМЕЧАНИЕ

Легко понять, что в мире императивных вычислений, ввода-вывода и т.п., все такие системы уравнений имеют вид "цепочки": мир(n+1) = мир(n) + действие. Нельзя достать из-за пазухи старое состояние мира и произвести действие с ним, так как разные состояния мира не могут существовать одновременно – то есть невозможно, например, такое уравнение, как мир(10)=мир(3)+(print “Hello”).

Таким образом, последовательность действий A1; A2; A3... можно представить себе как A1 `затем` (A2 `затем` (A3 `затем` ...)); для описания последовательности действий достаточно бинарной операции `затем` (она же – точка с запятой, “;” – но ее мы не привыкли воспринимать как бинарную операцию). Отметим, что эта операция ассоциативна (то есть, ((A1 `затем` A2) `затем` A3) == (A1 `затем` (A2 `затем` A3))), поэтому скобки можно опустить – как и делается во всех языках. Однако более логично воспринимать операцию `затем` как обладающую правой ассоциативностью (сначала первое действие, потом все остальное) – в противоположность левой ассоциативности (сначала все действия, кроме последнего, а затем последнее).

Встает вопрос, что считать значением (A `затем` B): значение А, значение B или что-то еще? Традиционный и логичный способ состоит в том, чтобы взять в качестве результата значение B. В самом деле: если понадобилось сначала вычислить А, а затем В – значит, возможность корректного вычисления В зависит от вычисления A. Например, в последовательности (послать запрос к БД) `затем` (дождаться ответа от БД) корректность операции «дождаться ответа» зависит от того, был ли отослан запрос.

А раз нас волнует корректность выполнения операции B, значит, нас волнует именно значение В, а не значение A. Во всяком случае, это так в тех случаях, когда мы выполняем (А `затем` B) для того, чтобы получить какое-то значение, а не только ради побочных эффектов.

C чисто функциональной точки зрения (если считать, что единственное, что важно в вычислении – это его результат) можно было бы сказать, что операция `затем` не нужна – и тогда в вышеприведенном примере можно было бы просто «дождаться ответа от БД». Однако очевидно, что это неправильно: необходимо сначала полностью выполнить операцию «послать запрос», и лишь потом приступить к выполнению «дождаться ответа».

Мораль: Вычисления с побочными эффектами (ввод-вывод, изменение переменных) обязательно должны производиться в определенном порядке. В мире ленивых чистых функциональных вычислений такая зависимость может быть выражена связующей операцией «А затем», означающей «Сначала надо выполнить первое действие, и только потом – все остальное». В этом состоит их отличие от «чистых» функциональных вычислений без эффектов, которые можно производить в любом порядке, поскольку важно лишь значение, которое они возвращают.

Вычисления с обработкой отсутствующих значений (монада Maybe)

Рассмотрим синтетический, но типичный и, должно быть, знакомый многим по стилю пример на псевдокоде (похожем на Java): программа открывает файл, читает из него строку и ищет в базе данных значение, соответствующее этой строке. Если на каком-то этапе происходит ошибка, возвращается null.

File f = open("keys.txt");
if (f == null)
  return null;

String key = readLine(f);
if (keys == null)
  return null;

String value = ourDatabase.get(key);
if (value == null)
  return null;

return "The value is: " + value;

Теперь используем вместо ";" ("а затем") операцию ";?" ("а затем, если получилось")

File f = open("keys.txt") ;?
String key = readLine(f) ;?
String value = ourDatabase.get(key) ;?
return "The value is: " + value;

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

Мораль: В мире, где что-то иногда не получается, вычисления связываются операцией "А затем, если получилось", означающей "Сначала первое, и если первое успешно – то тогда все остальное, а если произошла ошибка E – то возвратить ошибку E". В примере рассмотрен частный случай, когда ошибка бывает только одна – «получился null».

Вычисления с несколькими результатами (монада List)

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

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

        int sum = 0;
foreach(Shop s : getShops()) 
{
  foreach(Department d : getDepartments(s)) 
    foreach(Order ord : getOrders(d)) 
      sum += ord.getCost();
}
return sum;

Теперь рассмотрим случай, когда количество "уровней" перебора неизвестно – например, получение списка файлов в папке и подпапках.

List<File> listFilesRec(File file) 
{
  List<File> res = new ArrayList<File>();

  res.add(file);

  foreach(File sub : getContents(file))
    foreach(File rec : listFilesRec(sub))
      res.add(rec);

  return res;
}

А теперь перепишем эти примеры с использованием операции «;*», вызывающей следующую операцию для всех результатов предыдущей.

int sum = 0;
{
    Shop s <- getShops() ;*
    Department d <- getDepartments(s) ;*
    Order ord <- getOrders(d) ;*
    sum += ord.getCost();
}
return sum;

List<File> listFilesRec(File file) 
{
    List<File> res = new ArrayList<String>();
    res.add(file);
    {
        File contents <- getContents(file) ;*
        File rec <- listFilesRec(contents) ;*
        res.add(rec);
    }
    return res;
}

Мораль: В мире переборов и вычислений с несколькими результатами вычисления связываются операцией "Выполнить код для каждого результата", например: “Для каждого результата ord от вызова getOrders(d) выполнить sum += ord.getCost()”.

Что общего?

Пытливый читатель уже заметил, что во всех трех случаях имело место переопределение стратегий связывания двух вычислений – первого и оставшегося, переопределение оператора «;». Можно сказать, что в этом и состоит суть концепции монад. Теперь, чтобы пользоваться этим как средством абстракции, понадобится формализовать эту концепцию.

Формализуем понятие "связывания" вычислений, то есть обобщим операции ;, ;?, ;* до одной операции (>>=).

  1. Это полиморфная операция – то есть логика ее работы никак не зависит от того, значения каких типов (и, тем более, какие именно значения) она связывает. Так, операция «;*» работает абсолютно одинаково, когда связывает список магазинов с вычислением, зависящим от магазина, и когда связывает список файлов с вычислением, зависящим от файла.
  2. Тип операции (>>=), очевидно, зависит от типов первого и второго вычисления; он будет параметризован, как минимум, двумя типами: – a (тип результата первого вычисления), b (тип результата второго вычисления). Еще раз акцентирую внимание на полиморфизме: тип будет иметь вид forall a b, (тип, параметризованный a и b).
  3. Результатом этой операции является результат второго вычисления. forall a b , ... -> b
  4. Второе вычисление зависит от первого, иначе бы не имело смысла создавать какую-то там монаду, а достаточно было бы просто вычислить второе.

Получается что-то вроде a -> (a -> b) -> b; тут a – первое вычисление, а (a -> b) – второе, зависящее от "параметра", вычисляемого первым. Это близко к истине (впрочем, подвох чувствуется: функция с таким типом всего одна – функция аппликации ($), и она явно не тянет на монаду), однако есть очень важное замечание: мы предполагаем, что просто "значение" и "значение, вычисленное в монаде" - это одно и то же, однако это совсем не одно и то же! Например, вычислить значение "Hello" как "He"++"llo", или прочитать с клавиатуры "Hello" – это очень разные вещи, вторая с побочными эффектами, а первая – без! На самом деле, каждая монада помещает значения в "обертку", определенным образом модифицируя их тип. Например, readLine имеет тип не String, а IO String. А гипотетическая функция lookupUserByName имеет тип не String -> User, а String -> Maybe User.

Таким образом, тип монадного вычисления в монаде m – это не "a", а "m a". Это можно интерпретировать как "Значение, полученное определенным способом", например, "значение, полученное с side-эффектами" (монада IO) или "значение, которое не факт что получилось" (монада Maybe), или "несколько вариантов значения" (монада List). Монада прибавляет ко всем значениям некоторое прилагательное.

Теперь получается что-то типа m a -> (m a -> m b) -> m b. Это еще ближе, однако второму вычислению, на самом деле, не нужно знать про "обернутость" своих аргументов. Для функции print неважно, как была получена строчка, которую ее просят вывести, а для getContents неважно, что его на самом деле вызывают для нескольких файлов, а полученные списки складывают. Об этих связях должны заботиться не эти функции, а реализация операции связывания «>>=» в данной монаде – в точности в этом и состоит назначение операции «>>=»

Итак, получается тип (>>=) :: m a -> (a -> m b) -> m b. Это – окончательный и правильный вариант. Его можно прочитать так: (>>=) в монаде m связывает монадное вычисление параметра (m a) с монадным вычислением, зависящим от этого параметра (a -> m b), давая монадный результат (m b).

Итак, мы научились связывать два монадных вычисления – однако пока нет способа в общем случае создать монадное вычисление «из ничего» для произвольной монады. В принципе, нет необходимости в наличии такого способа, т.к. мы всякий раз будем использовать какую-нибудь конкретную монаду – например, монаду списков – а для конкретных случаев способ создать монадное значение обычно уже существует: например, нет ничего сложного в том, чтобы создать список значений (значение типа List a).

Однако поскольку такая операция в той или иной форме должна существовать в каждой монаде, то имеет смысл добавить ее в само понятие монады. Такая операция называется return и имеет тип a -> m a. Она делает из значения - то, что получилось бы, если бы это значение было вычислено в монаде.

Например, в монаде IO - return "Hello" :: IO String, и это вычисление представляет собой «Hello, вычисленное якобы с side-эффектами». В Maybe - return "Hello" = Just "Hello" – т.е., «Успешно вычисленное Hello», а в List - return "Hello" = ["Hello"], т.е. «Единственный результат вычисления – строчка Hello». Рассмотрим причину такого выбора реализаций return.

Напомню: (>>=) в монаде m связывает монадное вычисление параметра с монадным вычислением, зависящим от этого параметра.Если выполнить «монадное вычисление параметра» с помощью функции return, то будет логичным потребовать, чтобы результат был таким же, как и если бы параметр был просто передан второму вычислению:

(return x >>= f) = f x 

Это – первый из трех законов монад.

Легко проверить, что для вышеприведенных монад такие реализации (return "Hello" = Just "Hello" и т.п.) удовлетворяют этому закону.

Есть еще два менее очевидных закона; их назначение будет ясно из их определений. Итак, все три закона монад:

Законы монад

result = (z = ( 
                y = x ;;           -- Сначала вычислить y как x, а z как f(y)
                return (f y) 
              ) ;;
          return (g z))            -- А потом вычислить g(z)

должно быть равносильно:

result = (y = x ;;                  -- Сначала вычислить y как x
          ( z = f y ;;              -- А потом вычислить z как f(y)
            return (g z) ))         -- и вычислить g(z)

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

Теперь можно окончательно сказать, что такое монада:

Определение монады

Монадой называется тройка (m, return, >>=), где:

        -- m – тип с одним аргументом
return :: forall a . a -> m a
(>>=) :: forall a, b . m a -> (a -> m b) -> m b

Эти требования формализуемы на уровне системы типов, и в Prelude определен класс:

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

В монаде обязаны выполняться законы (они не могут быть проверены компилятором, однако их нарушение, скорее всего, приведет к экзотическим ошибкам):

(return x) >>= f = f x
(x >>= f) >>= g = x >>= (\y -> f y >>= g)
(x >>= return) = x

На самом деле, в Prelude класс Monad обладает еще одним членом – fail :: String -> m a, однако его мы рассматривать не будем, ибо к самой концепции монад он отношения не имеет, и в грядущих версиях стандарта Хаскелла планируется перенести эту функцию в отдельный класс.

Стандартные монады

Монада Identity (тождественная монада)

Это - простейшая монада, которой соответствует прилагательное «тождественный»: Identity String – «тождественный (обычный) String». То есть, эта монада, по сути, не меняет ни тип значений, ни стратегию связывания вычислений.

data Identity a = Identity a
return a = Identity a
(Identity a) >>= f = f a

Такая монада находит применение с монадными трансформерами, которые в данной статье не рассматриваются. Будем считать, что это чисто иллюстративный простейший пример.

Монада Maybe (монада вычислений с обработкой отсутствующих значений)

Здесь есть два класса значений – «обычные» и специальное значение «значение отсутствует»:

data Maybe a = Nothing 
             | Just a

Реализация тривиальна: Связывание «просто» параметра с параметризованным вычислением – это передача параметра вычислению, связывание отсутствующего параметра с параметризованным вычислением – отсутствующий результат.

return a = Just a
Nothing >>= f = Nothing 
(Just a) >>= f = f a

Монада List (вычисления, которые могут возвращать 0 или более результатов)

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

return a = [a]
params >>= f = concat [f x | x <- params]

Напомню типы участвующих значений:

Таким образом, params >>= f :: [b], как и следовало ожидать – это список возможных результатов применения функции к каждому из вариантов входного аргумента.

Например,

["c:/music", "c:/work"] >>= getDirectoryContents = 
  [ 
    "c:/music/Bach", 
    "c:/music/Beethoven", 
    "c:/music/Rammstein", 
    "c:/work/projects", 
    "c:/work/documents"
  ]

Монада State (монада вычислений с изменяемым состоянием)

Это монада посложнее, и я ее не упоминал выше. Она соответствует вычислению с состоянием, но с не настолько глобальным, как в монаде IO (где вычисления потенциально могут изменять состояние всего внешнего мира, и его нельзя получить в явной форме, изменить в явной форме или запомнить). В ней каждое вычисление, во-первых, возвращает какое-то значение, а во-вторых, изменяет значение переменнойсостояния. То есть, вычисление типа "a" с состоянием типа "s" имеет тип s -> (a, s). Так и есть:

        newtype State s a = State { runState :: s -> (a, s) }
      

(лучше было бы назвать StatefulComputation, но это длинновато)

Рассмотрим, например, программу, реализующую метод Монте-Карло. Для этого ей понадобится датчик случайных чисел, обладающий состоянием. Тогда для этой программы «a» будет соответствовать типу результата – например, числу успешных экспериментов – а «s» будет представлять внутреннее состояние датчика случайных чисел. Эта программа будет использовать функцию «сгенерировать случайное число», которая будет возвращать новое случайное число и изменять состояние датчика:

rand :: State RndGen Int
rand = ... -- Пока опустим реализацию rand. Она будет показана чуть ниже.

monteCarlo :: (Int -> Bool) -> Int -> State RndGen Int
monteCarlo experiment 0 = return 0
monteCarlo experiment n = rand >>= \r -> 
                          if (experiment r) then 
                              monteCarlo experiment (n-1) >>= \s -> 
                              return (s+1)
                          else
                              monteCarlo experiment (n-1)

Заметим, что в коде monteCarlo нет никаких намеков на присваивания и изменение состояния – код написан в чисто функциональном стиле, и всю «подноготную» скрывает в себе монада State и функция rand.

Но как же реализовать rand? Конечно, можно реализовать его «влоб» - например, так:

rand :: State RndGen Int
rand = State $ \(RndGen x) -> (x, RndGen ((x*1367823 + 918237) `mod` 32768))

Однако более естественным было бы в монаде State предоставить готовые функции для чтения и изменения состояния; они есть и называются соответственно get и put:

rand = get >>= \(RndGen x) ->
       put (RndGen ((x*1367823 + 918237) `mod` 32768)) >>= 
       return x

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

Функции get и put не являются какими-то «хаками» - они реализованы довольно-таки тривиально, но все же стоит приглядеться к ним.

get :: State s s
get = State $ \s -> (s, s)

put :: s -> State s ()
put s' = State $ \s -> ((), s’)

Получается, что get – это «вычисление с изменяемым состоянием, возвращающее само состояние», а put – это «вычисление с изменяемым состоянием, не возвращающее ничего, зато изменяющее состояние». Теперь реализация самой монады State:

instance Monad (State s) where
    return a = State dontChangeStateAndReturnA
        where dontChangeStateAndReturnA s = (a, s)

    -- r1 :: State s a = State (s -> (a, s)) - это вычисление с состоянием
    -- p :: a -> State s b = a -> State (s -> (b, s)) - это вычисление 
    -- с состоянием, зависящее от параметра, вычисляемого r1.
    (State r1) >>= p = State passState
        where passState s = (res2, finalState)
                      -- Запускаем первое вычисление, получаем параметр
                where (res1, intermediateState) = r1 s          
                      -- Вычисляем по параметру второе вычисление
                      (State r2) = p res1                       
                      -- Запускаем второе вычисление
                      (res2, finalState) = r2 intermediateState 

«return a» возвращает вычисление с состоянием, всегда возвращающее указанное значение «a», и не изменяющее состояние.

«>>=» связывает вычисление с состоянием «r1» с параметризованным вычислением с состоянием «p». На выходе получается вычисление, которое работает так:

  1. С начальным состоянием запускается «r1», которое вычисляет новое состояние «intermediateState» и возвращает некоторое значение «res1».
  2. «res1» передается в качестве параметра для «p», и получается вычисление с состоянием «r2».
  3. «r2» запускается с начальным состоянием «intermediateState», вычисляет конечное состояние «finalState» и значение «res2», которое берется в качестве результата всего вычисления.

Таким образом, получается вычисление с состоянием, состоящее из двух связанных по состоянию частей – «r1» и «r2», однако вторая часть – «r2» – не известна заранее, а зависит от результата «r1».

Чтобы окончательно уяснить принцип действия монады State, вернемся к коду «monteCarlo»:

monteCarlo :: (Int -> Bool) -> Int -> State RndGen Int
monteCarlo experiment 0 = return 0
monteCarlo experiment n = rand >>= \r -> 
                          if (experiment r) then 
                              monteCarlo experiment (n-1) >>= \s -> 
                              return (s+1)
                          else
                              monteCarlo experiment (n-1)

«monteCarlo» вычисляет число успешных экспериментов, попутно модифицируя состояние датчика случайных чисел.

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

При ненулевом числе экспериментов необходимо сначала вычислить случайное число «r» (при этом будет модифицировано состояние датчика):

monteCarlo experiment n = rand >>= \r -> 

а затемпередать его второму вычислению:

                          if (experiment r) then 
                              monteCarlo experiment (n-1) >>= \s -> 
                              return (s+1)
                          else
                              monteCarlo experiment (n-1)

Если сравнить это с реализацией «>>=», то получится, что «r1» соответствует «rand», «p» соответствует «(\r -> …)», а «r2» соответствует «if (experiment r) then … else …» - т.е. второе вычисление зависит от результата первого.

IO (монада вычислений с побочными эффектами)

ПРЕДУПРЕЖДЕНИЕ

В этой секции монада IO описана интуитивно понятным, но не совсем правильным образом. Сложность правильного описания превосходит предполагаемую сложность этой статьи. Однако правильное описание гораздо интереснее; с ним можно ознакомиться в [C.5]

Прилагательное для монады IO – это «вычисленный с побочными эффектами», а стратегия связывания – «сначала произвести побочные эффекты первого вычисления, затем – побочные эффекты второго» (вспомним пример с отсылкой запроса к базе и ожиданием ответа от нее). В то же время обычные (чистые) вычисления производятся как обычно, лениво, поскольку их порядок не имеет никакого значения.

Опишем монаду IO наивным образом:

data IO a = IO a

return a = IO a
(IO a) >>= f = f a -- однако побочные эффекты 'a' волшебным образом -- производятся ДО применения 'f' – см. далее

В начале статьи было сказано, что не существует способа восстановить прежнее состояние мира, и что суть последовательных вычислений с побочными эффектами – построение строго линейной цепочки состояний мира. Единственный способ создать непротиворечивую модель таких вычислений – запретить сохранение состояния мира, т.е. запретить нарушение линейности. Для этого достаточно сделать конструктор IO закрытым и, тем самым, запретить сопоставление с образцом (pattern matching) по данным типа IO a: let (IO s) = readLine in ...

Запретив pattern matching, мы получаем удивительный эффект: из монады IO нельзя выбраться! Не существует способа преобразовать IO a в просто a, и не существует способа скрыть тот факт, что некоторое вычисление обладает побочными эффектами. Если функция использует значения типа ... -> IO ..., то тип этой функции также окажется ... -> IO ... . Таким образом, функция, использующая внутри себя побочные эффекты, сама приобретает аннотацию «осторожно, побочные эффекты», и наоборот: если возвращаемый тип функции – не IO, то мы можем быть уверены, что эта функция – чистая, т.е. не обладает побочными эффектами. Этот факт проверяется компилятором на стадии проверки типов. Это – одна из причин малого числа багов в программах на Хаскелле: исчезает одна из самых частых причин багов – неявные взаимодействия из-за побочных эффектов.

На самом деле, существует функция System.IO.Unsafe.unsafePerformIO с типом IO a -> a, но следует дважды подумать, прежде чем ее использовать – вместе с этой функцией мы приобретаем заодно и все императивные проблемы, связанные с побочными эффектами, которые еще больше усугубляются ленивостью: становится чрезвычайно сложно сказать, когда именно произойдет тот или иной побочный эффект, и произойдет ли он вообще. Использование unsafePerformIO относительно безопасно только в ситуациях, когда побочный эффект заведомо не может ни на что повлиять, или когда он всегда одинаков, т.е. идемпотентен (например, чтение глобального файла конфигурации). Но даже в этих случаях лучше избегать unsafePerformIO.

Объяснить, как именно достигается упорядочение побочных эффектов, упомянутое в вышеприведенном фрагменте кода, довольно трудно, а с наивным подходом «data IO a = IO a» это и вовсе невозможно. Однако в рамках этого подхода, если принять (*) на веру и спрятать конструктор IO, иллюстрируются самые важные свойства монады IO: то, что чистые вычисления остаются чистыми и ленивыми; то, что побочные эффекты выстраиваются в цепочку; и то, что невозможно скрыть наличие побочных эффектов вычисления.

Существует несколько способов реализовать монаду IO «по-настоящему», например:

Прочие

В стандартной библиотеке Хаскелла определены еще несколько монад – Reader, Writer, Cont.

Рекомендуется ознакомиться с ними самостоятельно: Reader и Writer концептуально просты и довольно часто применяются; Cont – сложная монада, предназначенная для программирования в «стиле передачи продолжений» (continuation-passing style). Обсуждение ее выходит за рамки этой статьи.

do-синтаксис

Рассмотрим снова пример с многозначными функциями (монада List):

Shop s = getShops() ;*
Department d = getDepartments(s) ;*
Order ord = getOrders(d) ;*
sum += ord.getCost();

Перепишем его на Хаскелле:

let s = getShops
    d = getDepartments s
    ord = getOrders d
in
    sum (getCost ord)

Этот код, конечно же, неверен – он не проходит проверку типов. Ведь s имеет тип [Shop], поэтому getDepartments неприменим к s. Аналогично для d/getOrders и ord/getCost. Дело в том, что вышеприведенный код с оператором ;* нельзя воспринимать буквально: В записи

Shop s = getShops() ;*

имелось в виду, что s – это параметр, используемый оставшейся частью вычисления. Поэтому данная запись означает не «Пусть s равно getShops», а «Пусть параметр s вычисляется с помощью getShops». Результаты этого вычисления будут использованы оператором «;*», который имеет в монаде List тип [a] -> (a -> [b]) -> [b], а в частном случае, когда a = Shop[Shop] -> (Shop -> [b]) -> [b]. То есть, s имеет тип [Shop], а остаток вычисления зависит от Shop. С учетом этой идеи можно переписать первый фрагмент кода так:

getShops() >>= \s ->
getDepartments(s) >>= \d ->
getOrders(d) >>= \ord ->
sum += ord.getCost();

А на Хаскелле – вот так:

sum (getShops >>= \s ->
     getDepartments s >>= \d ->
     getOrders d >>= \ord ->
     return (getCost ord))

Очевидно, такой разновидности «присваивания» соответствует синтаксическая идиома value >>= \variable -> .... В Хаскелле есть на этот случай синтаксический сахар, называемый do-синтаксисом:

sum (do s <- getShops
    d <- getDepartments s
        ord <- getOrders d
        return (getCost ord))

Эта запись в точности транслируется компилятором в вышеуказанный код. Такой способ оформления делает код более похожим на императивный; еще больше это чувствуется при использовании монады IO:

main = do putStrLn "Input a number"
          a <- readNumber
      putStrLn "Input another number"
          b <- readNumber
          putStrLn $ "Their sum is: " ++ show (a + b)

Последовательное указание в do-синтаксисе двух строчек, первая из которых не является связыванием вида a <- ..., транслируется так, как будто написано не putStrLn “Input a number”,а _ <- putStrLn “Input a number” – т.е. действие выполняется, но возвращенное им значение никак не используется.

Вычисления, чье возвращаемое значение не важно, из рассмотренных нами монад есть лишь в монаде IO (вычисления с побочными эффектами) и монаде State (вычисления, которые изменяют состояние; мы рассматривали только одно такое примитивное вычисление, put, однако любая функция, использующая put, также, очевидно, можетизменять состояние и может быть важна именно этим). Несколько позже мы вернемся к рассмотрению таких действий и монад.

Вот во что транслируется вышеприведенный код:

main = putStrLn "Input a number" >>= \_ ->
       readNumber >>= \a ->
   putStrLn "Input another number" >>= \_ ->
       readNumber >>= \b ->
       putStrLn $ "Their sum is: " ++ show (a + b)

Проектирование монад

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

Синтаксический разбор

Спроектируем библиотеку для построения парсеров. Пусть парсер будет параметризован типом значения, которое он возвращает. Например, парсер, разбирающий число, будет возвращать число, а парсер для кода – абстрактное синтаксическое дерево (AST) этого кода. Тип парсера будет примерно таким: type Parser a = String -> Maybe a – Maybe, поскольку может оказаться, что входную строку нельзя разобрать. Например, парсер чисел вернет Nothing в случае строки «Hello».

С таким типом нетрудно написать несколько простых «примитивных» парсеров, например:

Можно также реализовать комбинатор «<|>» – т.н. «параллельная композиция» парсеров: он пробует применить первый парсер, и, если не получилось, применяет второй.

(<|>) :: Parser a -> Parser a -> Parser a
a <|> b = \s -> case (a s) of 
                    Just v  -> Just v
                    Nothing -> b s

Однако последовательную композицию парсеров (<*>) – например, парсер для двух чисел (“123 456” -> (123, 456)) – таким образом уже нельзя построить эффективно: в самом деле, поскольку о реализации этих двух парсеров комбинатору ничего не известно, то он будет вынужден перебирать все возможные разбиения входной строки на две, и для левой части вызывать первый парсер, а для правой – второй. Это ужасающе неэффективно, а когда парсеров становится несколько, то сложность становится экспоненциальной от длины строки и числа парсеров.

На самом деле, последовательную композицию хотелось бы реализовать так: пусть первый парсер «съест» от строки, сколько сможет, а то, что осталось – скормим второму парсеру. Тогда в тип парсера надо добавить тот факт, что он возвращает также и «несъеденную» порцию строки: type Parser a = String -> Maybe (a, String). Теперь последовательная композиция легко реализуется:

(<*>) :: Parser a -> Parser b -> Parser (a,b)
a <*> b = \s -> do
    (va, s’) <- a s
    (vb, s’’) <- b s’
    return (va,vb)

В этом фрагменте кода используется монада Maybe и do-синтаксис; для закрепления материала приведем еще два варианта с точно такой же семантикой:

Первый вариант – во что раскрывается компилятором вышеприведенный код:

a <*> b = \s ->
    (a s)  >>= \(va, s’)  ->
    (b s’) >>= \(vb, s’’) ->
    return (va,vb)

Второй вариант, вообще не использующий монадуMaybe – так раскрывается первый вариант, если раскрыть функции (>>=) и return:

a <*> b = \s -> 
    case (a s) of
        Nothing -> Nothing
        Just (va,s’) -> 
            case (b s’) of
                Nothing -> Nothing
                Just (vb, s’’) -> Just ((va,vb), s’’)
                

Итак, с типом type Parser a = String -> Maybe (a, String) легко реализовать последовательную композицию парсеров и (оставляется в качестве упражнения читателю) также легко реализовать и параллельную композицию.

Теперь рассмотрим еще пару важных комбинаторов: комбинатор «oneOrMore :: Parser a -> Parser [a]». Он пригодится, например, для разбора строк следующего вида:

Napoleon 1769|Emperor of France
Henry the 8 th 1491|King of England, had 6 wives

Распознающий (не возвращающий значения) парсер для таких строк можно реализовать как kingInfo = (oneOrMore anyChar) <*> space <*> (oneOrMore digit) <*> char ‘|’ <*> (oneOrMore anyChar).

Попробуем придумать реализацию oneOrMore :

Нам подходит только второй вариант. Самый простой (а возможно, и единственный) способ его реализовать – от типа Maybe перейти к типу списков. Этот принцип Philip Wadler сформулировал как «Replace failure by a list of successes» (замена неудачи списком успехов) в своей работе [C.1]. Теперь парсер будет иметь тип type Parser a = String -> [(a, String)]. Реализации операций при этом станут еще проще, а реализация <*> с использованием do-синтаксиса, как легко убедиться, не изменится вообще!

Попробуем теперь реализовать kingInfo как следует, возвращая тип King: data King = King {name::String, birth::Int, info::String}:

kingInfo :: String -> [(King, String)]
kingInfo = \s -> do
    (name, s1) <- oneOrMore anyChar s
    (_, s2) <- space s1
    (birthStr, s3) <- oneOrMore digit s2
    (_, s4) <- char ‘|’ s3
    (info, s5) <- oneOrMore anyChar s4
    return (King name (read birthStr) info, s5)

В каждой строке этого кода разбирается очередной элемент грамматики: имя (1 или более символов), пробел, год рождения (1 или более цифр), символ '|', дополнимельная информация (1 или более символов).

Притом весь код do-блока находится в монаде List, т.е. его можно интерпретировать следующим образом:

  1. Рассмотреть все способы распознать несколько символов в начале строки.
  2. Рассмотреть все способы распознать пробел в начале оставшейся строки (если она не начинается с пробела, то таких способов будет 0).
  3. Рассмотреть все способы распознать несколько цифр в начале оставшейся строки (опять же, если строка не начинается с цифры, то таких способов будет 0).
  4. Рассмотреть все способы распознать символ ‘|’ в начале оставшейся строки (то же замечание, что и выше).
  5. Рассмотреть все способы распознать несколько символов в начале оставшейся строки.
  6. Для каждого из полученных способов разбора исходной строки составить значение типа King.

Видно, что части этого кода связаны двумя способами:

Итак, монада List справляется с абстрагированием только одного аспекта связанности этапов разбора; похоже, стоит написать свою собственную монаду! Она будет похожа на монаду List, но будет вместе с полученными значениями хранить и остаток строки, из которой значения были прочитаны – похоже на смесь List и State:

newtype Parser a = Parser {runParser :: String -> [(a,String)]}
instance Monad Parser where
  return a  = Parser $ \s -> [(a,s)]
  pa >>= pb = Parser $ \s -> [(b,s’’) | (a,s’) <- pa s, (b,s’’) <- pb s’]

Все примитивные парсеры и их комбинаторы в процессе перехода от Maybe к монаде Parser подвергаются тривиальным изменениям: например, <*> теперь выглядит так:

(Parser p) <*> (Parser q) = Parser r
  where r s = concat [((vp,vq), s’’) | (vp,s’) <- p s, (vq, s’’) <- q s’]

А «королевский» пример можно теперь переписать так:

kingInfo = do
  name <- oneOrMore anyChar
  space
  birthStr <- oneOrMore digit
  char ‘|’
  info <- oneOrMore anyChar
  return (King name (read birthStr) info)

Теперь объявление парсера выглядит похоже на грамматику в форме Бэкуса-Наура. Очень важно заметить в задаче «монадную структуру» – обычно, если это удается сделать, то получается столь же ясный и красивый код, как в этом примере. Похожий подход к разбору используется в библиотеке парсеров Parsec, а идея именно этого подхода взята из работы [C.3].

Статистические распределения

Теперь займемся моделированием случайных процессов. Это самый сложный пример, но и самый красивый и неожиданный. Мне кажется, удовольствие от его прочтения стоит потраченных усилий – тривиальной информации о монадах в интернете полно, а красивых и неожиданных примеров очень мало.

Смоделируем, например, поведение трех типов водителей на светофоре и вероятность их столкновения (пример взят из книги [D.1]):

Осторожный Нормальный Агрессивный
Зеленый 1 1 1
Желтый 0.1 0.2 0.9
Красный 0 0.1 0.3

Требуется выяснить вероятность столкновения для двух данных типов водителей, и в среднем по всем типам.

На минутку забудем о наличии вероятностей – точнее, о том, что все переменные в этой задаче – «нечеткие», вероятностные. Тогда задача превращается в тривиальную детерминированную процедуру:

bool collide(Driver first, Driver second, Light light) {
    Action firstAction = first.actionOn(light);
    Action secondAction = second.actionOn(light);
    bool result = doActionsLeadToCollision(firstAction, secondAction);
    return result;
}

В общем-то, все, что надо сделать, чтобы из этой простой процедуры получить то, что нам нужно (вероятность столкновения), – заменить все типы T на «распределение над T» (т.е., способ сопоставить каждому значению типа T – вероятность его появления). Например, вместо «bool» нужно написать «распределение над bool», вместо «Driver» – «распределение над Driver» и т.п. Тогда, если язык обеспечит нам арифметические операции над такими типами, то задача будет решена.

Встает два вопроса:

1) Что такое «распределение над T» и как его представить в программе, и

2) Как реализовать операции над такими значениями.

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

Однако дисперсия выражается через матожидание: Dx = M[(x-Mx)2], а также через него выражаются и вероятности индивидуальных значений в конечном случае: P{x=x0} = M[if(x==x0) then 1 else 0]. В бесконечном случае функция распределения нам, на самом деле, не нужна. Действительно, для чего она вообще может понадобиться? Обычно ее интегрируют по интервалу, чтобы получить вероятность попадания значения в заданный интервал, однако и этот интеграл выражается через матожидание: P{x0 < x < x1} = M[if(x0 < x < x1) then 1 else 0]. Конечно, глупо говорить, что функция распределения – бесполезная вещь; речь идет о том, что для практических вычислительных задач она, будучи заданной в явном виде, непригодна.

В общем, похоже, что нам хватит следующих трех «базисных» свойств для описания распределения; все остальные свойства выразятся через них.

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

module Dist where

import System.Random

data Dist a = Dist {
    support :: [a],
    gen :: StdGen -> (a, StdGen),
    expect :: (a -> Float) -> Float
}

Теперь займемся вторым вопросом.

Напомню, что рандомизацией величины по параметру называется усреднение или получение распределения этой величины при случайном значении параметра.

Пусть нам надо сложить два «нечетких» значения – целое a и целое b. Распределение полученной величины можно вычислить, рандомизируя по параметру «a» зависящее от него распределение величины a+b. Сложение можно обобщить до любой функции; число аргументов функции также можно обобщить до сколь угодно большого. Таким образом, единственная необходимая операция для вычисления составных распределений и их характеристик, т.е. характеристик выражений вида f(a1, a2, a3, ...) – это рандомизация.

Оператор рандомизации имеет тип:

        (распределение параметра) -> 
(распределение, зависящее от параметра) -> 
  (рандомизированное распределение)
      

или – если записать тип «распределение по a» как «Dist a», то:

        Dist a -> (a -> Dist b) -> Dist b

      

Оп-па! Это ведь операция (>>=) :: m a -> (a -> m b) -> m b, примененная к монаде Dist!

Действительно, статистические распределения образуют монаду, в которой операцией связывания является рандомизация. А return, очевидно, соответствует дельта-распределению – то есть, такому, при котором случайная величина может принимать одно-единственное конкретное значение с вероятностью 1:

instance Monad Dist where
  return a = Dist 
    {
      support = [a], 
      gen = \g -> (a, g), 
      expect = \f -> f a
    }

  da >>= fdb = Dist 
    {
      support = concat [support (fdb a) | a <- support da],
      gen = \g -> let (a, g') = (gen da g)  in  (gen (fdb a) g'),
      expect = \f -> expect da (\a -> expect (fdb a) f)
    }

Вот как работают эти операции:

Теперь мы можем определить парочку простых распределений и приступить к решению исходной задачи. Для иллюстрации вполне хватит распределения freqs, задаваемого парами (значение, вероятность), которое, в свою очередь, можно выразить через комбинатор choose – «смесь двух заданных распределений в пропорции p : 1-p»:

choose p d1 d2 = Dist {support = s, gen = g, expect = e}
    where
      s = support d1 ++ support d2
      g sg = let (x,sg') = randomR (0.0,1.0) sg in if x < p then gen d1 sg' else gen d2 sg'
      e f = p * expect d1 f + (1-p) * expect d2 f

prob p = choose p (return True) (return False)

freqs [] = error "Empty cases list"
freqs [(_,a)] = return a
freqs ((w,a):as) = choose w (return a) (freqs as)

mean d = expect d id
disp d = expect d (\x -> (x-m)^2) where m = mean d
probability f d = expect d (\x -> if f x then 1 else 0)

А теперь уже можно решить исходную задачу.

data Light = Red | Green | Yellow
data Driver = Cautious | Normal | Aggressive

data Action = Drive | DontDrive

drive p = choose p (return Drive) (return DontDrive)

_          `actOn` Green  = drive 1.0
Cautious   `actOn` Yellow = drive 0.1
Normal     `actOn` Yellow = drive 0.2
Aggressive `actOn` Yellow = drive 0.9
Cautious   `actOn` Red    = drive 0.0
Normal     `actOn` Red    = drive 0.1
Aggressive `actOn` Red    = drive 0.3

Drive `collision` Drive = prob 0.3
_     `collision` _     = prob 0.0

driver = freqs [(0.2, Cautious), (0.6, Normal), (0.2, Aggressive)]

simulate d1 d2 light = do
  a1 <- d1 `actOn` light
  a2 <- d2 `actOn` light
  a1 `collision` a2

simulateOverDrivers light = do
  d1 <- driver
  d2 <- driver
  simulate d1 d2 light

probCollisionOnRed = probability (==True) (simulateOverDrivers Red)
probCollisionOfTwoAggressiveOnYellow = 
    probability (==True) (simulate Aggressive Aggressive Yellow)

В результате получим значения:

probCollisionOnRed = 0.0062
probCollisionOfTwoAggressiveOnYellow = 0.243

То есть, в среднем вероятность столкновения при красном свете светофора довольно мала и составляет примерно 0.6%, а встреча двух агрессивных шоферов на желтом свете с вероятностью 0.243 закончится для них плохо.

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

Обобщенные монадные вычисления и монадные комбинаторы

Мы увидели несколько примеров задач, хорошо выражающихся в терминах монад – то есть, одна из «заслуг» монад – это используемая ими терминология (идея связывания вычислений в цепочку, где следующее вычисление зависит от результата предыдущего, и способ связывания может быть произвольным), позволяющая компактно описывать некоторые задачи и шаблоны вычислений. Однако одна лишь терминология – это недостаточная причина, чтобы вводить дополнительную абстракцию в язык. Чтобы абстракция была полезной, необходимо, чтобы существовали операции, пользующиеся этой абстракцией как «черным ящиком». В данном случае речь идет об операциях, которые служат одинаковой цели независимо от того, к какой монаде они применены, то есть, об операциях, полиморфных относительно монадного типа: op :: (Monad m) => ... m ... С первого взгляда неочевидно, что такие операции вообще могут существовать и быть полезными – ведь рассмотренные нами монады были абсолютно разными и использовались для разных целей. Однако же они существуют.

Простейшие монадные комбинаторы

Первая из виденных нами ситуаций, когда одинаковым образом использовались разные монады – это do-синтаксис. Его можно лишь с натяжкой назвать «операцией», однако он работает одинаковым образом для всех монад, и является для них одинаково полезным.

Далее: В приведенных программах нередко встречались фрагменты кода, похожие на этот:

do a <- foo        -- foo :: m a
   b <- bar        -- bar :: m b
   return (f a b)  -- f :: a -> b -> m c

Почему не написать просто return (f foo bar)? Увы, такое выражение не пройдет проверку типов. Но этот шаблон встречается очень часто, поэтому имеет смысл его абстрагировать.

liftM :: (a -> b) -> (m a -> m b)
liftM f ma = do a <- ma ; return (f a)

liftM2 :: (a -> b -> c) -> m a -> m b -> m c
liftM2 f ma mb = do a <- ma; b <- mb; return (f a b)

И т.п.

Функции liftM, liftM2, ... входят в стандартную библиотеку и находятся в модуле Control.Monad.

Теперь можно писать, например, так: (этот фрагмент кода читает с клавиатуры строку и возвращает ее в верхнем регистре)

readAndUpper :: IO String
readAndUpper = liftM (map toUpper) getLine

Или так (монада Dist):

twoDrivers :: Dist (Driver,Driver)
twoDrivers = liftM2 (,) driver driver

Теперь представим себе, что требуется распечатать список значений values – выполнить для каждого значения putStrLn.

map putStrLn values

Однако, увы, это – не решение. Посмотрим на типы:

values :: [String]
putStrLn :: String -> IO ()
map putStrLn values :: [IO ()]

То есть, получается список значений типа IO () – список действий. Но поскольку Хаскелл – ленивый язык, то элементы списка не форсируются, а так и остаются пока не вычисленными вызовами putStrLn. Теперь, чтобы выполнить этот список действий, необходимо выполнить каждое из них по очереди.

doIOs [] = return []
doIOs (a:as) = do a ; doIOs as 

doIOs (map putStrLn values)

Если мы посмотрим в интерпретаторе на тип doIOs, то получится, что он – doIOs :: (Monad m) => [m a] -> m [a] . В нем не упоминается монада IO, он работает одинаково для всех монад! Логично слово «IO» убрать и из названия – получится стандартная функция

sequence :: Monad m => [m a] -> m [a]

и похожая на нее

mapM :: Monad m => (a -> m b) -> [a] -> m [b]
mapM f as = sequence (map f as)

Из аналогичных соображений и аналогичным образом в стандартной библиотеке определен еще ряд аналогов списочных функций и управляющих структур для монад:

when :: Monad m => Bool -> m () -> m ()
when b m = if b then m else return ()

replicateM :: Monad m => Int -> m a -> m [a]
foldM :: Monad m => (a -> b -> m a) -> a -> [b] -> m a

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

mapM_ :: Monad m => (a -> m b) -> [a] -> m ()
sequence_ :: Monad m => [m a] -> m ()

и т.п.

Зачем нужна эта общность?

Пока неочевидно, как могут использоваться вышеописанные комбинаторы (кроме liftM), кроме как для структуризации действий в монаде IO. Приведем несколько примеров:

collectDistinct :: (Ord a) => [a] -> BinaryTree a
collectDistinct as = s’ 
    where (s’, _)  = runState (mapM_ insert) emptyBinaryTree
          insert a = do t <- get
                        put (insertBinTree a t)
                        return ()
uniform a = freqs [(1.0/fromInteger a, i) | i <- [1..a]]
sumDist = foldM (\s a -> do da <- uniform a
                            return (s + da)) 
                [1..4]
fiveNumbers = replicateM 5 ((zeroOrMore (char ‘ ‘)) >> readNumber)

Особого внимания заслуживают функции, заканчивающиеся подчеркиванием и имеющие тип возвращаемого значения m()mapM_, sequenceM_, foldM_, и т.п. Они не вычисляют никаких значений (всегда возвращают «()») – они лишь применяют указанные функции к каждому члену последовательности, но игнорируют возвращаемые этими функциями значения – поэтому с этими функциями стоит использовать только монады, обладающие побочными эффектами, т.е. такие, где в вычислениях важны не только их возвращаемые значения.

К этим эффектам относятся, например, побочные эффекты монады IO (в ней в вычислении важно не только его возвращаемое значение, но и то, какие побочные эффекты происходят в процессе вычисления), или эффекты монады State (в ней в вычислении важно не только его возвращаемое значение, но и то, как вычисление изменяет переменную состояния). Только в таких монадах осмыслены последовательности вида do foo; bar; baz. Сформулировать это свойство формально, судя по всему, невозможно; это довольно нечеткое понятие, но можно сказать, что монады List и Maybe не обладают этим свойством, то есть, в них такие последовательности бессмысленны.

Например, бессмысленна последовательность do [1,2,3]; [5,6] – ее результатом является [5,6,5,6,5,6], но значение [1,2,3] было фактически выброшено на помойку, использовалась лишь его длина. Маловероятно, что такой способ использования монады List может быть полезным; таким образом, в монаде List бессмысленно использовать функции mapM_, sequenceM_ и replicateM_. Для монады Maybe, аналогично, будет использоваться не содержимое значений, а лишь тот факт, являются ли они Just или Nothing – трудно придумать ситуацию, в которой не хватило бы обычного типа Bool.

Наконец, рассмотрим несколько менее тривиальный пример, в котором создадим свой собственный полиморфный относительно монады комбинатор, а также коснемся темы монадных трансформеров.

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

Пример: SAX

Пусть, например, мы пишем программу, которая обрабатывает какие-нибудь древовидные структуры – например, XML-файлы. В таких программах очень часто используются операции, связанные с обходом дерева – например, печать его на экран, сериализация в поток, подсчет числа узлов, удовлетворяющих определенному условию, получение списка всех узлов или вычисление какой-то агрегатной функции. Скорее всего, нам понадобится функция для обхода дерева, производящая над каждым узлом какое-то действие. Однако, если этому действию будет передаваться лишь содержимое узла, то оно так ничего и не узнает о взаимном расположении узлов в дереве – поэтому лучше использовать модель SAX (Simple API for XML; поддерживается большинством современных XML-парсеров) – передавать обработчику события «вошли в поддерево» и «вышли из поддерева».

        -- Tree datatype

data Node = Node {tag :: String, attributes :: [(String,String)]}
data Tree = Tree Node [Tree]
data NodeEvent = Enter Node | Leave Node

Тогда функция обхода будет выглядеть примерно так: walk :: (NodeEvent -> действие) -> Tree -> результат. Однако обычно в реализациях SAX функция обхода ничего не возвращает; вся обработка и вычисление чего-либо ложится на обработчик. Сделаем так и мы: walk :: (NodeEvent -> действие) -> Tree -> нетРезультата.

Что может быть использовано в качестве действия? Конечно, чистая функция там будет неуместна – поскольку ее результат вычисления все равно не попадет в ответ и канет в бездну. Логично использовать монадическое действие в монаде с эффектами (как описано ранее) – т.е. IO, State и т.п.

Итого, walk :: Monad m => (NodeEvent -> m ()) -> Tree -> m () – проходится по дереву внутри обладающей эффектами монады m, вызывая для каждого события входа или выхода некоторое действие в этой монаде. Перейдем к реализации:

walk :: (Monad m) => (NodeEvent -> m a) -> Tree -> m ()
walk f (Tree n ts) = do f (Enter n)
                        mapM_ (walk f) ts
                        f (Leave n)
                        return ()

Теперь реализуем при помощи этой функции «красивую» (с отступами) сериализацию дерева в XML. Печать на экран текста с отступами имеет две особенности:

Первая особенность заставляет использовать монаду IO, вторая наталкивает на мысль о State. Однако ни одна из них сама по себе не подходит. Реализуем простую собственную монаду – монаду «вывода на экран с отступами»:

newtype IndentIO a = IndentIO { runWithIndent :: Int -> IO (Int, a) }
instance Monad IndentIO where
    return a = IndentIO $ \i -> return (i, a)
    (IndentIO r) >>= f = IndentIO r'
        where r' i = do (i', a) <- r i
                        runWithIndent (f a) i'

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

Вот несколько функций, которые нам определенно пригодятся:

        -- Производит действие, не меняя отступ
justIO action = IndentIO $ \i -> do action ; return (i,())
-- Увеличивает отступ, не производя действия
indentMore    = IndentIO $ \i -> return (i+4, ())         
-- Уменьшает   отступ, не производя действия
indentLess    = IndentIO $ \i -> return (i-4, ())          
-- Возвращает  отступ, не производя действия
getIndent     = IndentIO $ \i -> return (i, i)             
-- Печатает строку с отступом
printIndent s = do i <- getIndent                         
                   justIO $ putStrLn (replicate i ' ' ++ s)

А теперь можно использовать эти функции и решить исходную задачу:

        -- Порождает строку вида <tag atr1="v1" atr2="v2">
showEnter (Node tag ats) = "<"++tag++ concat[" "++n++"=\""++v++"\"" | (n,v) <- ats] ++">"
-- Порождает строку вида </tag>
showLeave (Node tag _)   = "</"++tag++">"

printTree = walk p
    where p (Enter node) = do printIndent (showEnter node)
                              indentMore
          p (Leave node) = do indentLess
                              printIndent (showLeave node)

Этот прием – совмещение нескольких монад в одной – требуется довольно-таки часто, и не хочется в каждом таком случае писать новую монаду. Для этого существуют монадные трансформеры, однако данная тема выходит за рамки этого текста. Тем не менее, пример с IndentIO хорошо показывает принцип работы монадных трансформеров и, по сути, является их частным случаем, специализированным для двух конкретных монад. Подробно ознакомиться с монадными трансформерами можно в [B.7] и [B.12].

С помощью функции walk и монады State можно также, например, посчитать количество узлов, удовлетворяющих некоторому предикату:

countNodes p tree = execState (walk (\e -> when (counts e) (modify (1+))) tree) 0
  where counts (Enter n) = p n
    counts _         = False

В этом коде в качестве обходчика используется функция, которая в случае события входа в узел, удовлетворяющий предикату, увеличивает переменную состояния на 1. Результатом обхода, согласно типу walk, является значение типа State Int () – то есть, это еще не ответ, а лишь функция, готовая его вычислить, если ей передать начальное состояние. Для этого вызывается execState со вторым аргументом 0 (начать счет с нуля).

Заключение

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

В процессе выяснилось, что монады могут применяться, в основном, для двух разных целей – для структуризации потока выполнения и описания императивных вычислений с эффектами (монада IO, State, IndentIO), и для структуризации потока данных (монады Maybe, List, Dist). Некоторые монады могут принадлежать к обоим классам (Parser). Операции, полиморфные относительно монады, почти всегда используются с монадами из первого класса.

Также мы затронули тему монадных трансформеров – составных монад, совмещающих в себе особенности нескольких простых – однако их подробное рассмотрение осталось за кадром.

Кроме того, за кадром осталась еще одна интересная тема: теоретическое обоснование концепции монад и их формулировка в терминах теории категорий. Эта тема особенно интересна потому, что монады изначально были открыты в теории категорий, и лишь спустя некоторое время Philip Wadler применил их в программировании.

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

Благодарности

Автор выражает благодарность Денису Москвину, Ивану Веселову и Олегу Цареву за ценные замечания и предложения к ранним версиям этого текста, а Юлии Астаховой – за оные же, к поздним версиям, за моральную поддержку и проявленное терпение. Ценные замечания о функциях, полиморфных относительно монад, дали участники IRC-канала #haskell с никами dons, bd_ и Saizan. Ряд ошибок и неточностей после публикации был найден и исправлен благодаря Ивану Тарасову, Максиму Талдыкину и Артему Шалхакову. Участники с никами quicksilver, ski, ddarius и roconnor изменили мое представление о монаде IO, указали на некорректность секции о ней и помогли исправить эту секцию.

Ссылки

A. Основы Haskell

  1. http://darcs.haskell.org/yaht/yaht.pdf – Yet Another Haskell Tutorial, один из самых простых и в то же время больших учебных материалов.
  2. http://www.rsdn.ru/article/haskell/haskell_part1.xml и http://www.rsdn.ru/article/haskell/haskell_part2.xml – перевод статьи «A gentle introduction to haskell»
  3. http://ru.wikibooks.org/wiki/Основы_функционального_программирования – курс лекций Романа Душкина
  4. http://www.haskell.org/haskellwiki/Tutorials – список обучающих материалов по языку Haskell

B. Другие учебные статьи о монадах

  1. http://citeseer.ist.psu.edu/wadler95monads.html – классическая статья Филипа Вадлера, с которой и началось «победное шествие» монад
  2. http://darcs.haskell.org/yaht/yaht.pdf – содержит главу про монады, посвященную, в основном, монадам, похожим на ST
  3. http://www.haskell.org/haskellwiki/Monad – статья на haskellwiki
  4. http://www.haskell.org/haskellwiki/Tutorials#Using_monads – список монадных туториалов
  5. http://www.haskell.org/haskellwiki/Monad_tutorials_timeline – список монадных туториалов по годам, с хорошими аннотациями
  6. http://www.haskell.org/all_about_monads/html/index.html – большой и подробный туториал, с описанием всех стандартных монад и с главой про трансформеры монад
  7. http://book.realworldhaskell.org/beta/monads.html, http://book.realworldhaskell.org/beta/monadcase.html, http://book.realworldhaskell.org/beta/monadtrans.html – главы книги Real World Haskell о монадах. Очень подробно, на практических примерах. Рассматриваются, в основном, Maybe, State и IO.
  8. http://www.haskell.org/haskellwiki/Monads_as_containers и http://community.livejournal.com/ru_lambda/12467.html – статья Monads as containers и ее перевод на русский язык
  9. http://research.microsoft.com/~simonpj/papers/marktoberdorf/ – статья Simon Peython Jones “Tackling The Awkward Squad”, прекрасно рассказывает о монаде IO
  10. http://rsdn.ru/article/haskell/haskell_part2.xml#E1JAC – Часть перевода статьи «A gentle introduction to Haskell», посвященная монадам. Содержит интересный пример нестандартной монады.
  11. http://members.chello.nl/hjgtuyl/tourdemonad.html – подробный обзор всего, связанного с монадами в стандартной библиотеке хаскелла
  12. http://spbhug.folding-maps.org/wiki/MonadTransformers – рассказ Михаила Митрофанова о монадных трансформерах

C. Научные статьи о монадах

  1. http://books.google.com/books?hl=en&... – Philip Wadler, “How to replace failure by a list of successes”
  2. http://okmij.org/ftp/Computation/monads.html – статьи Олега Киселева о монадах, в т.ч. монаде статистических экспериментов (метод Монте-Карло) и монаде логического вывода
  3. http://www.cs.nott.ac.uk/~gmh//monparsing.ps – Graham Hutton, Erik Meijer - Monadic Parser Combinators
  4. http://www.randomhacks.net/darcs/probability-monads/probability-monads.pdf – Eric Kidd, “Build your own probability monads” – статья с несколькими интересными вариациями на тему вероятностных монад
  5. http://luqui.org/blog/archives/2008/03/29/io-monad-the-continuation-presentation/ сообщение Люка Палмера, описывающее представление монады IO, основанное на продолжениях

D. Прочее

  1. http://www.amazon.com/Expert-F-Experts-Voice-Net/dp/1590598504 – книга «Expert F#»; представляет собой по совместительству прекрасное введение в функциональное программирование в целом.

Эта статья опубликована в журнале RSDN Magazine #3-2008. Информацию о журнале можно найти здесь
    Сообщений 29    Оценка 1675        Оценить