Pull to refresh

Монадные трансформеры для практикующих программистов

Reading time 6 min
Views 11K
Original author: Gabriele Petronella

Прикладное введение в монадные трансформеры, от проблемы к решению


Представьте, что вы сидите за рабочим столом, допиваете кофе и готовитесь к написанию кода на Scala. Функциональное программирование оказалось не так страшно, как его малюют, жизнь прекрасна, вы усаживаетесь поудобнее, сосредотачиваетесь и начинаете писать новый функционал, который нужно сдать на этой неделе.


Всё как обычно: несколько лаконичных однострочных выражений (да, детка, это Scala!), несколько странных ошибок компилятора (о, нет, Scala, нет!), лёгкое сожаление о том, что вы написали такой запутанный код… И вдруг вы сталкиваетесь со странной проблемой: выражение for не компилируется. «Ничего страшного», — думаете вы: «сейчас гляну на StackOverflow», как вы это делаете ежедневно. Как все мы это делаем ежедневно.


Но сегодня, похоже, неудачный день.



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


Однако на этот раз второй ответ похож на первый, и третий, и четвертый. Что происходит?


Монады. Трансформеры.


Даже названия звучат пугающе. Давайте посмотрим внимательнее, в чём ваша проблема?


Сначала вы написали функции:


def findUserById(id: Long): Future[User] = ???
def findAddressByUser(user: User): Future[Address] = ???

Это выглядело элегантно: класс Future представляет асинхронное вычисление, и у него есть метод flatMap, что означает, его можно поместить в выражение for. Супер!


def findAddressByUserId(id: Long): Future[Address] =
  for {
    user    <- findUserById(id)
    address <- findAddressByUser(user)
  } yield address

Затем вы внезапно поняли, что не для каждого идентификатора существует пользователь. Что делать, если пользователь не найден? Ну хорошо, класс Option служит как раз для этой цели:


def findUserById(id: Long): Future[Option[User]] = ???

И, если на то пошло, некоторые пользователи могут не иметь адреса:


def findAddressByUser(user: User): Future[Option[Address]] = ???

Но когда вы вернулись к этому коду, появилась ошибка компиляции:


def findAddressByUserId(id: Long): Future[Address] =
  for {
    user    <- findUserById(id)
    address <- findAddressByUser(user)
  } yield address

Да, всё верно, ведь тип возвращаемого значения теперь Future[Option[Address]]:


def findAddressByUserId(id: Long): Future[Option[Address]] =
  for {
    user    <- findUserById(id)
    address <- findAddressByUser(user)
  } yield address

Компилятор должен быть доволен. Но что он пишет?


error: type mismatch;
 found   : Option[User]
 required: User
           address <- findAddressByUser(user)

Нехорошо. Подумав немного, вы вспомнили, что <- — это просто удобный способ вызвать метод flatMap, и если вы его вызвали на объекте типа Future[Option[User]], то получили Option[User], хотя вам нужен объект User...


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


def findAddressByUserId(id: Long): Future[Option[Address]] =
  findUserById(id).flatMap {
    case Some(user) => findAddressByUser(user)
    case None       => Future.successful(None)
  }

Некрасиво или, по крайней мере, не так красиво, как было прежде. В идеале вам хотелось что-то вроде:


def findAddressByUserId(id: Long): Future[Option[Address]] =
  for {
    user    <- userOption    <- findUserById(id)
    address <- addressOption <- findAddressByUser(user)
  } yield address

Такой себе метод flatMap, который сразу извлекает значение и из Option, и из Future. Но на StackOverflow никто про него не упоминал...


Всё-таки, в чём загвоздка? Почему нет суперметода flatMap который работает с объектами типа Future[Option[X]]?


Дорогой читатель, глубоко вздохните: мы собираемся упомянуть кое-что из теории, но не отчаивайтесь. Вот всё, что вам нужно знать, чтобы читать дальше:
  1. Functor — это класс с функцией map.
  2. Monad — это класс с функцией flatMap.
    Это всё. Обещаю.

Эти базовые знания из теории категорий помогают разгадать загадку.


Если у вас есть два функтора A и B (то есть вы можете вызвать метод map на объектах класса A[X] и на объектах класса B[X]), вы можете их скомпоновать, не зная больше об этих классах ничего. Вы можете взять класс A[B[X]] и получить Functor[A[B[X]], скомпоновав Functor[B[X]] и Functor[A[X]].


Другими словами, если вы знаете, как отображать внутри A[X] и внутри B[X], вы также умеете отображать внутри A[B[X]]. Автоматически. Без усилий.


Для монад это неверно: умение выполнять flatMap над A[X] и B[X] не дает вам автоматической возможности выполнять flatMap над A[B[X]].


Оказывается, это хорошо известный факт: монады не компонуются, по крайней мере, в общем случае.


Ну ладно, монады не компонуются в общем случае, но вам нужны методы flatMap и map, которые работает на объектах класса Future[Option[A]].


Мы это точно сможем. Давайте напишем обёртку для Future[Option[A]] с методами map и flatMap:


case class FutOpt[A](value: Future[Option[A]]) {

  def map[B](f: A => B): FutOpt[B] =
    FutOpt(value.map(optA => optA.map(f)))
  def flatMap[B](f: A => FutOpt[B]): FutOpt[B] =
    FutOpt(value.flatMap(opt => opt match {
      case Some(a) => f(a).value
      case None => Future.successful(None)
    }))
}

Неплохо! Давайте её используем!


def findAddressByUserId(id: Long): Future[Option[Address]] =
  (for {
    user    <- FutOpt(findUserById(id))
    address <- FutOpt(findAddressByUser(user))
  } yield address).value

Работает!


Хорошо, это если у вас объект типа Future[Option[A]]. Но что, если у вас есть, скажем, List[Option[A]]? Может быть, поможет другая обёртка? Давай попробуем:


case class ListOpt[A](value: List[Option[A]]) {

 def map[B](f: A => B): ListOpt[B] =
    ListOpt(value.map(optA => optA.map(f)))
 def flatMap[B](f: A => ListOpt[B]): ListOpt[B] =
    ListOpt(value.flatMap(opt => opt match {
      case Some(a) => f(a).value
      case None => List(None)
    }))
}

Ага, она похожа на FutOpt, да ведь?


Если присмотреться, понятно, что нам не нужно ничего знать о «внешней» монаде (Future или List из предыдущих примеров). Пока мы умеем выполнять map и flatMap, всё отлично. С другой стороны, помните, как мы анализировали объект Option? Нужно знать специфику «внутренней» монады (в данном случае это Option), которая у нас имеется.


Получается, мы можем написать общую структуру данных, которая «обертывает» любую монаду M вокруг класса Option.


Потрясающее известие: мы случайно придумали монадный трансформер, который обычно называют OptionT!


OptionT имеет два параметра F и A, где F — обертывающая монада, а A — тип внутри Option. Другими словами, OptionT[F, A] является плоской версией F[Option[A]] и имеет методы map и flatMap.


класс OptionT[F, A] — это плоская версия класса F[Option[A]], и он сам является монадой


Обратите внимание, что класс OptionT также является монадой, поэтому мы можем использовать его в выражении for (в конце концов, мы для этого и городили огород).


Если вы используете библиотеки наподобие cats, многие монадные трансформеры (OptionT, EitherT, ...) в них уже есть.


Вернемся к нашему изначальному примеру:


import cats.data.OptionT, cats.std.future._
def findAddressByUserId(id: Long): Future[Option[Address]] =
  (for {
    user    <- OptionT(findUserById(id))
    address <- OptionT(findAddressByUser(user))
  } yield address).value

Работает!


Можем ли мы ещё что-то улучшить? Возможно, если мы всё время используем обёртки, стоит возвращать OptionT[F, A] из этих методов:


def findUserById(id: Long): OptionT[Future, User] =
  OptionT { ??? }
def findAddressByUser(user: User): OptionT[Future, Address] =
  OptionT { ??? }
def findAddressByUserId(id: Long): OptionT[Future, Address] =
  for {
    user    <- findUserById(id)
    address <- findAddressByUser(user)
  } yield address

И это очень похоже на наш первоначальный код. А когда нам нужно фактическое значение типа Future[Option[Address]], мы можем просто вызвать value.


Прежде чем завершить статью, небольшое предостережение:


  • Монадные трансформеры работают хорошо в некоторых распространенных случаях (как в этом), но не слишком увлекайтесь: я не советую вкладывать друг в друга больше двух монад, иначе код станет сложным. За печальным примером можно обратиться к этому README: https://github.com/djspiewak/emm;
  • Монадные трансформеры не даются бесплатно с точки зрения выделения памяти. В них применяется много обёрток, поэтому если вы беспокоитесь о скорости работы, дважды подумайте и позапускайте тесты производительности;
  • Поскольку они не являются стандартными в языке (существуют несколько реализаций в библиотеках cats, scalaz и, возможно, других) не выставляйте их наружу в вашем API. Вызывайте value на трансформерах и возвращайте просто класс A[B[X]]. Это не накладывает на ваших пользователей никаких ограничений, а также позволяет вам поменять внутреннюю реализацию, не внося изменений в API.

Добавлю, что монадные трансформеры — это лишь один из способов разделаться со вложенными монадами. Они подходят, если у вас простая проблема и вы не хотите сильно менять код, но если вы готовы к бóльшему, обратите внимание на библиотеку Eff.




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


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


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


Если вы хотите узнать больше по этой теме, я рассказывал о монадных трансформерах на конференции Scala Italy в прошлом году: https://vimeo.com/170461662


Счастливого (функционального) программирования!

Tags:
Hubs:
+25
Comments 8
Comments Comments 8

Articles