Практическое введение в Monad Transformers, с подходом от проблемы к решению.

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

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

Только вот сегодня будет плохой день.

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

Но на этот раз все по-другому, на этот раз второй ответ похож на первый, третий и четвертый. В чем дело?

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

Даже название звучит устрашающе.

В чем снова была ваша проблема?

Все началось, когда вы набросали свою функцию

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

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

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 и что если вы 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 ...

Итак, в чем здесь настоящая проблема? Почему у нас просто не может быть superflatMap, который работает на Future[Option[X]]?

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

1. Functor - это структура с функцией map.

2. Monad - это структура с функцией flatMap.

Вот и все. Я обещаю.

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

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

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

Это неверно для Monad: знание того, как flatMap за A[X] и B[X], не дает вам возможности волшебным образом получить flatMap за A[B[X]].

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

Итак, да, Monads может не создавать в целом, но вам нужен только flatMapmap), который работает для 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, не так ли?

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

Имея эту информацию в нашем кармане, мы можем написать общую структуру данных, которая «обертывает» любые Monad M вокруг Option.

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

OptionT имеет два параметра типа F и A, где F - это обертка Monad, а A - это тип внутри Option: другими словами, OptionT[F, A] - это плоская версия F[Option[A]], которая имеет свои собственные map и flatMap.

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

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

Если вы используете библиотеки типа 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.
  • Monad Transformers не бесплатны с точки зрения распределения. При этом выполняется множество операций по упаковке / развертыванию, поэтому если производительность является проблемой, подумайте дважды и выполните несколько тестов.
  • Поскольку они не являются стандартными для языка (доступно несколько библиотечных реализаций: cats, scalaz и, возможно, другие), не раскрывайте их как общедоступный API. Позвоните .value своим трансформаторам и покажите обычный A[B[X]], который не навязывает вашим пользователям никакого самоуверенного выбора, а также позволяет вам менять реализации без внесения критических изменений.

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

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

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

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

Если вы хотите узнать больше об этом, вот мой доклад на Scala Days 2017:

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

Если вы хотите работать там, где мы заботимся о качестве нашего рабочего процесса разработки, взгляните на https://buildo.io/careers