Прикладное введение в монадные трансформеры, от проблемы к решению
Представьте, что вы сидите за рабочим столом, допиваете кофе и готовитесь к написанию кода на 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]]
?
Дорогой читатель, глубоко вздохните: мы собираемся упомянуть кое-что из теории, но не отчаивайтесь. Вот всё, что вам нужно знать, чтобы читать дальше:
Functor
— это класс с функциейmap
.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.
Добавлю, что монадные трансформеры — это лишь один из способов разделаться со вложенными монадами. Они подходят, если у вас простая проблема и вы не хотите сильно менять код, но если вы готовы к бoльшему, обратите внимание на библиотеку Eff.
Итак, повторюсь, монадные трансформеры помогают нам в работе с вложенными монадами, предоставляя плоское представление двух вложенных монад, и сами являются монадами.
Надеюсь, я доказал, что они не так страшны, как называются, и что вы могли бы придумать их сами (а может, уже и придумали в той или иной мере).
Для них есть стандартные прикладные случаи применения, но не злоупотребляйте ими.
Если вы хотите узнать больше по этой теме, я рассказывал о монадных трансформерах на конференции Scala Italy в прошлом году: https://vimeo.com/170461662
Счастливого (функционального) программирования!
Комментарии (8)
iks_unn
22.04.2017 19:07Добрый день! Возник вопрос по коду.
def findUserById(id: Long): OptionT[Future, User] = OptionT { ??? }
Мой родной язык C++. Код выше — это объявление функции, заглушка или что-либо ещё? Вопрос возник потому, что по существу проблемы — поиск польователя по индексу — в коде ни сказано ни символа. Судя по полноте листинга, это финальная, рабочая версия кода, и три знака вопроса должны неким «магическим» образом решать поставленную задачу — но что-то логика подсказывает, что любую задачу тремя знаками вороса не решить!
Благодарю!prokofyev
22.04.2017 19:13???
— это просто заглушка. Она определена в scala как
def ??? : Nothing = throw new NotImplementedError
Статья, конечно же, не о поиске в базе данных по индексу. :)
msts2017
в скале не рублю но вопрос имею, а так нельзя было написать?
и за оверлоадить, если умеет.
kolpeex
Отдельно взятая, эта сигнатура попахивает. А если посмотреть шире примера из статьи, то это функция с таким параметром еще и хуже компонуется в случае когда юзер точно есть.
Таким образом можно вообще сделать
msts2017
т.е. можно
kolpeex
Конечно :) Но не стоит
mayorovp
Можно, но это будет говнокод.
msts2017
говнокод это костыль в виде OptionT