За последнее время мы очень многое узнали о монадах. Мы уже разобрались что это такое и даже знаем как их можно нарисовать, видели доклады, объясняющие их предназначение. Вот и я решил заскочить в уходящий монадный поезд и написать по этой теме, пока это окончательно не стало мейнстримом. Но я зайду с немного другой стороны: здесь не будет выкладок из теории категорий, не будет вставок на самом-лучшем-языке, и даже не будет scalaz/shapeless и библиотеки parser-combinators. Как известно, лучший способ разобраться как что-то устроено — сделать это самому. Сегодня мы с вами будем писать свою монаду.
Задача
Возьмем для примера банальную задачу: парсинг CSV-файла. Допустим нам требуется распарсить строки файла в case classes, чтобы потом отправить их в базу, сериализовать в json/protobuf и так далее. Забудем про escaping и кавычки, для еще большей простоты, считаем что символ разделителя в полях встречаться не может. Думаю, если кто-то решит затащить это решение в свой проект, докрутить эту фичу будет не трудно.
Допустим у нас есть следующий CSV файл:
1997;Ford;E350;ac, abs, moon;3000.00
1996; Jeep; Grand Cherokee; MUST SELL! air, moon roof, loaded; 4799.00
1999;Chevy;Venture "Extended Edition"; ; 4900.00
Нам нужно десериализовать его в набор объектов следующего типа:
case class Car(year: Int, mark: String, model: String, comment: String, price: BigDecimal)
Очевидный подход
Чтобы было с чем сравнивать, я должен привести пример из жизни, который использование монад делает нагляднее, приятнее, надежнее и т.п.
Допустим файл строкой уже загружен в переменную content
:
val lines = content.split('\n')
val entities =
lines.map { line =>
line.split(';').map(_.trim) match {
case Array(year, mark, model, comment, price) =>
Car(year.toInt, mark, model, comment, BigDecimal(price))
}
}.toSeq
Минусы подхода:
- Смешивание логики конвертации типов полей и конструирования самой сущности.
- Boilerplate case matching: при росте числа полей код будет стремительно утрачивать читаемость.
- Нужно явно обрабатывать случаи когда число полей не соответствует ожидаемому, когда строка слишком длинная и т.п.
Плюсы:
- Straight-forward: отсутствие дополнительных слоев абстракции.
Монадический парсер
Я предлагаю взглянуть на задачу с другой стороны.
- Представим что в начале мы имеем один фрагмент сырых данных — в частном случае — строку из файла, хотя на самом деле нам это не важно: это может быть byte array, список слов, итератор, все что угодно, из чего мы можем получать данные.
- Допустим что каждую запись мы парсим в несколько этапов, каждый из которых — это парсинг конкретного поля в записи. Тогда для каждого этапа мы можем зафиксировать результат: значение этого поля (далее слово) + остаток сырых данных (далее остаток), которые мы будем рассматривать на последующих этапах парсинга, извлекая из него следующие поля записи. Или не будем, если поле последнее.
Далее, для краткости будем называть эту функцию "обработчик". - Тогда в итоге нам останется только совместить результаты этих этапов в конечную сущность.
Возвращаясь к коду, обработчик каждого этапа иметь объявление вроде:
def parse[T, Src]: Src => (T, Src)
Теперь немного о самих монадах.
В двух словах монаду можно описать как контейнер, содержащий значение + некий контекст.
Синтаксически, в случае со Скалой, это значит что монада должна иметь метод flatMap, в общем случае объявляемый как:
def flatMap[T](f: T => M[T]): M[T]
Если f — значение, хранимое в контейнере, то что же такое контекст? А вот что: хотя у f только один аргумент, но, поскольку мы можем вызывать изнутри одного flatMap'a другой flatMap, то из внутреннего flatMap нам будут доступны все значения, объявленные внутри внешнего, то есть в том числе и все предыдущие слова.
Обратите внимание, что реализовывать метод map от монады не требуется, но мы его все-таки определим, он пригодится нам для создания модифицированных парсеров из уже определенных.
Также нужно определить операцию заворачивания чистого значения в монаду. Это не метод класса, но это может быть вызов конструктора, либо метод apply у companion object'а, никакого строгого требования к этому нет, и я предлагаю для удобства определить метод apply.
Реализуем монаду, содержащую в себе функцию parse, такого вида как мы определили выше и посмотрим, как мы сможем с ее помощью комбинировать разные парсеры.
Итак нам нужно написать класс, инкапсулирующий парсинг поля конкретного типа, который:
- Реализует метод flatMap
- Реализует метод map
- Также нужно определить операцию apply у companion object'a.
- Нужно определить интерфейсный метод, который будет вызываться конечным клиентским кодом и не будет содержать лишних деталей в объявлении.
class Parser[T, Src](private val p: Src => (T, Src)) {
def flatMap[M](f: T => Parser[M, Src]): Parser[M, Src] =
Parser { src =>
val (word, rest) = p(src)
f(word).p(rest)
}
def map[M](f: T => M): Parser[M, Src] =
Parser { src =>
val (word, rest) = p(src)
(f(word), rest)
}
def parse(src: Src): T = p(src)._1
}
Так что-же происходит в методе flatMap?
Мы применяем обработчик текущего парсера к входному значению, затем с помощью функции — аргумента метода добавляем его в контекст, видимый всем последующим парсерам по цепочке.
С методом map же все намного понятнее, мы просто применяем его аргумент — функцию f на текущее слово, а все остальное оставляем неизменным.
И companion object, содержащий операцию point, она же — метод apply, он же — вызов объекта с круглыми скобками:
object Parser {
def apply[T, Src](f: Src => (T, Src)) =
new Parser[T, Src](f)
}
Применение
И что? Какие преимущества нам предоставляет этот подход, кроме несомненного повышения вашего авторитета у незнакомых с монадами коллег? Сейчас увидим.
Используя предложенную выше абстракцию, наконец напишем наш инновационный, функциональный, типобезопасный CSV-парсер.
Пишем парсеры типов полей
Для начала реализуем парсер одного поля типа String.
def StringField =
Parser[String, String] { str =>
val idx = str.indexOf(separator)
if (idx > -1)
(str.substring(0, idx), str.substring(idx + 1))
else
(str, "")
}
Ничего сложного, правда?
А теперь посмотрим, как можно на базе StringField определить парсер типа Int.
Еще проще!
def IntField = StringField.map(_.toInt)
Аналогично для всего остального:
def BigDecimalField = StringField.map(BigDecimal(_))
def IntField = StringField.map(_.toInt)
def BooleanField = StringField.map(_.toBoolean)
// все что еще вам нужно
Собираем все воедино
До сих пор мы рассмотрели только парсеры отдельных полей, но как нам собрать эти поля в единую сущность? Вот тут нам и придет на помощь тот самый контекст. Благодаря ему, мы можем использовать в нижележащих парсерах значения, полученные в вышележащих.
Итак, конструирование конечного парсер сущностей будет выглядеть так:
val parser =
for {
year <- IntField
mark <- StringField
model <- StringField
comment <- StringField
price <- BigDecimalField
} yield Car(year, mark, model, comment, price)
По моему выглядит очень круто.
Если вы вдруг не полностью уверенно себя чувствуете с синтаксическим сахаром for comprehension, то вот примерно так это бы выглядело в виде цепочки flatMap'ов:
IntField.flatMap { year =>
StringField.flatMap { mark =>
StringField.flatMap { model =>
StringField.flatMap { comment =>
BigDecimalField.map { price =>
Car(year, mark, model, comment, price)
}
}
}
}
}
Выглядит, конечно, это немного хуже, зато становится очевидно о каких контекстах идет речь, это области видимости, ограниченные фигурными скобками.
Мы получили парсер parser, теперь все что нам нужно, это построчно скормить исходный файл его методу parse и получить результат. Например так:
val result = str.split('\n').map(parser.parse)
Результат:
Array(Car(1997,Ford,E350,ac, abs, moon,3000.00), Car(1996,Jeep,Grand Cherokee,MUST SELL! air, moon roof, loaded,4799.00), Car(1999,Chevy,Venture "Extended Edition",,4900.00))
Плюсы
- Конечный парсер описывается красиво и лаконично, из его объявления легко понять типы и последовательность полей в файле, его легко изменять и тестировать.
- Вы крутой специалист, знающий толк в ФП, могущий в монады и вообще самый модный
на районев опенспейсе.
Минусы
- Наличие генерализованной сущности с не самой очевидной логикой, особенно для тех, кто не очень хорош в этих ваших монадах, либо недавно перешедших с джавы.
Резюме
Монады и прочие категории в Скале — не что-то такое без чего нельзя жить. Более того, они практически никак не навязываются самим языком. По сути, монадность в Скале — это небольшой ad-hoc контракт, выполняя который вы получаете возможность использовать свои классы в for-comprehension. И на этом все.
Тем не менее гибкость языка и возможность довольно легко реализовывать на нем довольно хитрые конструкции — это безусловный плюс языка, развязывающий руки для экспериментов.
Насчет того, стоит ли использовать такого рода конструкции в продакшн коде: я не знаю, это выбор каждой отдельной команды. Наверное, я бы сначала постарался выделять их в отдельные библиотеки, покрывать тестами и всячески обкатывать (хотя мы конечно знаем, что у настоящих функциональщиков все работает и без тестов). А для логики, которая нужна здесь и сейчас, скорее использовал бы более straight-forward реализации.
Akon32
Велосипедненько.
А что, если в CSV будут дополнительные поля (или поля будут не по порядку)? Придётся менять код?
Экранирования в строках, кажется, нет (но несложно добавить).
.toInt будет бросать исключение, если что не так. Не вижу, чтобы оно обрабатывалось.
Почему бы не использовать Scala Parsers (правда эта библиотека небыстрая)?
"Очевидный подход" как-то нагляднее и наверняка быстрее.
xkorpsex
Я согласен с вашими тезисами.
1. Придется, пришлось бы в любом случае, пока у нас парсер не обладает своим разумом.
2. Экранирования нет, и я написал почему. Акцент статьи на понимании того, куда можно вкрутить монаду, при желании. Не хотелось нагружать примеры логикой, к этому не относящейся.
3. Parser-combinators — тоже самое, статья не именно о том как парсить CSV.
Еще есть вариант разбивать строку по полям с помощью OpenCSV и потом работать не со строкой на входе, а со списком строк, тогда код из статьи будет применим с минимальными корректировками, но изобретать эскейпинг не придется.
Akon32
Это не так сложно. Я просто добавлял первой строкой в CSV-подобном формате названия полей, и потом с помощью таблицы "имя->индекс столбца" искал нужные столбцы, из которых брал данные.
xkorpsex
Хм, тогда соглашусь.
Но тут тоже можно выкрутиться монадическим парсером, если сначала токенизировать в мапу по именам полей с помощью Parser[Map[String, String], String], а потом использовать мапу как остаток и парсить в сущность Parser[T, Map[String, String]]
Опять же, не знаю стал ли бы я так делать в продакшне)
barbalion
Добавьте "
Parser {case src =>
" в листинг, это магическое слово, превращающее лямбду в частично примененную функцию. А то у меня мозг взорвался, прежде чем я понял код.xkorpsex
Да, это моя ошибка — я в процессе экспериментов избавился от PartialFunctions, но тут забыл заменить. Теперь исправил, на обычную функцию.
Спасибо за замечание)
amakhrov
Так вот почему у меня мозг взрывается от этого кода. Все дело в недостающем
case
!Если серьезно, то у меня вопрос:
Я вот знаком со Скалой преимущественно по статьям на Хабре :). И для меня конструкции типа определения
Parser
в статье — вынос мозга каждый раз.А вы правда привыкли к ней настолько, чтобы легко и непринужденно оперировать подобными сущностями?
Или все еще требуется значительное усилие, чтобы разобраться, что к чему?
Написать-то один раз можно (даже я, наверное, справлюсь — с n-ной попытки). Но потом же кто-то этот код регулярно читает.
Меня действительно этот вопрос интересует.
xkorpsex
Ну если бы класс Parser был прост и интуитивен, я бы не написал об этом целую статью :)
На самом деле ответ на ваш вопрос есть в самой статье — не нужно любую задачу решать через монады.
Есть проблема в том, что скала достаточно богатый язык, чтобы в нем было очень легко прострелить себе ногу, но можно научиться брать эту его сторону под контроль и писать понятно.
В целом, в функциональном программировании есть свои паттерны (можно сказать что монада — один из них), и, научившись их распознавать, читать код становится намного проще. Тут похожая ситуация на ту, которая происходит, когда впервые изучаешь и пытаешься применять рекурсии. Как только мозг выворачивается в нужную сторону, все становится намного понятнее.
Akon32
Писать на scala можно по-разному.
У меня процентов 90 scala-кода вообще императивно написано, только с добавлением функциональных .map(), .foreach() и т.п. Тяжёлую функциональщину приходится писать очень редко, да и в конце концов такой код воспринимается как несложный dsl (например, Scala Parsers).
Но от применения серьёзной функциональщины чаще отказываюсь из-за тормознутости (и временами дикой прожорливости) результата, а не из-за сложности написания/понимания.