By Mike Vanier
В сообществе любителей Haskell прижилась шутка, что каждый Haskell-программист должен в процессе своего обучения написать одно или несколько руководств по монадам. И я — не исключение. Но я знаю, что существует очень много руководств по этой теме, многие из них хороши, — так зачем мне писать Еще Одно? Две причины:
Так как я буду писать примеры на Haskell, для вас, читатель, было бы полезно знать его, включая такие разделы, как полиморфизм и классы типов. Без этих знаний материал будет сложен для понимания. Уже написаны десятки вводных руководств по Haskell, которые стоит прочитать неподготовленному читателю, и потом вернуться к серии этих статей.
А вот знать теорию категорий, очень абстрактную ветвь математики, я не требую, хоть в ней и описывается теория монад (в терминах данной статьи). Конечно, знание теории категорий не навредит, но это не обязательно, чтобы понять представленный материал. Я не верю тем, кто говорит, что вам необходима теория категорий перед изучением монад в приложении к языкам программирования, — это не так. Если вы ее изучали, — хорошо, но я не вижу преимуществ в том, чтобы использовать терминологию оттуда.
Я не собираюсь учить вас всему, что есть в мире монад по двум причинам. Первая: это было бы очень долго, и вторая: я не знаю всего и, вероятно, никогда не узнаю. Я хочу дать вам понимание монад на чистом концептуальном уровне, а также почему они полезны, как с ними работать, и какие общие монады чаще всего используются. По ссылкам в конце этой серии статей вы можете изучить монады более глубоко.
Еще не ждите тонн кода, которые вы бы могли сразу использовать в своей повседневной работе. Это же не «книга готовых рецептов»! Я очень верю, что вам нужно понимать, что происходит, когда вы программируете с использованием монад, и для их детального объяснения было написано это руководство. Вместе с ним вы можете читать и другие руководства (смотрите в ссылках), чтобы найти лучшие монадные решения практических проблем, а моя цель — обрисовать общую картину и помочь вам действительно понимать монады и их работу.
Наконец, я вас уведомляю, что буду повторять и повторять главные мысли, пока они не вопьются в вас насмерть, потому что мне хочется, чтобы вы полностью поняли, что я пытаюсь сказать. Надеюсь, это не будет скучно, — хотя будет долго, потому что монады в нескольких предложениях не объяснишь. Сварите себе чашку кофе и убедитесь, что ваш стул удобен, — понимание займет время.
Насколько мне известно, впервые монады были использованы в Haskell, основанные на работах Eugenio Moggi и Philip Wadler (два гиганта, с которыми мне не сравниться). С тех пор они появились в других языках, особенно в функциональных. Но почему же вам, читателю (предположительно программисту, не попробовавшему наркотик функционального программирования), беспокоиться о монадах?
Главная идея функционального программирования — использовать чистые функции как можно шире. Чистая функция — это черный ящик. Все что она делает, — это принимает один или несколько аргументов, что-то вычисляет и возвращает результат. Она не оперирует никакими побочными эффектами. Никаких вам чтений-записей в файлы и соккеты, никакой печати в консоль, никакого изменения глобальных переменных, никакой обработки исключений, и так далее. Преимущество здесь в том, что поведение чистой функции строго определено: на одни и те же аргументы она всегда вернет одно и то же значение. Чистая функция более предсказуема, проще тестируется, и менее подвержена ошибкам. ({1}) Для сравнения, нечистая функция (у которой есть побочные эффекты) не обязательно вычислит тот же результат при нескольких одинаковых вызовах. Например, ответ может стать другим, если у задействованной глобальной переменной изменится значение, или если в считываемом файле окажется разное содержание. Нечистые функции труднее тестируются, подвержены многим ошибкам, и есть множество ситуаций, когда функции завершаются сбоем. По этим причинам функциональные языки программирования побуждают писать чистые функции, когда это только возможно.
Однако, программы в чистых функциях слишком ограничены. Есть случаи, когда программы пишутся проще с использованием побочных эффектов, хотя их и можно написать (с мучениями) только в чистых функциях. А в некоторых других случаях без побочных эффектов обойтись вообще нельзя. Например, программа, которая копирует файл из одной папки в другую, взаимодействует с файловой системой и изменяет ее; если вашим чистым функциям не позволено читать и писать файлы (а это и есть побочные эффекты), то они и не смогут решить эту задачу. Так что нам нужны способы работать с побочными эффектами даже в функциональных языках.
Функциональные языки бывают двух видов: чистые и нечистые. Нечистые ФЯ (Scheme, OCaml) не заботятся об этой проблеме: они просто позволяют писать любые функции с побочными эффектами, хотя программисты нечистых ФЯ обычно избегают этого без особой на то необходимости. Чистые ФЯ (такие как Haskell) более хардкорны: они вообще запрещают писать непосредственно функции с побочными эффектами (вы скоро узнаете, почему я написал «непосредственно»). Поэтому, как вы можете себе представить, тема о побочных эффектах в чистых языках программирования была одним из главных направлений в исследованиях долгое время.
Монады оказались ключом к решению этой проблемы. (Точнее, одним из ключей; в некоторых других ФЯ изобретены иные подходы; «Clean's uniqueness type» как вариант.) С помощью монад можно использовать вычисления с побочными эффектами без нарушения чистоты языка. Монады и система типов позволяют нам отделить вычисления с побочными эффектами от других вычислений, и они не будут мешать друг другу. Мы получаем все преимущества кода без побочных эффектов, причем это гарантирует нам система типов. В то же время мы можем выполнять побочные эффекты по необходимости. И это очень мощная концепция.
И как будто бы этого было недостаточно, — выяснилось, что у монад есть много других применений, не только обуздание побочных эффектов. Монады — очень универсальный инструмент, с помощью которого можно организовать различные виды вычислений с прозрачным поведением. Некоторые программы при этом резко упрощаются. Во многих случаях монадический код получается короче и понятнее, чем его немонадический аналог; мы разберем примеры этого явления. В общем, монады полезны и за пределами побочных эффектов в функциональных языках.
Монады — это одна из удивительных идей в теории языков программирования, и их стоит изучать.
В процессе я надеюсь объяснить вам не только сами монады и как они работают, но так же и то, почему они так обескураживают программистов, не встречавшихся с ними ранее. (Подсказка: это не потому, что программисты недостаточно умны или не знают теории категорий.)
Ну хорошо, давайте начнем разбор моего определения с выражения «понятие вычислений».
Самое простое и самое предсказуемое вычисление — это обычные (чистые) функции (то есть, математическое определение функций). Для простоты я буду рассматривать функции, отображающие один входной аргумент на один выходной. (Есть возможность свести многоаргументную функцию к функции с одним аргументом с помощью процедуры каррирования, и я должен буду рассказать об этом больше, — но позже. Сейчас просто воспринимайте мои слова как данность.) Как я и говорил ранее, для чистой функции должно выполняться правило: она должна всегда возвращать одинаковые результаты на один и тот же входной параметр. В сильно типизированных языках вроде Haskell у функции есть определение типа, которое всегда означает, что для типов a и b функция отображает значение типа a на значение типа b. Вот как это выглядит в Haskell:
Здесь двойное двоеточие "::" значит «имеет следующий тип». Таким образом функция f имеет функциональный тип a -> b, и это значит, что функция берет значение типа a и возвращает значение типа b. На практике вместо a и b обычно стоят конкретные типы: Int, Float, String…, но в Haskell функции также могут работать независимо от типов аргументов. ({3})
Итак, чистые функции — это самое простое «понятие вычисления». А какие вычисления существуют еще? Их много, и со многими вы знакомы; сюда входят вычисления, которые:
Заметьте: я использовал фразу «Ввод/Вывод», или сокращенно, I/O, чтобы обозначить ввод/вывод при работе с файлом или консолью. Известно, что операции I/O несут в себе побочные эффекты. Не путайте операцию ввода/вывода с входным и выходным значением функции.
Подумайте секунду о том, как вы бы работали с этими вычислениями в обычных языках программирования — в С или Java. Вычисления с операциями ввода/вывода? Нет проблем! Любая функция С и Java это может. А как насчет вызова исключений? В С это немного сложно, так как там нет языковой поддержки исключений, но зато можно вернуть код ошибки в случае сбоя. (Или вы можете обрабатывать ошибки вообще с помощью setjmp/longjmp, если вы матерый низкоуровневый программист.) В Java вы просто вызываете исключение в надежде, что оно где-то обрабатывается. Кроме исключений есть еще состояние, — как работать с ним? Да в общем-то просто: и в С, и в Java вы можете читать и писать переменные, глобальные и локальные, по-разному. А вычисления, которые могут провалиться? Их можно рассматривать как вырожденный случай исключений, так что опять никаких проблем. Наконец, как быть с вычислениями, возвращающими много значений? Здесь под множеством значений я имею в виду не один объект, содержащий в себе кучу результатов — не структуру C и не объект Java, — я говорю о функциях, которые могут вернуть несколько отдельных результатов «параллельно». Не совсем понятно, как это сделать в C или Java. ({4})
Важно отметить следующее: во всех случаях мы уже не говорим о традиционном понятии вычислений, так как помимо обычного отображения входного параметра на выходной «где-то еще» происходит «что-то еще». Кроме того, есть и другие виды «чего-то еще», со своими понятиями вычислений. Мы обычно не беспокоимся об этом, когда пишем программы; мы просто понимаем, что наши «функции» не совсем то же самое что и функции в математическом смысле. Ведь в них есть побочные эффекты ввода/вывода, исключения, изменение глобальных переменных и так далее. Для большинства программистов это и не важно, — пока они не схватят неприятную ошибку, из-за, оказывается, изменившейся глобальной переменной, или пока программа внезапно не остановится с исключением, или пока не возникнет еще какая-нибудь проблема, вызванная нефункциональной природой всех этих «функций». Таким образом нам бы хотелось использовать чистые функции как можно больше. Нам бы хотелось, — но есть случаи, когда это невозможно, и приходится делать «что-то еще», то есть, вычисления с побочными эффектами.
Вывод один: мы хотим сидеть на двух стульях. ({5}) Мы бы хотели писать код в чистых функциях везде, где это только возможно, получая все выгоды этого: проще отладка, верификация… Но также нам бы хотелось работать с тем самым «чем-то еще» контролируемым способом, ибо нет выхода или так лучше в конкретной ситуации. И это то, что монады нам позволяют.
НО! Ключевая фраза прошлого параграфа — это «контролируемым способом». Если бы этот механизм работал так же, как в C или Java, мы бы, конечно, решили свои задачи с помощью многих этих нефункциональных вычислений, однако же и потеряли бы при этом преимущества функционального программирования. Ведь у нас не было бы никаких гарантий, что функции — чистые, даже проверка типов тут не помогла бы. Необходим какой-то системный подход к работе с другими понятиями вычислений, который бы не нарушал чистоты кода.
Сейчас мы рассмотрим полезные понятия (чистых) функций, (чистого) применения функций и (чистой) композиции функций, а дальше сравним это с монадическим способом, реализующим те же цели.
Чуть ранее я упоминал, что в Haskell используется специальная запись для определения типов входных и выходных параметров функций. Для функции f, у которой входной тип a и выходной тип b, запись будет выглядеть так:
Таким образом, f имеет тип a -> b (читается как «из a в b»). Вот более специфичный пример функции, которая удваивает входное значение:
f имеет тип Int -> Int, потому что принимает целое, умножает его на два и возвращает другое целое.
Выполнить функцию просто, для этого мы применяем ее к аргументу (мы предполагаем, что у нее один аргумент). Обычно это делается приставлением аргумента к функции:
Заметьте, что в языке Haskell аргументы не оборачиваются в скобки, как во многих других языках программирования.
В Haskell существует специальный оператор $, это оператор применения функции. У него следующий тип:
(В Haskell символьные инфиксные операторы эквивалентны функциям с тем же именем, заключенным в круглые скобки. Так, запись f $ 2 эквивалентна записи ($) f 2. Операторы обычно определяются в их функциональной форме — для удобства. Обратитесь к вводным материалам по языку, если хотите узнать больше. Мы будем часто пользоваться операторами здесь.)
Запись значит, что для любых типов a и b этот оператор берет функцию из a в b как первый аргумент, применяет ее ко второму аргументу типа a и возвращает результат типа b. В функциональных языках считается обычным делом передавать функции в качестве аргументов других функций, так что тут нет никаких проблем. Можно сделать следующие выводы:
Вы видите просто три разных способа записать одно и то же.
Оператор $ не особо здесь нужен, потому что технически проще подставить аргумент к функции, чтобы ее выполнить. Но ради интереса мы можем задать оператор «обратного применения», назовем его >$>, и пусть он принимает те же аргументы в обратном порядке:
Можем читать это как «оператор берет значение x, применяет функцию к нему и возвращает результат». Если вы знакомы с UNIX-системами, вы могли заметить, что юниксовый конвейер (пайп, |) работает сходным образом. Вы передаете ему некоторые данные, а он применяет к ним идущую следом программу. Мы можем работать с операторами применения функции когда удобно, хотя обычно мы вообще их не используем, просто подставляем аргументы к функциям.
Теперь, когда мы поговорили о применении функций, следующая важная тема — это композиция функций. И это по-настоящему важная тема. Предположим, что у нас есть две функции f и g, а также значение x следующего вида:
где a, b, c — некоторые типы. Вы могли бы сделать с этими x, f и g следующее: взять x, применить к ней функцию f (получив бы значение типа b), и затем к результату применить функцию g. Значение x типа a преобразовалось бы к значению типа b, а затем то, что получилось, было бы преобразовано к значению типа c. Записать на Haskell это проще, чем сказать:
Но работать это будет только в том случае, если типы f и g совместимы, то есть, если результат функции f имеет тот же тип, что и у аргумента функции g (в нашем случае это тип b). Применение одной функции к другой можно трактовать и другим способом: мы берем две функции f и g типов, соответственно, a -> b и b -> c, и создаем третью функцию типа a -> c. Применяя ее к аргументу x, мы получим результат типа c. Эта идея с объединением двух функций в третью называется композицией функций. В Haskell даже определен простой оператор композиции функций:
Здесь использована запись "\x -> ...", которая обозначает лямбда-выражение (или, то же самое, анонимную функцию) с одним аргументом x. Вот так оператор композиции берет две функции в качестве аргументов и возвращает третью. И снова: в ФЯ функции как аргументы и как возвращаемые значения — это вполне обычное явление, которое встречается на каждом шагу.
Иногда случается неприятность с оператором композиции, когда функции следуют не в том порядке. Но мы можем написать «оператор обратной композиции» >.>:
Мы даже можем выразить его через оператор обратного применения функции >$>:
Или еще проще — через оператор композиции:
Сигнатура оператора >.> немного яснее и показывает, что происходит при композиции функций. Вы берете функции f и g и вычисляете новую функцию. Пусть она зовется h. Применяя h к значению, вы получите то же самое, если будете применять к значению сначала f, а потом к результату — g. Вот что такое композиция функции — способ из одних функций сделать другие.
Разберем пример:
Чем здесь занимается функция h? Она принимает целое число, умножает его на 2 и прибавляет 3. То есть, она эквивалентна следующему варианту:
Композиция функций может показаться не таким уж великим делом, — в реальности же это один из основных пунктов функционального программирования. Композиция позволяет связывать существующие функции в более сложные функции, опуская ручную работу с аргументами. И вместо того чтобы говорить «h — это функция, которая получается сначала вычислением функции y = f(x), а затем вычислением функции h = g(y)», мы просто говорим «h — это функция, которую мы получаем, применяя сначала f а затем g». Без промежуточных сущностей код становится более кратким и высокоуровневым. Представьте себе, что вам потребовалось вызвать десять функций одну за другой. Если бы вы записывали промежуточные результаты, это вылилось бы в что-то подобное:
Весьма утомительно, правда? А теперь посмотрим на композицию функций:
или, то же самое:
Это не только короче, но и более интуитивно. («Применяя f1, затем f2, затем f3 и так далее, мы получим f11»). Кстати, этот способ записи функций с использованием композиции и без аргументов, называется «бесточечным стилем». Ирония в том, что в «бесточечном стиле» оператор «точка» (.) очень даже используется, — сильнее, чем в обычном коде. Тут правильнее было бы сказать «безаргументный стиль», а не «бесточечный», так как мы опускаем аргументы функций.
Темы размышлений, закрепляющих материал:
Пока все, что я рассказал, надеюсь, было довольно простым. Теперь мы переходим к более сложным вещам.
Ранее я сказал, что суть монад в том, чтобы обобщить понятие композиции и применения функций в виде вычислений, которые отличаются от вычислений в чистых функциях, и мы даже рассмотрели некоторые примеры «нечистоты». Из определения монад следует, что мы получаем некие «расширенные функции», которые делают что-то еще, помимо простого вычисления над входным значением. На схематическом псевдо-Haskell-языке мы могли бы записать эти «расширенные функции» так:
где f — расширенная функция, a — тип аргумента, b — тип результата, а «что-то еще» специфично для разных понятий вычислений. В Haskell за словами «понятие вычислений» кроются, в частности, и монады. (Мы еще не знаем, что это такое, так что пока поверьте мне на слово.) Мы можем понимать «расширенные функции» как «монадические функции». Это не стандартная терминология, я их так зову, чтобы отличить их от обычных чистых функций.
Конечно, запись "--[something else]-->" невалидна в Haskell; чуть позже мы посмотрим, как это выглядит на самом деле, и я надеюсь, что это будет понятно. А сейчас мы будем придерживаться этих обозначений, чтобы сравнить описанные выше понятия вычислений; мы дадим каждому понятию вычислений имена, соответствующие монадам в Haskell.
Я написал слово «list» с маленькой буквы, потому что списки в Haskell выглядят несколько иначе благодаря синтаксическому сахару, так что нам не нужно отдельное слово для них.
Позже я приведу примеры для всех этих монад, а сейчас рассмотрим функции, выполняющие операции ввода/вывода, то есть функции, относящиеся к монаде IO. У нас есть псевдозапись:
Можно было бы сказать, что f — это функция из a в b, действующая в монаде IO. Как я упоминал выше, это невалидный синтаксис. В Haskell вы должны обернуть «монадность» монадической функции в тип, окружив им входной или выходной параметр. В принципе, получилось бы два варианта записи монадной функции, вот таких:
или
Оказывается, в Haskell используется вторая форма записи для монадических функций:
для любой монады m; для IO, например. (Для хардкорщиков замечу, что существует понятие комонад, где каждая функция имеет вид f :: c a -> b для некоторой комонады c. Оставим этот вопрос для будущих статей.)
Ну хорошо, что же на самом деле кроется за записью «f :: a -> m b»? Запись значит, что есть некая обычная (чистая) функция f, которая принимает значение типа a и возвращает значение типа m b (чем бы они ни были). Значит, в Haskell монадные функции являются чистыми функциями с монадическим типом возвращаемого параметра. Иначе говоря, чистая функция принимает обычное значение и возвращает монадное. И что же это значит?
Запись «m b» нуждается в пояснении. b — Это некоторый тип. m представляет некоторую монаду. Однако что конкретно понимается под m в Haskell? В Haskell «m» обязан быть конструктором типа — специальной функцией на типах: она берет аргумент и возвращает тип. Это не так странно, как может показаться. Рассмотрим понятие «список целых чисел», тип которого в Haskell выглядит как [Int]. Часть «список чего-то» можно понимать как конструктор типа, который берет некий тип (Int) и возвращает другой тип (список целых, [Int]). Квадратные скобочки жестко зашиты в Haskell для обозначения списков, но вы можете определить собственные конструкторы типов. Также свой конструктор есть у любого полиморфного типа. Один из самых простых полиморфных типов — это Maybe, определенный как
Здесь написано, что Maybe — это конструктор типа, который принимает тип (называемый a) и производит новый тип как выходное значение. Если подставить вместо a тип Int, получим новый тип Maybe Int, что записывается как:
Таким образом, Maybe — это функция на типах, которая отображает один тип в другой.
Монады, как они есть в Haskell, — это конструкторы типов, производящие новый тип оборачиванием вокруг старого. И монада IO, фактически, является конструктором типа, с помощью которого производятся такие типы как IO Bool, IO Int, IO Float, IO Char, IO String, и так далее. Это все валидные типы в Haskell. Сходным образом для монады Maybe конструируются валидные типы Maybe Bool, Maybe Int,…. Я буду называть типы, созданные монадным конструктором, «монадными типами». IO Bool, Maybe Int, и так далее — все это монадные типы.
Мы подошли к очень важному вопросу: чем занимаются значения, представляющие монадический тип? Я их называю «монадическими значениями». Например, что представляет собой значение типа Maybe Int? А IO Float — что это такое?
Мы только что столкнулись с тем, из-за чего монады кажутся «трудными для понимания».
Давайте резюмируем.
Теперь переформулируем вопрос: а что мы можем сказать о сущности «монадических значений»?
Ответ таков: Они не представляют собой ничего реально интуитивного!. Интуитивно понятие монадической функции (той, которая делает что-то еще кроме конвертирования одних данных в другие). Концепция «монадического значения» вовсе не интуитивна. Просто в Haskell так принято обозначать выходные значения монадических функций. Вы потратите время зря, если попытаетесь понять монады через то, что такое в действительности эти монадические значения. Не утруждайте себя! Не стоит!
Тем не менее, в литературе по Haskell вы можете обнаружить два общих способа объяснить монадические значения (ну и кучу глупых способов, которыми грешат многие руководства):
1. Монадическое значение типа m a (для некоторой монады m) — это особый вид «действия», которое что-то выполняет и возвращает значение типа a. Суть действия зависит от каждой конкретной монады.
2. Монадическое значение типа m a (для некоторой монады m) — это такой контейнер, в котором хранится значение типа a.
Изучать монады через размышления о монадических значениях — это неверный подход, а верный — через размышления о монадических функциях. Я попробую убедить вас, что в определении (1) даже есть какой-то смысл. А вот определение (2), как мы увидим далее, — это неверный путь изучать монады. Большая часть монад вовсе не контейнеры, хотя некоторые могут вести себя и как контейнеры тоже.
Давайте возьмем нашу функцию, надеюсь достаточно понятную, в качестве отправной точки:
Тогда функция f x, где x типа a, будет иметь тип m b:
f x теперь «монадическое значение», которое не совсем интуитивное. Рассмотрим еще одну функцию:
g делает буквально следующее: она берет значение любого типа a и оборачивает его в функцию, так что вы можете получить результат, передав в g пустое значение. ({6}) Пустой тип и значение записываются в Haskell одинаково в виде скобок (), и это просто тип/значение, которое для нас не важно. (Слово «пустой» значит, что это значение не представляет для нас никакого интереса.) Приведем пример:
Теперь, что мы получим, придумав функцию g (f x)? Посмотрим на типы:
Таким образом, функция g (f x) имеет тип () -> m b. Другими словами, она берет пустое значение и возвращает монадическое значение. А если посмотреть с другой стороны, это монадическая функция, которая преобразует пустое значение (неважно, какое) в значение типа b, одновременно выполняя «что-то еще». («Что-то еще» зависит от того, какая монада используется.) В этом есть какой-то смысл.
Вот моя мысль. Если вы считаете, что нужно понять, что такое монадическое значение (типа m b), лучше всего считать его монадической функцией типа () -> m b, то есть функцией, которая не только отображает пустое значение в значение типа b, но и выполняет что-то еще. Как будто значение типа m b и есть функция типа () -> m b, только по-другому написанная. Так сказать, монадические значения — это «тайные функции». Потому-то они часто обзываются «действиями», и ассоциируются с функциями, да не совсем функциями. (Иногда мы даже говорим «выполнить действие», что сходно с применением функции.)
Несколько примеров сейчас не повредят. Я воспользуюсь двумя функциями ввода/вывода в Haskell:
getLine — это «функция» (на самом деле — монадическое значение, известное также под названием «монадическое действие»), которая считывает строку текста с консоли и каким-то образом ее возвращает. putStrLn — это функция (на сей раз — действительно функция), которая принимает строку как аргумент и печатает ее в консоль, добавляя символ конца строки.
Задумайтесь на секунду о том, как бы типы этих функции выглядели в традиционных языках. Можно предположить что-то подобное:
Функцию getLine понять просто: она принимает пустое значение (неважно какое), каким-то образом взаимодействует с консолью, выуживая оттуда строку, и возвращает эту строку. putStrLn принимает строку как аргумент, как-то взаимодействует с консолью (печатая строку), и возвращает пустое значение (неважно какое). Обратите внимание, что смысл пустых значений свелся к тому, чтобы убедиться, что функции действительно функции, то есть у них есть входное и выходное значение. Избавившись от (), мы бы остались с:
и это неверно: getLine — не просто строка; она должна быть вызвана с аргументом, чтобы вернуть строку. Точно так же putStrLn — не просто строка; потому что ей нужен аргумент-строка, хотя возвращает она неважно что. В каждом случае пустые значения нужны всего лишь для подстановки на место входного или выходного значения.
Но вернемся в Haskell. У нас есть:
Тип функции putStrLn совсем нетрудно понять. Это просто монадическая функция внутри монады IO. Подразумевается, что она принимает строку для печати, возвращает пустое значение (неважно какое), и делает «что-то еще». (В данном случае взаимодействует с консолью, чтобы напечатать строку, — это то, что монада IO позволяет сделать.)
Тип функции getLine понять сложнее. getLine — монадическое значение. Но нам проще думать о нем как о монадической функции типа () -> IO String. Тогда появляется смысл: это функция, которая принимает неважно какое значение и возвращает строку, в процессе взаимодействуя с консолью (а именно, ждет то, что вы в консоли напечатаете).
Тем не менее, в Haskell для этой функции нет такого типа как () -> IO String, а есть тип IO String. Получается, что монадическое значение — это монадическая функция с неявным входным аргументом типа (). Многие знатоки Haskell воспринимают его как «действие». Когда они говорят, что getLine — это «действие», которое выполняет некую операцию ввода/вывода, они имеют в виду монадическую функцию. Когда в следующих статьях мы будем обсуждать монады состояния, вы глубже осознаете, как что-то, выглядящее как значение, может действовать как функция.
В следующей статье мы поговорим о двух фундаментальных монадических операциях: откуда они взялись и что за ними кроется.
Часть 1: основы
Часть 2: функции >>= и return
Часть 3: Монадные Законы
Часть 4: Монада Maybe и монада списка
{1} В оригинале «подвержена ошибкам» — «prone to bugs», что можно было бы перевести несколько иначе. ;)
{3} Это называется «параметрический полиморфизм».
{4} Автор имеет в виду набор однотипных объектов как результат функций. Проблема, по его мнению, в том, что функции могут вернуть разное количество объектов: от нуля до n, — то есть, количество объектов заранее неизвестно. И в C, и в Java эта проблема эффективно решается динамическими типами данных.
{5} В оригинале — устойчивое выражение: «have our cake and eat it too».
{6} В оригинале — «единичное» значение, unit.
Ссылки на другие материалы у автора я не нашел пока. Приведу свои.
1. Haskell Tutorials Самое полное собрание ссылок на руководства и статьи по Haskell на английском языке.
2. Haskell на xgu.ru — много полезных ссылок.
3. Russian Lambda Planet — отличный источник информации по ФП на русском.
4. Haskell Planet — еще более отличный источник информации по Haskell и ФП, на английском.
В сообществе любителей Haskell прижилась шутка, что каждый Haskell-программист должен в процессе своего обучения написать одно или несколько руководств по монадам. И я — не исключение. Но я знаю, что существует очень много руководств по этой теме, многие из них хороши, — так зачем мне писать Еще Одно? Две причины:
- Я думаю, что могу объяснить некоторые стороны монад лучше, чем многие другие руководства, которые я видел.
- Я стал гораздо лучше понимать монады, чем теперь и хочу поделиться по мере сил и возможностей.
Предварительные требования
Так как я буду писать примеры на Haskell, для вас, читатель, было бы полезно знать его, включая такие разделы, как полиморфизм и классы типов. Без этих знаний материал будет сложен для понимания. Уже написаны десятки вводных руководств по Haskell, которые стоит прочитать неподготовленному читателю, и потом вернуться к серии этих статей.
А вот знать теорию категорий, очень абстрактную ветвь математики, я не требую, хоть в ней и описывается теория монад (в терминах данной статьи). Конечно, знание теории категорий не навредит, но это не обязательно, чтобы понять представленный материал. Я не верю тем, кто говорит, что вам необходима теория категорий перед изучением монад в приложении к языкам программирования, — это не так. Если вы ее изучали, — хорошо, но я не вижу преимуществ в том, чтобы использовать терминологию оттуда.
Отказ от...
Я не собираюсь учить вас всему, что есть в мире монад по двум причинам. Первая: это было бы очень долго, и вторая: я не знаю всего и, вероятно, никогда не узнаю. Я хочу дать вам понимание монад на чистом концептуальном уровне, а также почему они полезны, как с ними работать, и какие общие монады чаще всего используются. По ссылкам в конце этой серии статей вы можете изучить монады более глубоко.
Еще не ждите тонн кода, которые вы бы могли сразу использовать в своей повседневной работе. Это же не «книга готовых рецептов»! Я очень верю, что вам нужно понимать, что происходит, когда вы программируете с использованием монад, и для их детального объяснения было написано это руководство. Вместе с ним вы можете читать и другие руководства (смотрите в ссылках), чтобы найти лучшие монадные решения практических проблем, а моя цель — обрисовать общую картину и помочь вам действительно понимать монады и их работу.
Наконец, я вас уведомляю, что буду повторять и повторять главные мысли, пока они не вопьются в вас насмерть, потому что мне хочется, чтобы вы полностью поняли, что я пытаюсь сказать. Надеюсь, это не будет скучно, — хотя будет долго, потому что монады в нескольких предложениях не объяснишь. Сварите себе чашку кофе и убедитесь, что ваш стул удобен, — понимание займет время.
Мотивация: почему вам следует думать о монадах?
Насколько мне известно, впервые монады были использованы в Haskell, основанные на работах Eugenio Moggi и Philip Wadler (два гиганта, с которыми мне не сравниться). С тех пор они появились в других языках, особенно в функциональных. Но почему же вам, читателю (предположительно программисту, не попробовавшему наркотик функционального программирования), беспокоиться о монадах?
Главная идея функционального программирования — использовать чистые функции как можно шире. Чистая функция — это черный ящик. Все что она делает, — это принимает один или несколько аргументов, что-то вычисляет и возвращает результат. Она не оперирует никакими побочными эффектами. Никаких вам чтений-записей в файлы и соккеты, никакой печати в консоль, никакого изменения глобальных переменных, никакой обработки исключений, и так далее. Преимущество здесь в том, что поведение чистой функции строго определено: на одни и те же аргументы она всегда вернет одно и то же значение. Чистая функция более предсказуема, проще тестируется, и менее подвержена ошибкам. ({1}) Для сравнения, нечистая функция (у которой есть побочные эффекты) не обязательно вычислит тот же результат при нескольких одинаковых вызовах. Например, ответ может стать другим, если у задействованной глобальной переменной изменится значение, или если в считываемом файле окажется разное содержание. Нечистые функции труднее тестируются, подвержены многим ошибкам, и есть множество ситуаций, когда функции завершаются сбоем. По этим причинам функциональные языки программирования побуждают писать чистые функции, когда это только возможно.
Однако, программы в чистых функциях слишком ограничены. Есть случаи, когда программы пишутся проще с использованием побочных эффектов, хотя их и можно написать (с мучениями) только в чистых функциях. А в некоторых других случаях без побочных эффектов обойтись вообще нельзя. Например, программа, которая копирует файл из одной папки в другую, взаимодействует с файловой системой и изменяет ее; если вашим чистым функциям не позволено читать и писать файлы (а это и есть побочные эффекты), то они и не смогут решить эту задачу. Так что нам нужны способы работать с побочными эффектами даже в функциональных языках.
Функциональные языки бывают двух видов: чистые и нечистые. Нечистые ФЯ (Scheme, OCaml) не заботятся об этой проблеме: они просто позволяют писать любые функции с побочными эффектами, хотя программисты нечистых ФЯ обычно избегают этого без особой на то необходимости. Чистые ФЯ (такие как Haskell) более хардкорны: они вообще запрещают писать непосредственно функции с побочными эффектами (вы скоро узнаете, почему я написал «непосредственно»). Поэтому, как вы можете себе представить, тема о побочных эффектах в чистых языках программирования была одним из главных направлений в исследованиях долгое время.
Монады оказались ключом к решению этой проблемы. (Точнее, одним из ключей; в некоторых других ФЯ изобретены иные подходы; «Clean's uniqueness type» как вариант.) С помощью монад можно использовать вычисления с побочными эффектами без нарушения чистоты языка. Монады и система типов позволяют нам отделить вычисления с побочными эффектами от других вычислений, и они не будут мешать друг другу. Мы получаем все преимущества кода без побочных эффектов, причем это гарантирует нам система типов. В то же время мы можем выполнять побочные эффекты по необходимости. И это очень мощная концепция.
И как будто бы этого было недостаточно, — выяснилось, что у монад есть много других применений, не только обуздание побочных эффектов. Монады — очень универсальный инструмент, с помощью которого можно организовать различные виды вычислений с прозрачным поведением. Некоторые программы при этом резко упрощаются. Во многих случаях монадический код получается короче и понятнее, чем его немонадический аналог; мы разберем примеры этого явления. В общем, монады полезны и за пределами побочных эффектов в функциональных языках.
Монады — это одна из удивительных идей в теории языков программирования, и их стоит изучать.
Определение: что такое монады?
Монады — это обобщение над функциями, над применением функций и над композицией функции, с помощью которого абстрагируется само понятие вычислений в сравнении со стандартными функциями.
В процессе я надеюсь объяснить вам не только сами монады и как они работают, но так же и то, почему они так обескураживают программистов, не встречавшихся с ними ранее. (Подсказка: это не потому, что программисты недостаточно умны или не знают теории категорий.)
Понятие вычислений
Ну хорошо, давайте начнем разбор моего определения с выражения «понятие вычислений».
Самое простое и самое предсказуемое вычисление — это обычные (чистые) функции (то есть, математическое определение функций). Для простоты я буду рассматривать функции, отображающие один входной аргумент на один выходной. (Есть возможность свести многоаргументную функцию к функции с одним аргументом с помощью процедуры каррирования, и я должен буду рассказать об этом больше, — но позже. Сейчас просто воспринимайте мои слова как данность.) Как я и говорил ранее, для чистой функции должно выполняться правило: она должна всегда возвращать одинаковые результаты на один и тот же входной параметр. В сильно типизированных языках вроде Haskell у функции есть определение типа, которое всегда означает, что для типов a и b функция отображает значение типа a на значение типа b. Вот как это выглядит в Haskell:
f :: a -> b
Здесь двойное двоеточие "::" значит «имеет следующий тип». Таким образом функция f имеет функциональный тип a -> b, и это значит, что функция берет значение типа a и возвращает значение типа b. На практике вместо a и b обычно стоят конкретные типы: Int, Float, String…, но в Haskell функции также могут работать независимо от типов аргументов. ({3})
Итак, чистые функции — это самое простое «понятие вычисления». А какие вычисления существуют еще? Их много, и со многими вы знакомы; сюда входят вычисления, которые:
- работают с вводом/выводом (файлы, консоль);
- вызывают исключения;
- изменяют какое-то общее состояние (глобальное, локальное);
- иногда могут завершиться ошибкой;
- возвращают сразу много результатов;
- и многие другие.
Заметьте: я использовал фразу «Ввод/Вывод», или сокращенно, I/O, чтобы обозначить ввод/вывод при работе с файлом или консолью. Известно, что операции I/O несут в себе побочные эффекты. Не путайте операцию ввода/вывода с входным и выходным значением функции.
Подумайте секунду о том, как вы бы работали с этими вычислениями в обычных языках программирования — в С или Java. Вычисления с операциями ввода/вывода? Нет проблем! Любая функция С и Java это может. А как насчет вызова исключений? В С это немного сложно, так как там нет языковой поддержки исключений, но зато можно вернуть код ошибки в случае сбоя. (Или вы можете обрабатывать ошибки вообще с помощью setjmp/longjmp, если вы матерый низкоуровневый программист.) В Java вы просто вызываете исключение в надежде, что оно где-то обрабатывается. Кроме исключений есть еще состояние, — как работать с ним? Да в общем-то просто: и в С, и в Java вы можете читать и писать переменные, глобальные и локальные, по-разному. А вычисления, которые могут провалиться? Их можно рассматривать как вырожденный случай исключений, так что опять никаких проблем. Наконец, как быть с вычислениями, возвращающими много значений? Здесь под множеством значений я имею в виду не один объект, содержащий в себе кучу результатов — не структуру C и не объект Java, — я говорю о функциях, которые могут вернуть несколько отдельных результатов «параллельно». Не совсем понятно, как это сделать в C или Java. ({4})
Важно отметить следующее: во всех случаях мы уже не говорим о традиционном понятии вычислений, так как помимо обычного отображения входного параметра на выходной «где-то еще» происходит «что-то еще». Кроме того, есть и другие виды «чего-то еще», со своими понятиями вычислений. Мы обычно не беспокоимся об этом, когда пишем программы; мы просто понимаем, что наши «функции» не совсем то же самое что и функции в математическом смысле. Ведь в них есть побочные эффекты ввода/вывода, исключения, изменение глобальных переменных и так далее. Для большинства программистов это и не важно, — пока они не схватят неприятную ошибку, из-за, оказывается, изменившейся глобальной переменной, или пока программа внезапно не остановится с исключением, или пока не возникнет еще какая-нибудь проблема, вызванная нефункциональной природой всех этих «функций». Таким образом нам бы хотелось использовать чистые функции как можно больше. Нам бы хотелось, — но есть случаи, когда это невозможно, и приходится делать «что-то еще», то есть, вычисления с побочными эффектами.
Вывод один: мы хотим сидеть на двух стульях. ({5}) Мы бы хотели писать код в чистых функциях везде, где это только возможно, получая все выгоды этого: проще отладка, верификация… Но также нам бы хотелось работать с тем самым «чем-то еще» контролируемым способом, ибо нет выхода или так лучше в конкретной ситуации. И это то, что монады нам позволяют.
НО! Ключевая фраза прошлого параграфа — это «контролируемым способом». Если бы этот механизм работал так же, как в C или Java, мы бы, конечно, решили свои задачи с помощью многих этих нефункциональных вычислений, однако же и потеряли бы при этом преимущества функционального программирования. Ведь у нас не было бы никаких гарантий, что функции — чистые, даже проверка типов тут не помогла бы. Необходим какой-то системный подход к работе с другими понятиями вычислений, который бы не нарушал чистоты кода.
Сейчас мы рассмотрим полезные понятия (чистых) функций, (чистого) применения функций и (чистой) композиции функций, а дальше сравним это с монадическим способом, реализующим те же цели.
Функции, применение (аппликация) функций и композиция функций
Чуть ранее я упоминал, что в Haskell используется специальная запись для определения типов входных и выходных параметров функций. Для функции f, у которой входной тип a и выходной тип b, запись будет выглядеть так:
f :: a -> b
Таким образом, f имеет тип a -> b (читается как «из a в b»). Вот более специфичный пример функции, которая удваивает входное значение:
f :: Int -> Int
f x = 2 * x
f имеет тип Int -> Int, потому что принимает целое, умножает его на два и возвращает другое целое.
Выполнить функцию просто, для этого мы применяем ее к аргументу (мы предполагаем, что у нее один аргумент). Обычно это делается приставлением аргумента к функции:
f 2 -- у функции "f 2" значение = 4.
Заметьте, что в языке Haskell аргументы не оборачиваются в скобки, как во многих других языках программирования.
Каррирование
На практике одноаргументных функций недостаточно для многих задач. Как нам определить двухаргументную функцию? Как нам, например, записать функцию q, которая берет два целочисленных аргумента и возвращает сумму их квадратов? Тело функции записать просто:
q x y = x * x + y * y
Сигнатура типа функции опущена. Возможно, вы ожидаете какой-то такой вариант:
q :: Int Int -> Int
или, возможно, такой:
q :: (Int, Int) -> Int
На самом деле тип этой функции выглядит так:
q :: Int -> Int -> Int
Стрелка "->" правоассоциативна, так что запись значит следующее:
q :: Int -> (Int -> Int)
Теперь это выглядит интересно. Функция двух аргументов, которая в Haskell становится функцией одного аргумента (x в нашем случае), возвращает другую функцию одного аргумента, которая в свою очередь принимает следующий аргумент (y) и возвращает результат. И это правильно, потому что в Haskell, как и в других ФЯ, функции могут быть возвращены как значения других функций. (Иными словами, функции — это просто другой тип данных в ФЯ.) Этот способ представления многоаргументных функций как одноаргументных, называется каррированием (в честь Хаскелля Карри, чьим именем так же назван язык Haskell. Каррирование независимо открыто ученым Шейнфинкелем, так что можете называть эту процедуру и так, если хотите). Для пояснения возьмем функцию r с четырьмя целочисленными аргументами w, x, y и z, которая возвращает целое число.
r :: Int -> Int -> Int -> Int -> Int
r w x y z = ... -- некоторая функция от w, x, y, и z
Правоассоциативная стрелка дает:
r :: Int -> (Int -> (Int -> (Int -> Int)))
r w x y z = ... -- некоторая функция от w, x, y, и z
где r — это функция одного целочисленного аргумента w, которая возвращает функцию типа (Int -> (Int -> (Int -> Int))). Та функция, когда применяется к целому (x в нашем примере), возвращает функцию типа (Int -> (Int -> Int)). Очередная функция, будучи применена к целому (y в примере), возвращает функцию типа (Int -> Int), которая, в свою очередь, когда применена к еще одному целому (z), возвращает целое число — результат вызова (r w x y z), который, на самом деле, ((((r w) x) y) z). И это называется каррированием. Haskell автоматически каррирует функции. Карринг оказывается очень удобным, потому что вы можете передавать аргументы по одному, а не все сразу, и эти частично примененные функции часто весьма полезны сами по себе. А также каррирование концептуально полезно нам тем, что с этого момента нам достаточно думать о функциях одного аргумента, и не более того. Прекрасно!
В Haskell существует специальный оператор $, это оператор применения функции. У него следующий тип:
($) :: (a -> b) -> a -> b
(В Haskell символьные инфиксные операторы эквивалентны функциям с тем же именем, заключенным в круглые скобки. Так, запись f $ 2 эквивалентна записи ($) f 2. Операторы обычно определяются в их функциональной форме — для удобства. Обратитесь к вводным материалам по языку, если хотите узнать больше. Мы будем часто пользоваться операторами здесь.)
Запись значит, что для любых типов a и b этот оператор берет функцию из a в b как первый аргумент, применяет ее ко второму аргументу типа a и возвращает результат типа b. В функциональных языках считается обычным делом передавать функции в качестве аргументов других функций, так что тут нет никаких проблем. Можно сделать следующие выводы:
f 2 --> вернет 4
f $ 2 --> тоже вернет 4
($) f 2 --> и здесь вернет 4
Вы видите просто три разных способа записать одно и то же.
Оператор $ не особо здесь нужен, потому что технически проще подставить аргумент к функции, чтобы ее выполнить. Но ради интереса мы можем задать оператор «обратного применения», назовем его >$>, и пусть он принимает те же аргументы в обратном порядке:
(>$>) :: a -> (a -> b) -> b
x >$> f = f x -- = то же самое, что и f $ x
Можем читать это как «оператор берет значение x, применяет функцию к нему и возвращает результат». Если вы знакомы с UNIX-системами, вы могли заметить, что юниксовый конвейер (пайп, |) работает сходным образом. Вы передаете ему некоторые данные, а он применяет к ним идущую следом программу. Мы можем работать с операторами применения функции когда удобно, хотя обычно мы вообще их не используем, просто подставляем аргументы к функциям.
Теперь, когда мы поговорили о применении функций, следующая важная тема — это композиция функций. И это по-настоящему важная тема. Предположим, что у нас есть две функции f и g, а также значение x следующего вида:
x :: a
f :: a -> b
g :: b -> c
где a, b, c — некоторые типы. Вы могли бы сделать с этими x, f и g следующее: взять x, применить к ней функцию f (получив бы значение типа b), и затем к результату применить функцию g. Значение x типа a преобразовалось бы к значению типа b, а затем то, что получилось, было бы преобразовано к значению типа c. Записать на Haskell это проще, чем сказать:
g (f x)
Но работать это будет только в том случае, если типы f и g совместимы, то есть, если результат функции f имеет тот же тип, что и у аргумента функции g (в нашем случае это тип b). Применение одной функции к другой можно трактовать и другим способом: мы берем две функции f и g типов, соответственно, a -> b и b -> c, и создаем третью функцию типа a -> c. Применяя ее к аргументу x, мы получим результат типа c. Эта идея с объединением двух функций в третью называется композицией функций. В Haskell даже определен простой оператор композиции функций:
(.) :: (b -> c) -> (a -> b) -> (a -> c)
g . f = \x -> g (f x)
Здесь использована запись "\x -> ...", которая обозначает лямбда-выражение (или, то же самое, анонимную функцию) с одним аргументом x. Вот так оператор композиции берет две функции в качестве аргументов и возвращает третью. И снова: в ФЯ функции как аргументы и как возвращаемые значения — это вполне обычное явление, которое встречается на каждом шагу.
Иногда случается неприятность с оператором композиции, когда функции следуют не в том порядке. Но мы можем написать «оператор обратной композиции» >.>:
(>.>) :: (a -> b) -> (b -> c) -> (a -> c)
f >.> g = \x -> g (f x)
Мы даже можем выразить его через оператор обратного применения функции >$>:
(>.>) :: (a -> b) -> (b -> c) -> (a -> c)
f >.> g = \x -> x >$> f >$> g
Или еще проще — через оператор композиции:
(>.>) :: (a -> b) -> (b -> c) -> (a -> c)
f >.> g = g . f
Сигнатура оператора >.> немного яснее и показывает, что происходит при композиции функций. Вы берете функции f и g и вычисляете новую функцию. Пусть она зовется h. Применяя h к значению, вы получите то же самое, если будете применять к значению сначала f, а потом к результату — g. Вот что такое композиция функции — способ из одних функций сделать другие.
Разберем пример:
f :: Int -> Int
f x = 2 * x
g :: Int -> Int
g y = 3 + y
h :: Int -> Int
h = g . f -- или то же самое: f >.> g
Чем здесь занимается функция h? Она принимает целое число, умножает его на 2 и прибавляет 3. То есть, она эквивалентна следующему варианту:
h :: Int -> Int
h x = 3 + 2 * x
Композиция функций может показаться не таким уж великим делом, — в реальности же это один из основных пунктов функционального программирования. Композиция позволяет связывать существующие функции в более сложные функции, опуская ручную работу с аргументами. И вместо того чтобы говорить «h — это функция, которая получается сначала вычислением функции y = f(x), а затем вычислением функции h = g(y)», мы просто говорим «h — это функция, которую мы получаем, применяя сначала f а затем g». Без промежуточных сущностей код становится более кратким и высокоуровневым. Представьте себе, что вам потребовалось вызвать десять функций одну за другой. Если бы вы записывали промежуточные результаты, это вылилось бы в что-то подобное:
f11 x =
let
x2 = f1 x
x3 = f2 x2
x4 = f3 x3
x5 = f4 x4
x6 = f5 x5
x7 = f6 x6
x8 = f7 x7
x9 = f8 x8
x10 = f9 x9
x11 = f10 x10
in
x11
Весьма утомительно, правда? А теперь посмотрим на композицию функций:
f11 = f10 . f9 . f8 . f7 . f6 . f5 . f4 . f3 . f2 . f1
или, то же самое:
f11 = f1 >.> f2 >.> f3 >.> f4 >.> f5 >.> f6 >.> f7 >.> f8 >.> f9 >.> f10
Это не только короче, но и более интуитивно. («Применяя f1, затем f2, затем f3 и так далее, мы получим f11»). Кстати, этот способ записи функций с использованием композиции и без аргументов, называется «бесточечным стилем». Ирония в том, что в «бесточечном стиле» оператор «точка» (.) очень даже используется, — сильнее, чем в обычном коде. Тут правильнее было бы сказать «безаргументный стиль», а не «бесточечный», так как мы опускаем аргументы функций.
Темы размышлений, закрепляющих материал:
- Функции, применение (аппликация) функций, композиция функций как фундаментальные концепции функционального программирования.
- Операторы для применения функций, для композиции функций, принимающие аргументы в любом порядке, какой мы захотим.
Монадические функции, монадические значения
Пока все, что я рассказал, надеюсь, было довольно простым. Теперь мы переходим к более сложным вещам.
Ранее я сказал, что суть монад в том, чтобы обобщить понятие композиции и применения функций в виде вычислений, которые отличаются от вычислений в чистых функциях, и мы даже рассмотрели некоторые примеры «нечистоты». Из определения монад следует, что мы получаем некие «расширенные функции», которые делают что-то еще, помимо простого вычисления над входным значением. На схематическом псевдо-Haskell-языке мы могли бы записать эти «расширенные функции» так:
f :: a --[что-то еще]--> b
где f — расширенная функция, a — тип аргумента, b — тип результата, а «что-то еще» специфично для разных понятий вычислений. В Haskell за словами «понятие вычислений» кроются, в частности, и монады. (Мы еще не знаем, что это такое, так что пока поверьте мне на слово.) Мы можем понимать «расширенные функции» как «монадические функции». Это не стандартная терминология, я их так зову, чтобы отличить их от обычных чистых функций.
Конечно, запись "--[something else]-->" невалидна в Haskell; чуть позже мы посмотрим, как это выглядит на самом деле, и я надеюсь, что это будет понятно. А сейчас мы будем придерживаться этих обозначений, чтобы сравнить описанные выше понятия вычислений; мы дадим каждому понятию вычислений имена, соответствующие монадам в Haskell.
- Функции, производящие операции ввода/вывода в консоль или файл. Операции I/O соответствуют монаде IO, так что запишем это так:
f :: a --[IO]--> b
(Кстати, у монады IO есть и другие применения, как мы увидим позже.) - Функции, способные генерировать исключения. Им соответствует несколько видов монад:
f :: a --[error]--> b
- Функции, взаимодействующие с глобальным или локальным состоянием. Это монада State:
f :: a --[State s]--> b
- Функции, способные завершиться с ошибкой. Мы говорим о монаде Maybe:
f :: a --[Maybe]--> b
- Функции, возвращающие одновременно несколько значений. Монада list (список):
f :: a --[list]--> b
Я написал слово «list» с маленькой буквы, потому что списки в Haskell выглядят несколько иначе благодаря синтаксическому сахару, так что нам не нужно отдельное слово для них.
Позже я приведу примеры для всех этих монад, а сейчас рассмотрим функции, выполняющие операции ввода/вывода, то есть функции, относящиеся к монаде IO. У нас есть псевдозапись:
f :: a --[IO]--> b
Можно было бы сказать, что f — это функция из a в b, действующая в монаде IO. Как я упоминал выше, это невалидный синтаксис. В Haskell вы должны обернуть «монадность» монадической функции в тип, окружив им входной или выходной параметр. В принципе, получилось бы два варианта записи монадной функции, вот таких:
f :: IO a -> b
или
f :: a -> IO b
Оказывается, в Haskell используется вторая форма записи для монадических функций:
f :: a -> m b
для любой монады m; для IO, например. (Для хардкорщиков замечу, что существует понятие комонад, где каждая функция имеет вид f :: c a -> b для некоторой комонады c. Оставим этот вопрос для будущих статей.)
Ну хорошо, что же на самом деле кроется за записью «f :: a -> m b»? Запись значит, что есть некая обычная (чистая) функция f, которая принимает значение типа a и возвращает значение типа m b (чем бы они ни были). Значит, в Haskell монадные функции являются чистыми функциями с монадическим типом возвращаемого параметра. Иначе говоря, чистая функция принимает обычное значение и возвращает монадное. И что же это значит?
Запись «m b» нуждается в пояснении. b — Это некоторый тип. m представляет некоторую монаду. Однако что конкретно понимается под m в Haskell? В Haskell «m» обязан быть конструктором типа — специальной функцией на типах: она берет аргумент и возвращает тип. Это не так странно, как может показаться. Рассмотрим понятие «список целых чисел», тип которого в Haskell выглядит как [Int]. Часть «список чего-то» можно понимать как конструктор типа, который берет некий тип (Int) и возвращает другой тип (список целых, [Int]). Квадратные скобочки жестко зашиты в Haskell для обозначения списков, но вы можете определить собственные конструкторы типов. Также свой конструктор есть у любого полиморфного типа. Один из самых простых полиморфных типов — это Maybe, определенный как
data Maybe a = Nothing | Just a
Здесь написано, что Maybe — это конструктор типа, который принимает тип (называемый a) и производит новый тип как выходное значение. Если подставить вместо a тип Int, получим новый тип Maybe Int, что записывается как:
data Maybe Int = Nothing | Just Int
Таким образом, Maybe — это функция на типах, которая отображает один тип в другой.
Монады, как они есть в Haskell, — это конструкторы типов, производящие новый тип оборачиванием вокруг старого. И монада IO, фактически, является конструктором типа, с помощью которого производятся такие типы как IO Bool, IO Int, IO Float, IO Char, IO String, и так далее. Это все валидные типы в Haskell. Сходным образом для монады Maybe конструируются валидные типы Maybe Bool, Maybe Int,…. Я буду называть типы, созданные монадным конструктором, «монадными типами». IO Bool, Maybe Int, и так далее — все это монадные типы.
Заметка на полях: все монады в Haskell должны быть конструкторами типов, но не все конструкторы типов являются монадами. Как мы еще увидим, монады обязаны быть конструкторами типов. Для монад должны быть определены особые операции, и они должны удовлетворять нескольким «монадным законам».
Мы подошли к очень важному вопросу: чем занимаются значения, представляющие монадический тип? Я их называю «монадическими значениями». Например, что представляет собой значение типа Maybe Int? А IO Float — что это такое?
Мы только что столкнулись с тем, из-за чего монады кажутся «трудными для понимания».
Давайте резюмируем.
- Существует знакомое вам понятие «чистая функция», то есть такая функция, которая не делает ничего, а только преобразует входное значение одного типа в выходное значение другого типа (а может быть, того же самого).
- Обозначены некоторые особые функции, которые делают что-то еще помимо преобразования одних значений в другие. Это «что-то еще» позволяет оперировать вводом/выводом с файлами или консолью, генерировать исключения, взаимодействовать с глобальным или локальным состоянием, оно может вернуть результат или завершиться с ошибкой, или даже вернуть много результатов. Все эти особые функции представлены монадами, и я называю их «монадическими функциями». Понятие монадической функции должно быть достаточно интуитивным, потому что каждый программист постоянно с ними работает, не подозревая об этом.
- В Haskell монадические функции — это чистые функции, которые преобразуют входное значение какого-то типа в выходное значение специального монадического типа. Я называю эти значения «монадическими».
Теперь переформулируем вопрос: а что мы можем сказать о сущности «монадических значений»?
Ответ таков: Они не представляют собой ничего реально интуитивного!. Интуитивно понятие монадической функции (той, которая делает что-то еще кроме конвертирования одних данных в другие). Концепция «монадического значения» вовсе не интуитивна. Просто в Haskell так принято обозначать выходные значения монадических функций. Вы потратите время зря, если попытаетесь понять монады через то, что такое в действительности эти монадические значения. Не утруждайте себя! Не стоит!
Тем не менее, в литературе по Haskell вы можете обнаружить два общих способа объяснить монадические значения (ну и кучу глупых способов, которыми грешат многие руководства):
1. Монадическое значение типа m a (для некоторой монады m) — это особый вид «действия», которое что-то выполняет и возвращает значение типа a. Суть действия зависит от каждой конкретной монады.
2. Монадическое значение типа m a (для некоторой монады m) — это такой контейнер, в котором хранится значение типа a.
Изучать монады через размышления о монадических значениях — это неверный подход, а верный — через размышления о монадических функциях. Я попробую убедить вас, что в определении (1) даже есть какой-то смысл. А вот определение (2), как мы увидим далее, — это неверный путь изучать монады. Большая часть монад вовсе не контейнеры, хотя некоторые могут вести себя и как контейнеры тоже.
Давайте возьмем нашу функцию, надеюсь достаточно понятную, в качестве отправной точки:
f :: a -> m b
Тогда функция f x, где x типа a, будет иметь тип m b:
x :: a
f x :: m b
f x теперь «монадическое значение», которое не совсем интуитивное. Рассмотрим еще одну функцию:
g :: a -> () -> a
g x () = x
g делает буквально следующее: она берет значение любого типа a и оборачивает его в функцию, так что вы можете получить результат, передав в g пустое значение. ({6}) Пустой тип и значение записываются в Haskell одинаково в виде скобок (), и это просто тип/значение, которое для нас не важно. (Слово «пустой» значит, что это значение не представляет для нас никакого интереса.) Приведем пример:
h = g 10
h () -- будет вычислено число 10
Теперь, что мы получим, придумав функцию g (f x)? Посмотрим на типы:
f x :: m b -- смотрите выше
g :: a -> () -> a
g (f x) :: () -> m b
Таким образом, функция g (f x) имеет тип () -> m b. Другими словами, она берет пустое значение и возвращает монадическое значение. А если посмотреть с другой стороны, это монадическая функция, которая преобразует пустое значение (неважно, какое) в значение типа b, одновременно выполняя «что-то еще». («Что-то еще» зависит от того, какая монада используется.) В этом есть какой-то смысл.
Вот моя мысль. Если вы считаете, что нужно понять, что такое монадическое значение (типа m b), лучше всего считать его монадической функцией типа () -> m b, то есть функцией, которая не только отображает пустое значение в значение типа b, но и выполняет что-то еще. Как будто значение типа m b и есть функция типа () -> m b, только по-другому написанная. Так сказать, монадические значения — это «тайные функции». Потому-то они часто обзываются «действиями», и ассоциируются с функциями, да не совсем функциями. (Иногда мы даже говорим «выполнить действие», что сходно с применением функции.)
Несколько примеров сейчас не повредят. Я воспользуюсь двумя функциями ввода/вывода в Haskell:
getLine :: IO String
putStrLn :: String -> IO ()
getLine — это «функция» (на самом деле — монадическое значение, известное также под названием «монадическое действие»), которая считывает строку текста с консоли и каким-то образом ее возвращает. putStrLn — это функция (на сей раз — действительно функция), которая принимает строку как аргумент и печатает ее в консоль, добавляя символ конца строки.
Задумайтесь на секунду о том, как бы типы этих функции выглядели в традиционных языках. Можно предположить что-то подобное:
getLine :: () -> String -- не в Haskell
putStrLn :: String -> () -- не в Haskell
Функцию getLine понять просто: она принимает пустое значение (неважно какое), каким-то образом взаимодействует с консолью, выуживая оттуда строку, и возвращает эту строку. putStrLn принимает строку как аргумент, как-то взаимодействует с консолью (печатая строку), и возвращает пустое значение (неважно какое). Обратите внимание, что смысл пустых значений свелся к тому, чтобы убедиться, что функции действительно функции, то есть у них есть входное и выходное значение. Избавившись от (), мы бы остались с:
getLine :: String
putStrLn :: String
и это неверно: getLine — не просто строка; она должна быть вызвана с аргументом, чтобы вернуть строку. Точно так же putStrLn — не просто строка; потому что ей нужен аргумент-строка, хотя возвращает она неважно что. В каждом случае пустые значения нужны всего лишь для подстановки на место входного или выходного значения.
Но вернемся в Haskell. У нас есть:
getLine :: IO String
putStrLn :: String -> IO ()
Тип функции putStrLn совсем нетрудно понять. Это просто монадическая функция внутри монады IO. Подразумевается, что она принимает строку для печати, возвращает пустое значение (неважно какое), и делает «что-то еще». (В данном случае взаимодействует с консолью, чтобы напечатать строку, — это то, что монада IO позволяет сделать.)
Тип функции getLine понять сложнее. getLine — монадическое значение. Но нам проще думать о нем как о монадической функции типа () -> IO String. Тогда появляется смысл: это функция, которая принимает неважно какое значение и возвращает строку, в процессе взаимодействуя с консолью (а именно, ждет то, что вы в консоли напечатаете).
Тем не менее, в Haskell для этой функции нет такого типа как () -> IO String, а есть тип IO String. Получается, что монадическое значение — это монадическая функция с неявным входным аргументом типа (). Многие знатоки Haskell воспринимают его как «действие». Когда они говорят, что getLine — это «действие», которое выполняет некую операцию ввода/вывода, они имеют в виду монадическую функцию. Когда в следующих статьях мы будем обсуждать монады состояния, вы глубже осознаете, как что-то, выглядящее как значение, может действовать как функция.
В следующей статье мы поговорим о двух фундаментальных монадических операциях: откуда они взялись и что за ними кроется.
Содержание
Часть 1: основы
Часть 2: функции >>= и return
Часть 3: Монадные Законы
Часть 4: Монада Maybe и монада списка
Примечания
{1} В оригинале «подвержена ошибкам» — «prone to bugs», что можно было бы перевести несколько иначе. ;)
{3} Это называется «параметрический полиморфизм».
{4} Автор имеет в виду набор однотипных объектов как результат функций. Проблема, по его мнению, в том, что функции могут вернуть разное количество объектов: от нуля до n, — то есть, количество объектов заранее неизвестно. И в C, и в Java эта проблема эффективно решается динамическими типами данных.
{5} В оригинале — устойчивое выражение: «have our cake and eat it too».
{6} В оригинале — «единичное» значение, unit.
От переводчика
Ссылки на другие материалы у автора я не нашел пока. Приведу свои.
1. Haskell Tutorials Самое полное собрание ссылок на руководства и статьи по Haskell на английском языке.
2. Haskell на xgu.ru — много полезных ссылок.
3. Russian Lambda Planet — отличный источник информации по ФП на русском.
4. Haskell Planet — еще более отличный источник информации по Haskell и ФП, на английском.