2009-11-07

О страшных монадах

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

Как верно было подмечено, монады — это очень просто. А еще монады — везде. Почему же тогда у многих с ними проблемы?

1. Терминология
Где-то написано, что использование нетипичной для предметной области терминологии — это аттрибут троллинга (к сожалению, не удалось найти ссылку). Можно возразить: "да ведь монады — это же математический термин". Да, но математика и инженерия (в частности, программирование) — разные дисциплины. Математики не строят мосты, да. А термин монада не относится к предметной области программирования. В ней есть такое понятие как "вычисление" (computation). (Пусть меня осудят, но) фактически, монада в Haskell — это стратегия вычисления.

Почему же тогда говорят "поместить/извлечь значение в/из монаду/ы"? Говорят так для простоты. На самом деле, можно лишь запустить определенное вычисление с определенным входным значением (отсюда и идиома run...). Слишком похоже на "запустить процедуру", не так ли?

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

2. Подход к изучению
К сожалению, большинство объяснений монад так или иначе отталкиваются от того, что монада — это всего навсего тип и 2 простые функции. Но проблема в том, что от того, что вы поймете, как работают функции return и bind, вам не станет сразу ясно, зачем они нужны вместе. Отталкиваться в понимании того, что такое монады стратегии вычисления, нужно от того, зачем они нужны? Сейчас я сделал именно так, и все легко стало на свои места.

Какие бывают стратегии вычисления? Я сейчас не буду называть самую важную из них, а напишу о ней в следующей записи — пока предлагаю прийти к ответу самостоятельно. Могут быть: вычисление, которое завершается неудачно, если неудачно завершается хотя бы одно из внутренних подвычислений — это знаменитая монада Maybe, которую мы сейчас рассмотрим. Может быть вычисление, которое использует какое-то доступное для всех подвычислений "хранилище" данных — State. Есть вычисление, которое связанно с обменом данными с внешней средой — IO и т.д.

Бытует мнение, что Haskell — ленивый язык (более того, это официальная позиция). На самом деле, все немного сложнее или проще, зависит от того, как посмотреть. Во-первых, всем понятно, что абсолютно (или, как говорится, чисто) ленивого языка быть не может, поскольку в таком случае мы никогда не получим какого-то результата работы программы. Каким образом Haskell'исты выходят из этого затруднения? Принято говорить, что, в целом, язык ленивый, а вся неленивость/энергичность (eagerness) находится (так и хочется сказать, заточЕна) внутри монад. Но дело в том, что любая Haskell-программа находится внутри монады IO. В итоге оказывается, что ленивыми в Haskell являются только чистые функции, а все вычисления (в понимании программистском), которых, на самом деле, большая часть — все равно остаются энергичными. В то же время любая чистая Haskell-функция, учитывая pattern matching и немутируемые переменные, по большому счету представляют из себя одну case-конструкцию и вызов других функций. Красиво? Часто, да. Однобоко? Тоже. ОК, но самое интересное дальше.

И в других языках примерно то же самое! По сути, разница только в том, что:
(а) не проведена четкая граница между ленивыми вычислениями и энергичными. Впрочем, каждый может для себя ее проводить сам: например, можно думать о ленивых вычислениях как об аттрибутах "математических" функций (и таким образом их и программировать), а об энергичных — как о процедурах. В любом языке может быть реализована ленивая стратегия вычислений, однако не везде это сделать одинаково легко...
(б) программист самостоятельно может строить процесс вычисления адекватный конкретной задаче. По сути, построение вычислительных процессов и есть одно из главных составляющих собственно того, что мы понимаем под программированием
Можно выдвинуть предположение, что все разные виды вычислений можно свести к нескольким десяткам определенному количеству монад. И, изучив их, можно будет составлять вычисления, как кубики в конструкторе. (И это предположение действительно выдвигается). В таком случае монады и вправду становились бы решающим методом абстракции в программировании. Впрочем, оказывается, что даже сведя вычисления к монадам, составлять их также легко, как кубики, не удается, и приходится прибегнуть к помощи еще одних монад — монадных преобразователей (monad transformers). В итоге, монада на монаде сидит и монадой погоняет... :) (Можно было догадаться исходя из принципа вычислительной эквивалентности. Вообще говоря, приняв во внимание этот принцип, можно понять, что максима "there is no silver bullet" таки справедлива всегда).
И еще одно: вывод про сведение всех вычислений к определенному набору не нов. Он лежит в основе структурного программирования, где доказано, что все вычисления можно свести к комбинации последовательного выполнения, условного выражения и цикла.

Вот тут то разница между Haskell'ем и другими языками становится наиболее существенной: другие языки имеют синтаксическую поддержку определенного узкого набора монад, и дают возможность создавать другие монады ad hoc с использованием того или иного механизма. Haskell, по сути, делает то же самое, только создание новых монад регуляризирует и вгоняет в определенные рамки. Итак Haskell — это язык, в котором дисциплина на первом месте.

В общем, концепция монад очень полезна, чтобы понять, что могут быть совершенно любые стратегии вычислений и не стоит считать, что возможны только те, которые определенный язык считает родными (вот, в С не было стратегии вычисления Exception, а в С++ появилась :)

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

Этот шаблон хорошо знаком всем, кому доводилось встречаться с логическими операторами с коротким замыканием. Идеально он работает в Common Lisp, где значение nil также является логической ложью (это, кстати, считается многими одним из кардинальных преимуществ Common Lisp над Scheme, в котором эти 2 значения разделены). Ниже приводится пример реализации Maybe на CL, аналогичный примеру из пособия по монадам:

(defmacro maybecall (val &rest funs)
"Consequently apply a sequence of FUNctionS to the
return value of the previous computation, starting with VAL"
`(and-it ,val
,@(mapcar (lambda (fun)
`(funcall ,fun it))
funs)))

;; где:
(defmacro and-it (&rest args)
"Like AND, but IT is bound to the value of the previous computation"
(cond ((null args) t)
((null (tail args)) (first args))
(t `(let ((it ,(first args)))
(when it
(and-it ,@(tail args)))))))

;; работает?

(defun mother (x)
(gethash x *mothers*))

(defun father (x)
(gethash x *fathers*))

(setf (gethash :b *fathers*) :e
(gethash :a *fathers*) :d
(gethash :b *mothers*) :c
(gethash :a *mothers*) :b)

(maybecall :a #'mother #'father) => :e
(maybecall :a #'mother #'father #'father #'brother) => nil
;; и даже на brother не ругается, потому что до него дело не доходит


Впрочем, я понял, что обычный AND (или AND-IT) в CL — это и есть полноценная реализация Maybe стратегии. Притом простая и понятная, нет?

3. Нужны ли монады за пределами Haskell'я?
То, что в других языках представляет из себя единую ткань вычислений, в рамках Haskell разделено на 2 класса: чистые (обычные функции) и "грязные" (монады). Это приводит к разделению (дублированию) синтаксиса и усложнению программного кода (полная противоположность дуалистического подхода). В то же время, это делает рассуждения о программах более структурированными и помогает (однако не гарантирует!) соблюдению принципа ссылочной целостности. В теории концепция монад полезна для понимания того, что такое вычисления. На практике же введение дополнительных правил приводит к тому, что появляется определенный предел выразительности языка и близости его к предметной области. Именно так, несмотря на заявления программистов на Haskell об обратном: концепция монады, монадного трансформера и т.д. неимоверно далеки от любой предметной области, которая встречается при программировании, а абстрагировать их средствами Haskell не удастся. Более того, само понятие вычисления, которое в других языках остается имплицитным, в Haskell'е достается на поверхность. Именно с этим, по-моему, связанны частые претензии к преувеличенной сложности Haskell программ.

---

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

Еще по теме:
* All About Monads
* Why monads have not taken the Common Lisp world by storm

2009-11-02

О Haskell

Обновление: обсуждение на habrahabr.ru Изучай Haskell ради... Haskell'а

Я долго (несколько лет) не решался составить окончательное мнение о Haskell'e: слишком противоречивы были мысли. И вот, наконец, благодаря этой записи о разборе программки определения двудольности графа я могу это сделать :)

Я понял, что Haskell-программисты — в основном, нужно сказать, хобби-программисты — это те, кто программирует не решение задачи, алгоритм, систему, а Haskell! [1] Посмотрите, какой простой алгоритм описан в заметке, а сколько вокруг него нагромождено языковых конструкций, объяснений и дискуссий. Чтоб доказать, что он очень простой, привожу пример кода на Lisp'е, который решает ту же задачу без никаких монадных трансформеров и т.п. (не обращайте внимание на большой docstring):
(defun test-bipartite (graph)
"Test for bipartiteness a GRAPH, that is given as a list of pairs
vertix - list of immediately connected vertices, like:

'((0 . (1)) (1 . (0 2 8))
(2 . (1 3)) (3 . (2 6))
(4 . (5 7)) (5 . (4))
(6 . (3)) (7 . (4 8))
(8 . (1 7)))
-- bipartite one

'((0 . (1 2)) (1 . (0 2 8))
(2 . (1 3)) (3 . (2 6))
(4 . (5 7)) (5 . (4))
(6 . (3)) (7 . (4 8))
(8 . (1 7)))
-- not bipartite one

'((0 . (1 2)) (1 . (0 2 8))
(2 . (1 3)) (3 . (2 6))
(4 . (5 7)) (5 . (4))
(6 . (3)) (7 . (4 8))
(8 . (1 7)) (9 . ()))
-- not connected one"

(let ((map (make-hash-table))
(visited '()))

(labels ((con1 (v)
"vertices immediately connected to V"
(tail (assoc v graph)))

(paint (v level)
"paint the graph from vertix V, return all
visited so far (in subsequent calls of PAINT) vertices"
(pushnew v visited)
(setf (gethash v map) level)
(mapc (lambda (v) (paint v (1+ level)))
(remove-if (lambda (v) (member v visited))
(con1 v)))
visited))

;; first we paint and check, that all vertices are visited
(unless (set-exclusive-or (paint 1 0)
(mapcar #'first graph))
(every (lambda (entry)
(every (lambda (v)
(oddp (+ (gethash (first entry) map)
(gethash v map))))
(tail entry)))
graph)))))


Мне хорошо знакомо это умонастроение — когда в погоне за максимальным использованием мощи языка забываешь о самой задаче,— поскольку в Lisp-мире оно тоже часто встречается: есть языки, которые способны действительно увлечь. И выражение "это взорвало мне мозг" часто звучат и по поводу Lisp'а, и по поводу Haskell'а. Но это же — фигня! Конечно, не может не радовать узнать что-то новое, но не нужно же радоваться этому, как ребенок новой игрушке. Хороший язык программирования должен быть максимально понятен и прост, должен давать человеку свободу самовыражения. Честно говоря, именно этому я обрадовался, когда открыл для себя Lisp: что нашел то, что искал. А не тому, что увидел какую-то конструкцию или изворот, который не доводилось встречать раньше.

Так что же, вывод: всем программировать на Lisp? Я, конечно, за, но вывод тут другой: Haskell — очень интересный язык, у которого есть как плюсы, так и минусы. Плюсы: это интересная семантика и сильная теоретическая база, хорошая скорость выполнения современных компиляторов. Минусы: ужасно нерегулярный синтаксис [2], искусственная ограниченность, которая приводит к необходимости задействовать сложные подоходы там, где отлично справятся и простые. И им просто обязательно стоит заниматься, если вас интересует тема языков программирования как таковых, их развития и исследований. Из Haskell берут многое другие более практичные языки: яркий пример тому Clojure. Но он не для написания больших систем и даже не для исследования алгоритмов в общем случае. У языков программирования кроме синтаксиса и семантики есть еще третий аспект, пожалуй даже важнейший, о котором часто забывают — прагматика. То, как язык используется, для чего он предназначается, чем живет сообщество его разработчиков и пользователей. Прагматика Haskell'а заключается в том, что он существует прежде всго для исследования... Haskell'а.

[1] Есть, конечно, исключительные, прекраснейшие Haskell-программисты, написавшие на нем много полезного кода для реального мира, но это, как говорится в нелюбимом мной афоризме, только подтверждает правило.

[2] Для современного языка нерегулярный синтаксис — это неуважение к своим пользователям. Ведь никто в современном мультиязыковом мире не программирует на одном языке, поэтому нельзя требовать от человека держать в голове идиосинкразии каждого. И этих общеупотребимых языков будет все больше и больше, а количество legacy кода уменьшаться не будет. Я сейчас имею дело с Lisp, Python, Php, C, JavaScript, Shell, Java. И это ведь не самый яркий пример.