26 апреля 2011 в 18:23

Haskell без монад из песочницы

Любой программист, изучающий haskell, рано или поздно встречается с таким непостижимым понятием как монада. Для многих знакомство с языком заканчивается монадами. Существует множество руководств по монадам, и постоянно появляются новые (1). Те немногие, кто понимает монады, тщательно скрывают свои знания, объясняя монады в терминах эндофункторов и естественных преобразований (2). Ни один опытный программист не может найти монадам место в своей устоявшейся картине мира.

В результате java-программисты только посмеиваются над хаскелем, не отрываясь от своего миллионострочного энтерпрайзного проекта. Разработчики на С++ патчат свои сверх-быстрые приложения и придумывают ещё более умные указатели. Веб-разработчики листают примеры и огромные спецификации по css, xml и javascript. А те из них, кто в свободное время изучает haskell, сталкивается с труднопреодолимым препятствием, имя которому монады.

Итак, узнаем как программировать на хаскеле без монад.


Для этого нам понадобится немного свободного времени, выспавшаяся голова, кружка любимого напитка и компилятор ghc. В windows и macos его можно найти в составе пакета haskell platform (3), пользователи linux могут установить ghc из репозитория. Примеры кода, начинающиеся на Prelude> можно проверерять в ghci — интерактивном интерпретаторе.

На хабре уже была похожая статья (4), однако она не объясняет всей подноготной ввода-вывода, а просто предлагает готовые шаблоны для использования.

Переход к следующему действию — оператор


Начнём издалека. Все вычисления в хаскеле поделили на «с побочными эффектами» и «без побочных эффектов». К первому относятся, например, запись/чтение с устройств ввода/вывода, вычисления с возможностью возникновения ошибки где-то посередине и т.п. К «без побочных эффектов» относятся такие операции, как сложение чисел, склеивание строк, любые математические вычисления — любые «чистые» функции.

Чистые функции комбинируются так же, как комбинируются функции во всех других языках программирования:
Prelude> show (head (show ((1 + 1) -2)))
'0'


Для составления же программ с побочными эффектами был создан специальный оператор
>>=

назовём его «соединить» (англ. bind). Все действия ввода/вывода склеиваются именно им:
Prelude> getLine >>= putStrLn
asdf
asdf

Этот оператор принимает на вход 2 функции с побочными эффектами, причём вывод левой функции подаёт на вход правой.

Посмотрим типы функций командой интерпретатора :t:
Prelude> :t getLine
getLine :: IO String
 
Prelude> :t putStrLn
putStrLn :: String -> IO ()


Итак, getLine не принимает на вход ничего, и возвращает тип IO String.

То, что в имени типа 2 слова, говорит о том, что этот тип составной. И слово, которое стоит на первом месте, назовём построителем типа, а всё остальное — параметры этого построителя (знаю что звучит неблагозвучно, но так надо).

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

Перейдём к putStrLn. Функция принимает на вход строку, и возвращает IO (). С IO всё понятно, побочный эффект, а () — это хаскельный аналог сишного void. Т.е. функция что-то там делает с вводом/выводом и возвращает пустое значение. К слову, все программы на хаскеле должны оканчиваться этим самым IO ().

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

Что, если возвращаемое и принимаемое значения склеиваемых функций не совпадают? На помощь приходят лямбда-функции. Например, просто принимаем на вход параметр, но ничего с ним не делаем:
Prelude> (putStrLn "Строка 1") >>= (\a -> putStrLn "Строка 2") >>= (\b -> putStrLn "Строка 3")
Строка 1
Строка 2
Строка 3

Забегая вперёд, скажу что оператор »= имеет очень низкий приоритет и при желании в этом примере можно обойтись без скобок. Кроме того, если внутри лямбда функции аргумент не используется, как в нашем примере, можно заменить его на _.

Давайте перепишем первый пример на полностью эквивалентный, но с использованием лямбда функции:
Prelude> getLine >>= \a -> putStrLn a
asdf
asdf

При выводе строки на экран мы с помощью лямбда-функции теперь явно указали, что принимаем одну переменную, и явно написали как мы её используем.

Ты сказал «переменная»?


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

В коде выше a и b очень похожи на переменные. На них можно так же ссылаться, как и в других языках. Однако эти a и b существенно отличаются от переменных в императивных языках.

Во всех императивных языках программирования переменная — это поименованная область памяти. В хаскеле такие штуки как a и b — это поименованные выражения и значения.

Приведём пример и покажем эти отличия. Рассмотрим следующий код на си:
a = 1;
a = a + 1;
printf("%d",a)

Всё кристально понятно и результат предсказуем

Теперь сделаем то же самое на хаскеле:
Prelude> let a = 1
Prelude> let a = a + 1
Prelude> print a
^CInterrupted.

Выполнение кода не завершится никогда. В первой строке мы определяем a как 1. Во второй строке мы определяем a как a + 1. По время прочтения второй строки интерпретатор забывает о предыдущем значении а, и определяет а заново, в данном случае через самого себя. Ну а это рекурсивное определение никогда не вычислится.

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

С помощью этой конструкции можно передавать параметры через несколько вызовов оператора «соединить»:
Prelude> getLine >>= \a -> putStrLn "Вы ввели:" >>= \_ -> putStrLn a
asdf
Вы ввели:
asdf


Реальный код


Теперь используя наши тайные знания напишем что-нибудь настоящее. А конкретно программу, которая получает данные от пользователя, выполняет над ними какие-нибудь действия и выводит результат на экран. Программу напишем как и подобает в отдельном файле и скомпилируем её в машинный код.

Назовём файл test.hs:
main = putStrLn "Введите целое число:" >>= \_ -> 
       getLine >>= \a -> 
       putStrLn "Возведённое в квадрат число:" >>= \_ -> 
       putStrLn (show ((read a)^2))

компилируем:
ghc --make test.hs

запускаем:
$ ./test 
Введите целое число:
12
Возведённое в квадрат число:
144

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

Функция read не безопасна, если мы дадим ей буквы и попросим распарсить число, возникнет ошибка. Не будем на этом останавливаться, упомяну лишь что на этот случай есть модуль safe.

Примесь чистоты


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

В приведенном примере чистая функция записана просто как аргумент IO функции. Часто этого бывает достаточно, но не всегда.

Существуют другие способы вызова чистого кода.

Первый из них — это насильственное превращение чистого кода в побочно-эффектный. В самом деле, можно считать чистый код частным случаем побочно-эффектного, поэтому никаких опасностей такое преобразование не таит. А осуществляется оно с помощью функции return:
main = putStrLn "Введите целое число:" >>= \_ -> 
       getLine >>= \a -> 
       putStrLn "Возведённое в квадрат число:" >>= \_ ->
       return (show ((read a)^2)) >>= \b ->
       putStrLn b

Компилируем, проверяем, программа работает как и прежде.

Ещё один способ — использование хаскельной конструкции let … in … Во многих мануалах ей уделяется достаточно внимания, поэтому не станем на ней останавливаться, приведу лишь готовый пример:
main = putStrLn "Введите целое число:" >>= \_ -> 
       getLine >>= \a -> 
       putStrLn "Возведённое в квадрат число:" >>= \_ -> 
       let b = (show ((read a)^2)) in
       putStrLn b


Нужно больше сахара


Разработчики языка обратили внимание на то, что часто встречаются конструкции
>>= \_ ->

поэтому для их обозначения ввели оператор
>>

Перепишем наш код:
main = putStrLn "Введите целое число:" >>
       getLine >>= \a ->
       putStrLn "Возведённое в квадрат число:" >>
       let b = (show ((read a)^2)) in
       putStrLn b

Так стало немного красивее.

Но есть и более крутая фишка — синтаксический сахар «do»:
main = do
    putStrLn "Введите целое число:" 
    a <- getLine 
    putStrLn "Возведённое в квадрат число:" 
    let b = (show ((read a)^2)) 
    putStrLn b

То что нужно! Так уже можно жить.

Внутри блока do, ограниченного выравниванием по левому краю, происходят следующие замены:
a <- abc    заменяется на abc >>= \a ->
abc         заменяется на abc >> 
let a = b   заменяется на let a = b in do

Нотация «do» делает синтаксис очень похожим на синтаксис всех современных языков программмирования. И тем не менее под капотом у неё достаточно продуманный механизм разделения чистого и побочно-эффектного кода.

Интересным отличием является использование оператора return. Его можно вставить в середину блока, и он не будет прерывать выполнения функции, что может вызвать недоумение. Но в действительности его часто используют в конце блока, чтобы вернуть из IO функции чистое значение:
get2LinesAndConcat:: IO String
get2LinesAndConcat = do
    a <- getLine
    b <- getLine
    return (a + b)


Сфера в вакууме


А сейчас вынесем наш чистый код в отдельную функцию. А заодно расставим, наконец, отсутсвующие сигнатуры типов.
main :: IO ()
main = do
    putStrLn "Введите целое число:"
    a <- getLine
    putStrLn "Возведённое в квадрат число:"
    let b = processValue (read a)
    putStrLn (show b)
 
processValue :: Integer -> Integer
processValue a = a ^ 2

Важным моментом является то, что побочно-эффектный код ввода-вывода может запускаться только из кода ввода-вывода. Однако чистый код может запускаться откуда угодно.

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

В стилистических руководствах рекомендуется минимизировать использование побочно-эффектного кода и максимум функционала выносить в чистые функции (5). Однако если программа предназначена для выполнения действий ввода/вывода, не нужно избегать использовать его везде где нужно. Как правило в таких случаях требуются вспомогательные функции, которые могут быть чистыми. Опытные программисты на хаскеле признают отличную поддерживаемость даже IO кода в сравнении с императивными языками (высказывание приписывают Simon Peyton Johnes, но прямая ссылка не нашлась).

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

Что вы несёте?


«Этот код ужасен, он неоправданно сложен, имеет слишком мало общего с тёплой ламповой семантикой всех остальных языков, для любых целей достаточно c/c++/c#/java/python etc.».

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

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

Если же вы считаете, что «и в питоне всё хорошо, что вы привязались со своими побочными эффектами!», никто вам не мешает использовать тот инструмент, который вам нравится. От себя могу добавить, что хаскель действительно упрощает разработку и делает код более понятным. Единственный способ убедиться в этом или обратном — попробовать писать на хаскеле!

Куда идти дальше


Для дальнейшего изучения или вместо этой статьи можно порекомендовать статью «мягкое введение в haskell» (6), а особенно её перевод (7).

Кроме этого, конечно, подойдут любые другие статьи (8). Руководств написано очень много, но все они объясняют одни и те же вещи с разных точек зрения. К сожалению, очень мало информации переведено на русский язык. Несмотря на обилие руководств, язык прост, его описание вместе с описанием стандартных библиотек занимает всего 270 страниц (9).

Достаточно много информации содержится также в документации по стандартным библиотекам (10).

Буду рад, если статья поможет кому-то или просто покажется интересной, комментарии и критика приветствуется.

p.s. То, что я назвал «построителем типов» в мире хаскеля называется «конструктором типов». Сделано это для того, чтобы легче было забыть значение слова «конструктор», взятое из ООП, это совершенно разные вещи. Ситуация усугубляется тем, что помимо конструкторов типов есть ещё конструкторы данных, тоже ничего общего с ООП не имеющие.

Ссылки


  1. www.haskell.org/haskellwiki/Monad_tutorials_timeline
  2. http://en.wikipedia.org/wiki/Monad_(category_theory)
  3. hackage.haskell.org/platform/
  4. habrahabr.ru/blogs/Haskell/80396/
  5. www.haskell.org/haskellwiki/Avoiding_IO
  6. www.haskell.org/tutorial/
  7. www.rsdn.ru/article/haskell/haskell_part1.xml
  8. www.haskell.org/haskellwiki/Tutorials
  9. www.haskell.org/definition/haskell98-report.pdf
  10. www.haskell.org/ghc/docs/7.0.3/html/libraries/


upd: (SPOILER!)

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

Так вот, словом «монада» называют набор операторов
>>=
>>
return
fail

и любой тип данных, на котором они определены. Например, IO.

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

upd2:
Пользователь afiskon привёл ссылку на интересную презентацию
о хаскеле.
+52
2234
43
tranquil 10,3

Комментарии (26)

+3
Droid924, #
Писать на хаскелле без монад безусловно можно. Можно даже в какой-то мере успешно.
Однако, мне это напоминает вождение автомобиля с закрытым лобовым стеклом. Конечно, можно вести, высунувшись из окна, но это немного не то.
+24
Flux, #
Haskell без монад

+4
ControlFlow, #
прочитал название топика.
прокрутил листинги кода.
везде монада IO, с do-нотацией и без.
о каком «хаскеле без монад» вообще идёт речь?
просто пипец.
0
netslow, #
Странный подход. «Я не понимаю как работает мотор, давайте вырвем его с корнем, а машину будем толкать». Быть может лучше объяснить что такое монады и как они применяются?
+1
saidaino, #
1. А что, в общем-то, такого в объяснении монад через эндофункторы и естественные преобразования? :) В сумме выходит максимум 20 простых определений, начиная с категорий. Это же гораздо проще понять чем, скажем, объектно-ориентированное программирование.

2. rsdn.ru/forum/decl/4049523.1.aspx — вполне понятное описание без «страшных слов».

3. Конструкции, которые по сути являются монадами или моноидами широко используются и в других языках программирования. И пока их не называют монадами, проблем с пониманием обычно не возникает.
+1
Nashev, #
Объяснять непонятное непонятным — само по себе порочно, ибо сепулькарий выходит. 20 новых определений в рамках одного объяснения — тоже ужасно, ибо среди них выход из сепулькария найти трудно, да и удерживается в голове 5-7 элементов одновременно, а среди них должны быть не только новые понятия, но и уже известные, и связи этих новых с ними.

Ваше «вполне понятное описание» оперирует кучей непонятных слов и ещё большей кучей непонятных символов типа рыбки >=>. Лично я, не знакомый с хаскелем, понял лишь что-то в части про рефакторинг — что в хаскеле можно преобразовывать его выражения по формальным правилам.

А вот про то, что можно было бы назвать монадами в других языках — было бы полезно услышать. Так может и монады понятны станут… Когда станут опираться на известные понятия.
+1
tranquil, #
ответы добавлены в конец топика
+4
sylvio, #
Эта статья замечательный способ запутать разработчика. Давайте лучше писать вообще на haskell без haskell, например:

import BASIC

main = runBASIC $ do

    10 LET X =: 1
    20 PRINT "Hello BASIC world!"
    30 LET X =: X + 1
    40 IF X <> 11 THEN 20
    50 END

0
tranquil, #
Эта статья замечательный способ запутать разработчика.
Что вы хотели бы там прояснить? Может быть, лямбда функции?

Давайте лучше писать вообще на haskell без haskel например:
Многие так и делают. Хотя назвать это «haskell без haskell» не получится, но встроенные проблемно-ориентированные языки очень распространены, примеры:

hackage.haskell.org/package/atom/ — edsl для генерации embedded кода на си
jaspervdj.be/blaze/ — edsl для построения html
legacy.cs.uu.nl/daan/parsec.html — edsl для парсинга контекстно-свободных(и некоторых КЗ — грамматик)
www.lexifi.com/downloads/frankau.pdf — edsl для трэйдинговой системы
+2
Dragonizer, #
Prelude> (putStrLn "Строка 1") >>= (\a -> putStrLn "Строка 2") >>= (\b -> putStrLn "Строка 3")
Строка 1
Строка 2
Строка 3


Забегая вперёд, скажу что оператор »= имеет очень низкий приоритет и при желании в этом примере можно обойтись без скобок.
Насколько я понял, здесь результат не поменяется, НО поменяется порядок поулчения этого результата. Если бы приоритет >>= был ниже, чем у ->, то вот этот код, который вы привели позже:
Prelude> getLine >>= \a -> putStrLn "Вы ввели:" >>= \_ -> putStrLn a
asdf
Вы ввели:
asdf
хорошенько бы вас отругал. Что он непременно и сделает, стоит только поставить скобки:
Prelude> getLine >>= (\a -> putStrLn "Вы ввели:") >>= (\_ -> putStrLn a)

:1:62: Not in scope: `a'
0
tranquil, #
ответ чуть ниже
0
tranquil, #
Отлично подмечено!
Prelude> getLine >>= \a -> putStrLn "Вы ввели:" >>= \_ -> putStrLn a

В данном случае \a -> это парметр лямбда-функции, тело которой — весь остаток строки. А первый bind связывает getLine и (\a -> putStrLn «Вы ввели:» >>= \_ -> putStrLn a).

Поэтому скобки более правильно было бы расставлять так:
Prelude> getLine >>= (\a -> putStrLn "Вы ввели:" >>= (\_ -> putStrLn a))
0
bagyr, #
Превосходная статья.
Лично у меня после этого www.muitovar.com/monad/moncow.xhtml наступило просветление.
+1
afiskon, #
Ну и от себя 5 копеек.

1. Есть прекрасная презентация, объясняющая преимущества Хаскеля mmcs.sfedu.ru/~ulysses/IT/Haskell/papers/why-haskell-censored.pdf
2. На русском языке есть две годные книжки за авторством Душкина.
0
tranquil, #
ответ ниже
НЛО прилетело и опубликовало эту надпись здесь
0
tranquil, #
ru-declarative.livejournal.com/97251.html

Вот здесь первоисточник
–1
tranquil, #
Действительно, годная презентация!

Но. Опытному программисту на haskell это всё и не нужно объяснять. А вот опытному программисту на с++ никакими аргументами не доказать что map лучше for. Хоть об стену разбейся. Почему? Вопрос открыт.

Диалог слепого с глухим обычно примерно следующий:
— В map нет побочных эффектов, код более надёжен!
— Чо? Каких эффектов? Какой надёжен, он же непонятен! А вот for понятен каждому!
0
afiskon, #
Почему-то мне, как опытному (более-менее) программисту на C++ аргументы показались убедительными. Сами идея хорошая. Особенно понравилась возможность параллельного выполнения программ на функциональных языков (без мьютексов, нитей и тд). Я бы на всякий случай включил ссылку в статью.
0
afiskon, #
Кстати, в институте, когда нам объясняли ФП (кажется, это был Lisp, а возможно как раз и Haskell) на вопрос «а кому это все нужно» лектор (аспирант) ответил что-то в стиле «ну, математикам такой язык понятнее». Вопиющая безграмотность!
0
tranquil, #
Всё таки кто автор презентации, на каких условиях распространяется?
0
afiskon, #
Не знаю, мне ссылка через твиттер пришла.
0
ulysses, #
Изначально презентация была размещена в ЖЖ-сообществах ру-лямбда и ру-декларатив, сходу могу дать ссылку на второе, видимо, спрашивать надо там:
ru_declarative.livejournal.com/97251.html

Я менее аккуратный в вопросах лицензий человек, так что я выбросил оттуда слайд с матом (заодно потерялись все гиперссылки, что ужасно), перевыложил в сеть университета (домен sfedu.ru) и дал ссылку студентам.
0
Longes, #
Хакель без монад называется LISP :)
0
Vlad911, #
Для тех, кто все-таки хочет разобраться с тем, что такое монады я перевел очень толковую статью о них, которая на примерах на Javascript и Haskell отвечает на вопрос, что же это такое и зачем их придумали. joydevel.blogspot.ru/2013/01/haskell-javascript.html
0
tranquil, #
Больше мануалов по монадам!

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