Запах монад по утрам

Feb 15, 2016 16:37

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

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

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

Но, несмотря на всё это, означенные монады действительно имеют практически полезное применение, отличное от универсального способа перевести любой практический вопрос в убедительную (в основном, для самого́ отвечающего) демонстрацию его запредельной крутизны. Собственно, когда основная цель в этом, ответ специально строится так, чтобы непонимающий и дальше бы тоже ничего не понял.

Грубо говоря, это как если вы просите научить вас водить автомобиль, а вам вместо этого дают абсолютно точное определение понятия «транспортное средство», включающее в себя всё - от самоката до самолёта, и от ишака до гоночного болида. Ну а потом чуть-чуть затрагивают тему максимально точного определения универсального понятия «вождение».

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

Собственно, именно так я сейчас поступлю с монадой.

Проще всего представлять себе это понятие банально как контейнер. Не какой-то конкретный, а просто любой контейнер, коих вы наверно видели довольно много. Ну там, коллекции ArrayList и LinkedList, например. Да, чего там, даже просто массив сойдёт - если сделать внешние утилиты для его обработки. Сойдёт и просто обёртка для ссылки на объект: это - как бы контейнер, в котором может быть только один элемент.

Поэтому так и сделаем: будем представлять себе контейнер.

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

Кроме того, должна быть определена и ещё одна операция: bind (связывание). Понять, почему оно именно «связывание», а не что-то другое, так сразу не получится. Тем более, в ряде реализаций оно ни фига не «связывание» на самом деле, а более широкая операция.

Однако ничего особо эзотерического в этой операции нет, и я сейчас объясню, что она делает. Мне, понятное дело, ближе Scala, поэтому я примеры буду приводить на ней. Но чисто технически сейчас всё это возможно реализовать на любом языке, в котором уже есть ссылки на функции. А они сейчас есть уже почти везде - в C#, Java и даже уже в С++.

В Scala эта операция называется flatMap (в Java 8, кстати, тоже). Но чтобы было проще понять её физический смысл, рассмотрим сначала операцию, которая чуть проще: map.

Есть у нас, положим, список с элементами 1, 2 и 3. При помощи метода map этого списка мы можем над каждым элементом произвести какую-то операцию и получить новый список, в котором лежат преобразованные данной операцией элементы. Например, мы можем умножить каждый элемент списка целых чисел на два.

val list = List( 1, 2, 3 )

val newList = list.map( x => x * 2 )

// List(2, 4, 6)

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

Нам тут важно, что на выходе получается контейнер того же класса: был List - новый тоже будет List. Был бы Array - новый тоже был бы Array-ем. И так далее.

При этом тип значения в новом контейнере может поменяться. То есть, мы могли бы не умножать на 2 элементы контейнера, а, например, превращать их в строки. И у нас тогда из List[Int] получился бы List[String].

val list = List( 1, 2, 3 )

val newList = list.map(x => x.toString)

// List("1", "2", "3")

Теперь перейдём от map ко flatMap. По своей сути это - та же операция, но с одной оговоркой: та функция, которой мы описываем преобразование аргумента, должна возвращать не просто какой угодно результат преобразования этого аргумента, а такой же контейнер, как и тот, у которого мы вызывали flatMap.

Ну, это в теории так. А на практике обычно требуется, чтобы возвращался контейнер, который своди́м к исходному. Например, его наследник или родственник из соседней ветки наследования.

В новый контейнер, создаваемый flatMap, при этом попадает не тот контейнер, который вернула функция преобразования, а то, что в нём лежит.

Звучит всё это громоздко, но на простом примере сразу станет понятно, про что тут.

val list = List( 1, 2, 3 )

val newList = list.flatMap( x => List( x * 2 ) )
// List(2, 4, 6)

val newList2 = list.flatMap( x => List( x, x ) )
// List(1, 1, 2, 2, 3, 3)

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

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

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

val dict = List( "яблоко", "малина", "клубника", "мандарин" )

def findWords( startLetters: String ) =
dict.filter( x => x.startsWith( startLetters ) )

val hints = findWords( "ма" )
// List(малина, мандарин)

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

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

val userLetters = List( "ябл", "ма" )

val allHints = userLetters.flatMap( findWords )
// List(яблоко, малина, мандарин)

В качестве аргумента flatMap была передана функция findWords, которая возвращает список. Она была вызвана для каждого из введённых пользователем сочетаний букв. Из возвращаемых ей списков извлекались элементы и добавлялись в новый контейнер, ссылка на который теперь называется allHints.

Многие спросят, а где тут «связывание»? А его тут, собственно, и нет. Точнее, его сюда можно притянуть за уши, но это только всё запутает.

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

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

Один из знакомых почти всем примеров такового - итератор. Он, конечно, сам по себе контейнером не является, но имеет схожие свойства.

Положим, у итератора тоже есть функция map (а в Scala это действительно так).

val list = List( 1, 2, 3 )

val iterator = list.iterator.map( x => x * 2 )

while ( iterator.hasNext ) {
println( iterator.next( ) )
}
// 2
// 4
// 6

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

Взглянем, что тут произошло.

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

Реальное же умножение на два происходило только тогда, когда у итератора вызывался метод next.

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

def twoTimes( x: Int ) = x * 2

def increment( x: Int ) = x + 1

val iterator = list.iterator.map(twoTimes).map(increment)

while ( iterator.hasNext ) {
println( iterator.next( ) )
}
// 3
// 5
// 7

То есть сначала у нас есть обычный итератор. Потом, при помощи первого map, мы получаем из него итератор, который умножает элементы на 2, а из него, в свою очередь, - итератор, который вдобавок ещё и прибавляет 1.

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

Естественно, всё это работает не только для итераторов, а для чего угодно, что имеет свой метод map. И, соответственно, свой flatMap тоже.

Такие, вот, операции. Довольно простые и весьма понятные. Ну и как, сильно бы вам помогло в объяснении расставленное тут и там слово «монада»?

Однако всё-таки вернёмся к ним самым, к монадам. У монад есть три закона, и если их сформулировать чисто формально, то возникнет ожидаемый эффект: «ни хрена не понятно» и «непонятно нахрена́».

На самом же деле эти законы - всего лишь описание ожидаемых «правил игры». А именно: что должны делать конструктор, flatMap и map, чтобы не вынести мозг тому, кто будет всё это использовать.

  1. Методы flatMap и map должны просто применять переданную им функцию к каждому элементу контейнера, а не делать хрен знает что

  2. При этом метод flatMap должен вынимать элементы из того контейнера, который ему вернёт функция преобразования, перед тем, как положить результат преобразования в новый контейнер

  3. container.flatMap( x => f(x).flatMap( g ) ) должно давать тот же результат, что container.flatMap(f).flatMap(g)

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

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

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

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

Рассмотрим вот такой пример: нам нужен поиск чего-то где-то. Например, первого элемента в некотором списке строк, который удовлетворяет заданным условиям. Сигнатура функции поиска в этом случае будет примерно такая.

def find(list: List[String], criteria: String => Boolean)

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

Но… э-э-э… а что возвращать, если элемент, удовлетворяющий условию, не найден?

null?

Ну, в этом примере оно, конечно, более-менее, однако в общем случае null может считаться корректным значением и лежать в списке (например, в кэше какой-нибудь продвинутой фабрики). Как его отличить от «ничего не найдено»?

А что делать, если поиск идёт среди значений примитивных типов? Для них ведь нулевые ссылки не определены. Завести магическую константу?

И, наконец, как потом обрабатывать результат? Ведь велик соблазн просто вызвать нужный метод у найденного объекта. Позабыв о том, что в качестве ответа может вернуться нулевая ссылка. Ну да, десять раз программист вспомнит о проверке на null, а ну как на одиннадцатый забудет? И компилятор никак на его забывчивость не отреагирует.

И вот тут офигенно в тему оказываются контейнеры с одним или нулём элементов: надо просто результат поиска оборачивать в такой контейнер.

Рассмотрим один из них, называющийся Option. Option имеет два класса-наследника: Some, то есть «какой-то», и None - «ничего». Если мы что-то нашли, то мы возвращаем Some(найденное), если нет - возвращаем None.

val found = Some(100)

val notFound = None

Оба значения в данном случае имеют тип Option[Int]. Точнее, оба с ним совместимы, поскольку None - это вообще-то синглтон, который совместим с Option от чего угодно, ибо внутри него ничего не лежит.

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

Мы получаем ответ в виде Some(что-то) - сработало, или None - не сработало. Указанное «что-то», естественно, можно извлечь из Some. Однако как всё это проделать так, чтобы вышло не очень многословно?

Наиболее очевидный способ обработки такового - некий аналог switch-case (в Scala он называется «match»):

found match {
case Some( x ) => println( "Мы нашли " + x )
case None => println( "А вот йух!" )
}

Конечно, такой вариант менее многословен, чем проверка при помощи instanceOf, приведение типов и так далее, однако он всё равно слишком уж многословен. И это будет особенно заметно, если мы хотим сделать не одну операцию, а несколько. Типа, если первая удалась, то делаем вторую, если вторая - делаем третью. Да, тут без балды получится гирлянда из вложенных друг в друга match-case.

Так вот, не очевидный, но очень лаконичный - без гирлянд - способ запрятан в этих самых «монадических» фишках.

Чтобы понять, в чём прикол, рассмотрим пример с серией операций.

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

class Parsed

class File {
def read: String = ""

def close() = {}
}

def openFile(fileName: String) = new File

def parseString(str: String) = new Parsed

Как выглядит среднестатистический способ работы с таким интерфейсом? Примерно так.

val file = openFile("MyFile.txt")
val parsed = parseString(file.read)
file.close()

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

Традиционный сейчас способ решения подразумевает использование try-catch. И вот во что превращаются эти простые три строки в этом случае.

var file: File = null
var parsed: Parsed = null

try {
file = openFile( "MyFile.txt" )
parsed = parseString( file.read )
} catch {
case e: Exception => println( e )
} finally {
try {
if( file != null ) file.close( )
} catch {
case e: Exception => println( e )
}
}

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

Но, что интересно, некоторые ещё и гордятся тем, что подобные нагромождения пишут без ошибок.

Теперь посмотрим, что нам даст вышерассмотренный контейнер с одним или нулём элементов - Option.

Для начала переопределим все методы так, чтобы исключений они не выкидывали, а если возвращают результат, то возвращали бы его обёрнутым в Option.

class Parsed

class File {
def read: Option[String] = Some("")

def close( ) : Option[File] = {}
}

def openFile( fileName: String ): Option[File] = Some(new File)

def parseString( str: String ): Option[Parsed] = Some(new Parsed)

Не окажется ли теперь, что вместо вложенных, уродливых и многословных try-catch у нас будут ровно такие же уродливые, вложенные друг match-case? Если подойти в лоб - несомненно, будут.

Однако вместо решения в лоб мы воспользуемся тем, что Option - это контейнер. Причём он не просто контейнер, а контейнер с методом flatMap. Что нам это даёт? Это нам даёт следующее:

  1. Если в контейнере ноль элементов (None), то написанное во flatMap не будет выполнено ни разу

  2. Если в контейнере один элемент (Some), то написанное в flatMap будет выполнено один раз - именно для того, что обёрнуто в этот Some

  3. Нам вернётся результат выполнения, обёрнутый в Option: Some(результат), если всё прошло гладко, и None - если нет

Сие позволяет нам написать вот так.

val file = openFile( "MyFile.txt" )
val str = file.flatMap( f => f.read )
val parsed = str.flatMap( s => parseString( s ) )
file.flatMap( f => f.close() )

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

Что тут примерно происходит? В первой строке мы пытаемся открыть файл. Если это нам удалось, то в file попадёт Some(ссылка на файл). Если не удалось - None.

Во второй строке, если файл не открылся, то есть file == None, метод flatMap сразу, без вычислений, вернёт None.

Если же у нас всё-таки есть Some(файл), то его попытаются прочитать методом read.

Метод read тоже возвращает Option. Однако благодаря тому, что мы вызываем flatMap, а не map, у нас не получится Option, вложенный в Option - результатом будет Option без вложений.

Иными словами, если file == None или file.read == None мы в качестве ответа получим None. В ином же случае мы получим Some(содержимое файла).

В следующих строках логика такая же.

В результате, если всё пройдёт гладко, мы в конце получим Some(распарсенный объект). Если на каком-то этапе оно навернётся, то мы получим None.

А в самом конце, если файл всё-таки открылся, то последняя строка попытается его закрыть.

А вот как всё это можно записать ещё короче:

val file = openFile( "MyFile.txt" )

val parsed = file.flatMap( _.read ).flatMap( parseString )

file.flatMap( _.close() )

Пусть, с небольшими добавками, но это всё-таки почти те же исходные три строки. Почти столь же хорошо читаемые.

Теперь, что делать, если мы хотим не просто проигнорировать все ошибки, а уведомить о них пользователя?

Идея та же: контейнер с одним элементом. С той лишь разницей, что вместо None должно использоваться что-то типа OtherSome(exception), которая будет, подобно None, «путешествовать» от одной строки к другой и, в конце концов, вернётся к нам, если что-то пойдёт не так.

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

Try - это такая версия контейнера с одним элементом, которая внутри себя перехватывает возникшие исключения. Но не просто их игнорирует, а запоминает внутри обёртки, которая трактуется так же, как в Option трактуется None: никаких преобразований совершать не надо, надо просто вернуть то же самое обёрнутое исключение.

val file = Try( openFile( "MyFile.txt" ) )

val parsed = file.flatMap( f => Try( f.read ) ) .flatMap( s => Try( parseString( s ) ) )

file.foreach( f => Try( f.close( ) ) )

В результате вышенаписанного в parsed будет лежать либо Success(распарсенный объект), либо Failure(то исключение, на котором вся цепочка навернулась).

Да, так чуть длиннее, чем с Option, но всё равно гораздо короче, чем с try-catch.

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

val file = Try( openFile( "MyFile.txt" ) )
val parsed = file.map( _.read ).map( parseString )
file.map( _.close( ) )

Обратите внимание, что в последней строке тут тоже используется именно map, а не foreach: ведь foreach не создаёт нового контейнера Try, а потому возможное исключение в методе close в этом случае не будет перехвачено.

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

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

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

Во-вторых, использование слова «монада» на самом деле в целом ряде случаев ещё и логически неверно. Дело в том, что «монада» - это на самом деле лишь некое отдельное свойство всех этих контейнеров. Посмотрите сами, методов в контейнерах, как правило, намного больше, чем один-два. И вы реально будете вводить людей в заблуждение, называя их словом «монада», но рассуждая при этом об операциях, отличных от flatMap.

Вот, предположим, что вы ввели понятие «сравнимый». Это понятие означает, что два экземпляра некоторого класса можно сравнить при помощи метода «compare». Числа, строки и куча других классов в этом смысле являются «сравнимыми».

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

Ну да, они действительно подходят под критерий. И действительно являются «сравнимыми». Но это слово имеет какой-то приоритет только в том случае, когда вы описываете алгоритм, опирающийся на вот это самое свойство сравнимости и игнорирующий все остальные - например, сортировку таких объектов в списке.

Строки, например, можно прикреплять одну к другой. Можно ли прикреплять «сравнимые»? Неизвестно.

Числа можно перемножить. Можно ли перемножить «сравнимые»? Снова неизвестно. Строки - нет, числа - да, пользовательские классы - в зависимости от того, что решили их разработчики.

Но что вы в результате сообщаете другому человеку, когда говорите ему «а сейчас мы умножим один сравнимый на другой»? Какую-то фигню. Сравнимость никак не связана с умножением. Несмотря даже на то, что некоторые объекты можно и сравнивать, и перемножать.

В-третьих, вы блеснули знанием «умных слов», но при этом скрыли от того, к кому обращаетесь, многие важные черты того объекта, о котором говорите.

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

Например, метод filter может вернуть и пустой, и не пустой контейнер для одного и того же экземпляра исходного контейнера - в зависимости от условия фильтрации. При этом его ровно так же, как и flatMap, можно вплести в цепочку преобразований.

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

Чем эти методы принципиально хуже метода flatMap? Да ничем. Просто называется оно не так эффектно.

Не, можно всё-таки и тут извернуться: назвать все методы, которые возвращают аналогичный исходному контейнер - «монадическими». Однако что это даст на практике? Чем это лучше простого и понятного словосочетания: «метод, возвращающий такой же контейнер»? Ведь уже по нему вполне понятно, что если метод возвращает контейнер, то можно сразу же через точку вызвать следующий метод контейнера.

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

doc-файл

программирование

Previous post Next post
Up