В течение довольно длительного времени мы поддерживали приложение, которое обрабатывает данные в форматах XML и JSON. Обычно поддержка заключается в исправлении дефектов и незначительном расширении функциональности, но иногда она также требует рефакторинга старого кода.


Рассмотрим, например, функцию 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)


  1. sshikov
    04.07.2016 19:45
    +2

    Это у вас вышло хорошо. Спасибо.


  1. kstep
    04.07.2016 21:14
    +1

    Наконец-то вменяемое описание композиции Клейсли для инженеров, а не для математиков! Спасибо!


  1. cs0ip
    04.07.2016 22:01

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


    1. danslapman
      04.07.2016 22:27
      +1

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

      да не знаю, вроде после того, как узнаешь про Kleisli всё нормально читается. Может я, конечно, не видел обфусцированного каким-нибудь партизаном кода)
      скале с кучей имплиситов можно потом очень долго пытаться понять какие преобразования и откуда применились
      вот это я вообще каждый раз удивляюсь как встречаю, что IDE отменили? вроде уже даже Eclipse умеет переходить в имплиситы и подчёркивать их


      1. cs0ip
        04.07.2016 23:05
        +1

        Здесь конечно слишком мало кода, чтобы можно было оценить возможную обфусцированность. Гораздо интереснее в этом смысле читается код самой Scalaz.

        вот это я вообще каждый раз удивляюсь как встречаю, что IDE отменили? вроде уже даже Eclipse умеет переходить в имплиситы и подчёркивать их

        В том то и дело, что для того, чтобы понять, что же происходит, надо по каждому методу тыкать ctrl+click и смотреть куда в итоге попадешь, а имплиситы могут быть и многоуровневыми (особенно во всяких Scalaz). В то время как обычный код можно просто читать. Из-за этого разница в скорости понимания происходящего будет в разы.


        1. danslapman
          04.07.2016 23:06

          Из-за этого разница в скорости понимания происходящего будет в разы

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

          В scalaz вообще неприпомню необходимости лазать по имплиситам, там они — средство реализации. Если не понимать идей scalaz, разматывание кода по ниточкам не особо приводит к смыслу) На себе ощутил


          1. cs0ip
            04.07.2016 23:22

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


            1. danslapman
              04.07.2016 23:28

              Так в обычной жизни разматывание и ненужно… Если сильно хочется на досуге — можно расковырять scalaz, пожалуйста, но я в упор не припомню ни одного случая за последний год, когда нельзя было по типам разобраться, что происходит. Не хотел обидеть, просто постоянно пишут про «многочасовые копания», а моя практика не подтверждает этого


              1. cs0ip
                05.07.2016 00:33

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

                Не хотел обидеть

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


            1. mikhanoid
              05.07.2016 06:54

              Я читаю код и на Haskell, и на Scala. Но не пишу на них. Должен Вам сказать, что воспринимаются они сторонним наблюдателем одинаково тяжело. Основную сложность представляет не синтаксис, а сокрытие потока данных, от чего сложно прослеживать: что, куда и как передаётся. В этом бесточечный код и на Scala, и на Haskell достигают эпических высот невыразительности. Поэтому, как обычно, всё дело в привычке и опыте чтения и написания, а не в конкретном языке. Дело привычки. Статья же хорошая. Потому что про категории Клейсли на Хабре уже было на Haskell. Теперь взгляд под другим углом


  1. bull1251
    11.11.2016 16:20
    +1

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


  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, а также их исходники.