Pull to refresh

Haskell без монад

Reading time 10 min
Views 7.1K
Любой программист, изучающий 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 привёл ссылку на интересную презентацию
о хаскеле.
Tags:
Hubs:
+52
Comments 26
Comments Comments 26

Articles