Рассмотрим, например, функцию
getByPath
, которая извлекает элемент из XML дерева по его полному пути.import scala.xml.{Node => XmlNode}
def getByPath(path: List[String], root: XmlNode): Option[XmlNode] =
path match {
case name::names =>
for {
node1 <- root.child.find(_.label == name)
node2 <- getByPath(names, node1)
} yield node2
case _ => Some(root)
}
Эта функция отлично работала, но требования поменялись и теперь нам нужно:
- Извлекать данные из JSON и, возможно, других древоподобных структур, а не только из XML;
- Возвращать сообщение об ошибке, если данные не найдены.
В этой статье мы расскажем, как осуществить рефакторинг функции
getByPath
, чтобы она соответствовала новым требованиям.Композиция Клейсли
Давайте выделим тот фрагмент кода, который извлекает дочерний элемент по имени. Мы можем назвать ее
createFunctionToExtractChildNodeByName
, но давайте назовем ее для краткости просто child
.val child: String => XmlNode => Option[XmlNode] = name => node =>
node.child.find(_.label == name)
Теперь мы можем сделать ключевое наблюдение: наша функция
getByPath
является последовательной композицией функций, извлекающих дочерние элементы. Приведенная ниже функция compose реализует такую композицию двух функций: getChildA
and getChildB
.type ExtractXmlNode = XmlNode => Option[XmlNode]
def compose(getChildA: ExtractXmlNode,
getChildB: ExtractXmlNode): ExtractXmlNode =
node => for {a <- getChildA(node); ab <- getChildB(a)} yield ab
К счастью, библиотека Scalaz предоставляет более общий, абстрактный способ реализовать композицию функций вида
A => M[A]
, где M является монадой. Библиотека определяет Kleisli[M, A, B]
, обертку для A => M[B]
, у которой есть метод >=> для реализации последовательной композиции этих Kleisli
, подобно композиции обычных функций при помощи andThen
. Эту композицию мы будем называть композицией Клейсли. Приведенный ниже код демонстрирует пример такой композиции:val getChildA: ExtractXmlNode = child(“a”)
val getChildB: ExtractXmlNode = child(“b”)
import scalaz._, Scalaz._
val getChildAB: Kleisli[Option, XmlNode, XmlNode] =
Kleisli(getChildA) >=> Kleisli(getChildB)
Обратите внимание на бесточечный стиль, который мы здесь используем. Функциональные программисты любят записывать функции как композиции других функций, без упоминания аргументов.
Композиция Клейсли – это именно то, что нам нужно, чтобы реализовать нашу функцию
getByPath
как композицию функций child
, извлекающих дочерние элементы.import scalaz._, Scalaz._
def getByPath(path: List[String], root: XmlNode): Option[XmlNode] =
path.map(name => Kleisli(child(name)))
.fold(Kleisli.ask[Option, XmlNode]) {_ >=> _}
.run(root)
Обратите внимание на использование
Kleisli.ask[Option, XmlNode]
в качестве нейтрального элемента метода fold. Этот нейтральный элемент нужен нам для обработки специального случая, когда path пуст. Kleisli.ask[Option, XmlNode]
– это просто другое обозначение функции из любого node в Some(node)
.Абстрагируемся от XmlNode
Давайте обобщим наше решение и абстрагируем его от XmlNode. Мы можем переписать его в виде следующей обобщенной функции
getByPathGeneric
:def getByPathGeneric[A](child: String => A => Option[A])
(path: List[String], root: A): Option[A] =
path.map(name => Kleisli(child(name)))
.fold(Kleisli.ask[Option, A]) {_ >=> _}
.run(root)
Теперь мы можем повторно использовать
getByPathGeneric
для извлечения элемента из JSON (мы используем здесь json4s):import org.json4s._
def getByPath(path: List[String], root: JValue): Option[JValue] = {
val child: String => JValue => Option[JValue] = name => json =>
json match {
case JObject(obj) => obj collectFirst {case (k, v) if k == name => v}
case _ => None
}
getByPathGeneric(child)(path, root)
}
Мы написали новую функцию,
child: JValue => Option[JValue]
, чтобы работать с JSON вместо XML, но функция getByPathGeneric
осталась неизменной и работает как с XML, так и с JSON.Абстрагируемся от Option
Мы можем обобщить
getByPathGeneric
еще больше и абстрагировать её от Option
при помощи библиотели Scalaz, которая предоставляет экземпляр (instance) монады для Option -- scalaz.Monad[Option]
. Так что мы можем переписать getByPathGeneric
следующим образом:import scalaz._, Scalaz._
def getByPathGeneric[M[_]: Monad, A](child: String => A => M[A])
(path: List[String], root: A): M[A]=
path.map(name => Kleisli(child(name)))
.fold(Kleisli.ask[M, A]) {_ >=> _}
.run(root)
Теперь мы можем реализовать нашу исходную функцию
getByPath
при помощи функции getByPathGeneric
:def getByPath(path: List[String], root: XmlNode): Option[XmlNode] = {
val child: String => XmlNode => Option[XmlNode] = name => node =>
node.child.find(_.label == name)
getByPathGeneric(child)(path, root)
}
Таким образом, мы можем повторно использовать
getByPathGeneric
, чтобы возвращать сообщение об ошибке, если элемент не найден. Для этого мы используем scalaz.\/ (т.н. “дизъюнкцию”) которая является правосторонней версией scala.Either
. В дополнение,
Scalaz
предоставляет “неявный” (implicit) класс OptionOps
с методом toRightDisjunction[B](b: B)
, который преобразует Option[A]
в scalaz.B\/A
, так, что Some(a)
становится Right(a)
и None
становится Left(b)
. Так, мы можем написать функцию, которая повторно использует
getByPathGeneric
, чтобы вернуть сообщение об ошибке вместо None
, если искомый элемент не найден.type Result[A] = String\/A
def getResultByPath(path: List[String], root: XmlNode): Result[XmlNode] = {
val child: String => XmlNode => Result[XmlNode] = name => node =>
node.child.find(_.label == name).toRightDisjunction(s"$name not found")
getByPathGeneric(child)(path, root)
}
Исходная функция
getByPath
обрабатывала только данные в формате XML и возвращала None, если искомый элемент не найден. Нам понадобилось, чтобы она также работала с форматом JSON и возвращала сообщение об ошибке вместо None.Мы видели, как использование композиции Клейсли, которую предоставляет библиотека
Scalaz
, позволяет написать обобщенную функцию getByPathGeneric
, используя параметризированные типы (generics) для поддержки как XML так и JSON, а также scalaz.\/ (дизъюнкцию) для абстрагирования от Option
и выдачи сообщений об ошибках.Разработчик конструктора сайтов Wix,
Михаил Дагаев
Оригинал статьи: блог инженеров компании Wix.
Комментарии (12)
kstep
04.07.2016 21:14+1Наконец-то вменяемое описание композиции Клейсли для инженеров, а не для математиков! Спасибо!
cs0ip
04.07.2016 22:01Получилось конечно кратко, но на мой взгляд проблема подобного кода в том, что без соответствующей статьи с пояснениями его поддерживать невозможно. Такое лучше писать на хаскеле хотя бы потому, что там подобный код будет более читаемым и понятным, а на скале с кучей имплиситов можно потом очень долго пытаться понять какие преобразования и откуда применились в этой функции на пять строк. Кроме того, описанная тут задача решается даже на яве без необходимости написания сотен строк копипасты. В общем применение подобного подхода в целом и Scalaz в частности кажется здесь весьма надуманным и сильно усложнит жизнь тем, кому с этим потом придется работать.
danslapman
04.07.2016 22:27+1проблема подобного кода в том, что без соответствующей статьи с пояснениями его поддерживать невозможно
да не знаю, вроде после того, как узнаешь про Kleisli всё нормально читается. Может я, конечно, не видел обфусцированного каким-нибудь партизаном кода)
скале с кучей имплиситов можно потом очень долго пытаться понять какие преобразования и откуда применились
вот это я вообще каждый раз удивляюсь как встречаю, что IDE отменили? вроде уже даже Eclipse умеет переходить в имплиситы и подчёркивать ихcs0ip
04.07.2016 23:05+1Здесь конечно слишком мало кода, чтобы можно было оценить возможную обфусцированность. Гораздо интереснее в этом смысле читается код самой Scalaz.
вот это я вообще каждый раз удивляюсь как встречаю, что IDE отменили? вроде уже даже Eclipse умеет переходить в имплиситы и подчёркивать их
В том то и дело, что для того, чтобы понять, что же происходит, надо по каждому методу тыкать ctrl+click и смотреть куда в итоге попадешь, а имплиситы могут быть и многоуровневыми (особенно во всяких Scalaz). В то время как обычный код можно просто читать. Из-за этого разница в скорости понимания происходящего будет в разы.danslapman
04.07.2016 23:06Из-за этого разница в скорости понимания происходящего будет в разы
Зато можно быстро понять идею, что в некоторых случаях куда полезнее. Тут скорее вопрос ситуации
В scalaz вообще неприпомню необходимости лазать по имплиситам, там они — средство реализации. Если не понимать идей scalaz, разматывание кода по ниточкам не особо приводит к смыслу) На себе ощутилcs0ip
04.07.2016 23:22В плане демострации каких-то математических абстракций может быть действительно это оптимальный подход. Но когда речь идет о таких обыденных вещах, как работа с xml и json, лично мне было бы приятнее работать с библиотекой, по коду которой я без проблем могу сказать, что она делает, не тратя часы на то самое разматывание кода. Ну и опять же, в хаскеле подобный код читается легче из-за большей заточенности языка под функциональный подход и там можно разбираться даже в текстовом редакторе, что как бы говорит о том, что одни и те же вещи можно реализовать с разной красотой и аккуратностью.
danslapman
04.07.2016 23:28Так в обычной жизни разматывание и ненужно… Если сильно хочется на досуге — можно расковырять scalaz, пожалуйста, но я в упор не припомню ни одного случая за последний год, когда нельзя было по типам разобраться, что происходит. Не хотел обидеть, просто постоянно пишут про «многочасовые копания», а моя практика не подтверждает этого
cs0ip
05.07.2016 00:33Вообще, при интенсивном использовании любой библиотеки, почти всегда наталкиваешься на какой-то код который работает не так, как этого ожидаешь. И вот тогда неминуемо приходится лезть в исходники. Тут на хабре с год назад эпизод был (ссылку сейчас не найду), когда пытались разобраться, что делает какой-то метод из старой версии Scalaz (в текущей подобного уже нет) и в итоге так никто и не осилил. Если бы подобные библиотеки действительно можно было использовать как «черный ящик», то вопросов бы к ним не было, но на практике это обычно не получается.
Не хотел обидеть
Ну что вы, никто и не думал обижатся. Мы тут, по возможности конструктивно, просто обмениваемся личными мнениями, а они не обязаны совпадать )
mikhanoid
05.07.2016 06:54Я читаю код и на Haskell, и на Scala. Но не пишу на них. Должен Вам сказать, что воспринимаются они сторонним наблюдателем одинаково тяжело. Основную сложность представляет не синтаксис, а сокрытие потока данных, от чего сложно прослеживать: что, куда и как передаётся. В этом бесточечный код и на Scala, и на Haskell достигают эпических высот невыразительности. Поэтому, как обычно, всё дело в привычке и опыте чтения и написания, а не в конкретном языке. Дело привычки. Статья же хорошая. Потому что про категории Клейсли на Хабре уже было на Haskell. Теперь взгляд под другим углом
bull1251
11.11.2016 16:20+1Ну вообще то я закончил физико-математический факультет. И в какой то степени разбираюсь в данной области. К тому же нельзя сказать, что построил такую теорию за пару дней. В течении многих лет я задумывался над этими вопросами, с каждым разом все ближе и ближе подбираясь к сути, но не мог уловить самой основы, не мог представить в себе как выглядит пространство фотона. А после прочтения указанной статьи я с большей силой заинтересовался этими вопросами и решил постараться выстроить полную картину, собрав в едино все имеющиеся свои идеи. В процессе детального анализа я вдруг осознал как нужно смотреть, чтобы представить пространство фотона.
Scf
05.07.2016 11:56+2Имхо, слишком сложно для использования на практике.
Начиная с первого же куска кода, зачем использовать for-yield, если можно намного проще?
def getByPath(path: List[String], root: XmlNode): Option[XmlNode] = path match { case name::names => root.child.find(_.label == name).flatMap { child => getByPath(names, child) } case _ => Some(root) }
Или еще понятнее:
def getByPath2(path: List[String], root: XmlNode): Option[XmlNode] = path match { case name::names => root.child.find(_.label == name) match { case Some(child) => getByPath(names, child) case None => None } case _ => Some(root) }
Объем кода такой же, а понять его не в пример проще. Возвращать сообщение об ошибке — так оно уже возвращается, если None — то данные не найдены. Обернуть Option в любой другой тип в отдельной функции и всё.
Что касается обобщенного кода: Scala-компактный язык. В большинстве случаев лучше написать рядышком еще одну реализацию в несколько строк, что будет и проще, и, как правило, быстрее исполняться.
Проблема Scala в том, что он, по сути, содержит в себе два языка программирования — качественный промышленный и обширный экспериментальный. Множество попыток внедрения скалы провалилось из-за того, что первые энтузиасты тщательно зашифровывали исходники библиотеками типа scalaz, превращая текст в подобие двоичного дампа. В этом плане полезно изучить опыт Twitter, а также их исходники.
sshikov
Это у вас вышло хорошо. Спасибо.