Scala
Functional Programming
Райффайзенбанк corporate blog
April 15

9 советов по использованию библиотеки Cats в Scala

Original author: Mikołaj Koziarkiewicz
Translation
Функциональное программирование в Scala может быть нелегко освоить из-за некоторых синтаксических и семантических особенностей языка. В частности, некоторые средства языка и способы реализации задуманного с помощью основных библиотек кажутся очевидными, когда ты с ними знаком — но в самом начале изучения, особенно самостоятельного, узнать их не так просто.

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



9) Конструкторы методов расширения


Начнем с, пожалуй, самого основного средства — методов расширения любого типа, превращающих экземпляр в Option, Either и т.д., в частности:

  • .some и соответствующий метод-конструктор none для Option;
  • .asRight, .asLeft для Either;
  • .valid, .invalid, .validNel, .invalidNel для Validated

Два главных преимущества их использования:

  1. Так компактнее и понятнее (поскольку сохраняется последовательность вызовов метода).
  2. В отличие от вариантов конструктора, возвращаемые типы этих методов расширены до супертипа, т.е.:

import cats.implicits._

Some("a")
//Some[String]

"a".some
//Option[String] 

Хотя выведение типов с годами улучшилось, а количество возможных ситуаций, в которых данное поведение помогает программисту сохранять спокойствие, сократилось, ошибки компиляции из-за чрезмерно специализированной типизации всё еще возможны в Scala и сегодня. Довольно часто желание побиться головой об стол возникает при работе с Either (см. главу 4.4.2 книги Scala with Cats).

Еще кое-что по теме: у .asRightи .asLeft всё еще один параметр типа. Например, "1".asRight[Int] это Either[Int, String]. Если не предоставить этот параметр, компилятор попытается его вывести и получит Nothing. И всё-таки это удобнее, чем каждый раз предоставлять оба параметра или не предоставлять ни один, как в случае конструкторов.

8) Пятьдесят оттенков *>


Оператор *>, определяемый в любом методе Apply (то есть в Applicative, Monad и т.д.), означает просто «обработать изначальное вычисление и заменить результат тем, что указано во втором аргументе». Говоря языком кода (в случае Monad):

fa.flatMap(_ => fb)

Зачем применять малопонятный символьный оператор для операции, не имеющей заметного эффекта? Начав использовать ApplicativeError и/или MonadError, вы обнаружите, что операция сохраняет эффект ошибки для всего рабочего процесса. Возьмем в качестве примера Either:

import cats.implicits._

val success1 = "a".asRight[Int]
val success2 = "b".asRight[Int]

val failure = 400.asLeft[String]

success1 *> success2
//Right(b)

success2 *> success1
//Right(a)

success1 *> failure
//Left(400)

failure *> success1
//Left(400)

Как видите, даже в случае ошибки вычисление остается короткозамкнутым. *> поможет вам в работе с отложенными вычислениями в task-ах Monix, IO и им подобных.

Существует и симметричная операция, <*. Так, в случае предыдущего примера:


success1 <* success2
//Right(a)

Наконец, если использование символов вам чуждо, необязательно к нему прибегать. *> — это просто псевдоним productR, а *< — псевдоним productL.

Примечание


В личной беседе Adam Warski (спасибо, Адам!) справедливо заметил, что помимо *>(productR) существует и >> от FlatMapSyntax. >> определяется таким же образом, как fa.flatMap(_ => fb), но с двумя нюансами:

  • он определяется независимо от productR, а потому, если по какой-либо причине меняется контракт этого метода (теоретически, он может быть изменен без нарушения монадических законов, но я не уверен насчет MonadError), вы не пострадаете;
  • что более важно, у >> имеется второй операнд, вызываемый call-by-name, т.е. fb: => F[B]. Отличие в семантике становится принципиальным, если вы проводите вычисления, которые могут привести к взрыву стека.

Исходя из этого, я начал использовать *> чаще. Так или иначе, не забывайте о перечисленных выше факторах.

7) Поднять паруса!


Многим требуется время, чтобы уложить в голове концепцию lift. Но когда вам это удастся, вы обнаружите, что он повсюду.

Как и многие витающие в эфире функционального программирования термины, lift пришел из теории категорий. Попробую объяснить: возьмите операцию, измените сигнатуру её типа так, чтобы она стала непосредственно относиться к абстрактному типу F.

В Cats простейшим примером служит Functor:

def lift[A, B](f: A => B): F[A] => F[B] = map(_)(f)

Это означает: изменить данную функцию так, чтобы она действовала на заданном типе функтора F.

Функция lift зачастую синонимична «вложенным конструкторам» для заданного типа. Так, EitherT.liftF по сути является EitherT.right. Пример из Scaladoc:

import cats.data.EitherT
import cats.implicits._

EitherT.liftF("a".some)
//EitherT(Some(Right(a)))

EitherT.liftF(none[String])
//EitherT(None)

Вишенка на торте: lift присутствует в стандартной библиотеке Scala повсюду. Самый популярный (и, пожалуй, самый полезный в повседневной работе) пример — PartialFunction:

val intMatcher: PartialFunction[Int, String] = {
    case 1 => "jak się masz!"
}

val liftedIntMatcher: Int => Option[String] = intMatcher.lift

liftedIntMatcher(1)
//Some(jak się masz!)
liftedIntMatcher(0)
//None

intMatcher(1)
//jak się masz!
intMatcher(0)
//Exception in thread "main" scala.MatchError: 0

Теперь можно перейти к более насущным вопросам.

6) mapN


mapN — полезная вспомогательная функция для работы с кортежами. Опять-таки, это не новинка, а замена старому доброму оператору |@|, он же ”Scream”.

Вот как выглядит mapN в случае кортежа из двух элементов:

// where t2: Tuple2[F[A0], F[A1]]
def mapN[Z](f: (A0, A1) => Z)(implicit functor: Functor[F], 
                              semigroupal: Semigroupal[F]): F[Z] = 
                                            Semigroupal.map2(t2._1, t2._2)(f)

В сущности, она позволяет нам мапить значения внутри кортежа из любых F, которые являются полугруппой (product) и функтором (map). Итак:

import cats.implicits._

("a".some, "b".some).mapN(_ ++ _)
//Some(ab)

(List(1, 2), List(3, 4), List(0, 2).mapN(_ * _ * _))
//List(0, 6, 0, 8, 0, 12, 0, 16)

Кстати, не забывайте о том, что с cats вы получаете map и leftmap для кортежей:

("a".some, List("b","c").mapN(_ ++ _))
//won't compile, because outer type is not the same

("a".some, List("b", "c")).leftMap(_.toList).mapN(_ ++ _)
//List(ab, ac)

Еще одна полезная функция .mapN — инстанцирование case-классов:

case class Mead(name: String, honeyRatio: Double, agingYears: Double)

("półtorak".some, 0.5.some, 3d.some).mapN(Mead)
//Some(Mead(półtorak,0.5,3.0))

Конечно, вы скорее используете для этого оператор цикла for, но mapN позволяет избежать монадных трансформеров в простых случаях.

import cats.effect.IO
import cats.implicits._

//interchangable with e.g. Monix's Task
type Query[T] = IO[Option[T]]

def defineMead(qName: Query[String],
               qHoneyRatio: Query[Double],
               qAgingYears: Query[Double]): Query[Mead] =
  (for {
    name       <- OptionT(qName)
    honeyRatio <- OptionT(qHoneyRatio)
    agingYears <- OptionT(qAgingYears)
  } yield Mead(name, honeyRatio, agingYears)).value

def defineMead2(qName: Query[String],
                qHoneyRatio: Query[Double],
                qAgingYears: Query[Double]): Query[Mead] =
  for {
    name       <- qName
    honeyRatio <- qHoneyRatio
    agingYears <- qAgingYears
  } yield (name, honeyRatio, agingYears).mapN(Mead)

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

5) Nested


Nested — по сути, обобщающий двойник монадных трансформеров. Как можно догадаться по названию, он позволяет вам при некоторых условиях выполнять операции вложения. Вот пример для .map(_.map( :

import cats.implicits._
import cats.data.Nested

val someValue: Option[Either[Int, String]] = "a".asRight.some

Nested(someValue).map(_ * 3).value
//Some(Right(aaa))

Помимо Functor, Nested обобщает операции Applicative, ApplicativeError и Traverse. Дополнительная информация и примеры — здесь.

4) .recover/.recoverWith/.handleError/.handleErrorWith/.valueOr


Функциональное программирование в Scala во многом связано с обработкой эффекта ошибки. В ApplicativeError и MonadError есть несколько полезных методов, и вам может быть полезно узнать тонкие различия между основными четырьмя. Итак, при ApplicativeError F[A]:

  • handleError конвертирует все ошибки в точке вызова в A согласно заданной функции.
  • recover действует похожим образом, но принимает и частичные функции, а потому может конвертировать в A ошибки, выбранные вами.
  • handleErrorWith похож на handleError, но его результат должен выглядеть как F[A], а значит, он помогает вам преобразовывать ошибки.
  • recoverWith действует как recover, но также требует F[A] в качестве результата.

Как видите, можно ограничиться handleErrorWith и recoverWith, которые покрывают все возможные функции. Однако каждый метод имеет свои плюсы и удобен по-своему.

В общем и целом, я советую ознакомиться с API ApplicativeError, являющимся одним из самых богатых в Cats и унаследованным в MonadError — а значит, поддерживаемым в cats.effect.IO, monix.Task и т.д.

Существует еще один метод для Either/EitherT, Validated и Ior.valueOr. По сути, он работает как .getOrElse для Option, но обобщен для классов, содержащих что-нибудь «слева».

import cats.implicits._

val failure = 400.asLeft[String]

failure.valueOr(code => s"Got error code $code")
//"Got error code 400"

3) alley-cats


alley-cats — удобное решение для двух случаев:

  • экземпляры тайпклассов, не следующие своим законам на 100 %;
  • необычные вспомогательные тайпклассы, которые можно использовать с толком.

Исторически наибольшей популярностью в этом проекте пользуется экземпляр монады для Try, ведь Try, как известно, не удовлетворяет всем монадическим законам в плане fatal errors. Теперь он по-настоящему представлен в Cats.

Несмотря на это, я рекомендую ознакомиться с данным модулем, он может показаться вам полезным.

2) Ответственно относитесь к импорту


Должно быть, вы знаете — из документации, книги или еще откуда-то — что cats использует определенную иерархию импортирования:

cats.x для базовых (kernel) типов;
cats.data для типов данных вроде Validated, монадных трансформеров и т.д.;
cats.syntax.x._ для поддержки расширяющих методов, чтобы можно было вызывать sth.asRight, sth.pure и др.;
cats.instances.x._ для непосредственного импорта реализации различных тайпклассов в implicit scope для отдельных конкрентых типов, чтобы при вызове, например, sth.pure, не возникала ошибка «implicit not found».

Конечно, вы заметили импорт cats.implicits._, при котором импортируется весь синтаксис и все экземпляры класса типа в implicit scope.

В принципе, при разработке с помощью Cats вам стоит начинать с определенной последовательности импортов из FAQ, а именно:


import cats._
import cats.data._
import cats.implicits._

Познакомившись с библиотекой поближе, вы cможете комбинировать на свой вкус. Следуйте простому правилу:

  • cats.syntax.x предоставляет синтаксис расширения, относящийся к x;
  • cats.instances.x предоставляет инстансы тайпклассов.

Например, если вам нужен .asRight, который является расширяющим методом для Either, выполните следующее:

import cats.syntax.either._

"a".asRight[Int]
//Right[Int, String](a)

С другой стороны, для получения Option.pure вы должны импортировать cats.syntax.monad И cats.instances.option:

import cats.syntax.applicative._
import cats.instances.option._

"a".pure[Option]
//Some(a)

Благодаря ручной оптимизации вашего импорта вы ограничите implicit scopes в своих файлах Scala и сократите тем самым время компиляции.

Однако, прошу: не делайте этого при несоблюдении следующих условий:

  • вы уже неплохо овладели Cats
  • ваша команда владеет библиотекой на том же уровне

Почему? Потому что:

//мы не помним, где находится `pure`,
//и стараемся быть умными
import cats.implicits._
import cats.instances.option._

"a".pure[Option]
//could not find implicit value for parameter F: cats.Applicative[Option]

Такое происходит потому, что и cats.implicits, и cats.instances.option — расширения cats.instances.OptionInstances. По сути, мы импортируем его implicit scope дважды, чем запутываем компилятор.

При этом в иерархии имплиситов нет никакой магии — это четкая последовательность расширений типов. Вам нужно всего лишь обратиться к определению cats.implicits и изучить иерархию типов.

За каких-то 10-20 минут вы сможете изучить её достаточно, чтобы избежать проблем вроде этих — поверьте, эта инвестиция точно окупится.

1) Не забывайте про обновления cats!


Возможно, вы считаете, что ваша FP-библиотека неподвластна времени, но на самом деле cats и scalaz активно обновляются. Возьмем в качестве примера cats. Вот лишь последние изменения:


Поэтому при работе с проектами не забывайте проверять версию библиотеки, читайте примечания к новым версиям и вовремя обновляйтесь.
+28
3.4k 37
Comments 11
Top of the day