Функциональное программирование в Scala может быть нелегко освоить из-за некоторых синтаксических и семантических особенностей языка. В частности, некоторые средства языка и способы реализации задуманного с помощью основных библиотек кажутся очевидными, когда ты с ними знаком — но в самом начале изучения, особенно самостоятельного, узнать их не так просто.
По этой причине я решил, что будет полезно поделиться некоторыми советами по функциональному программированию в Scala. Примеры и наименования соответствуют cats, но синтаксис в scalaz должен быть аналогичным из-за общей теоретической базы.
Начнем с, пожалуй, самого основного средства — методов расширения любого типа, превращающих экземпляр в Option, Either и т.д., в частности:
Два главных преимущества их использования:
Хотя выведение типов с годами улучшилось, а количество возможных ситуаций, в которых данное поведение помогает программисту сохранять спокойствие, сократилось, ошибки компиляции из-за чрезмерно специализированной типизации всё еще возможны в Scala и сегодня. Довольно часто желание побиться головой об стол возникает при работе с
Еще кое-что по теме: у
Оператор *>, определяемый в любом методе
Зачем применять малопонятный символьный оператор для операции, не имеющей заметного эффекта? Начав использовать ApplicativeError и/или MonadError, вы обнаружите, что операция сохраняет эффект ошибки для всего рабочего процесса. Возьмем в качестве примера
Как видите, даже в случае ошибки вычисление остается короткозамкнутым. *> поможет вам в работе с отложенными вычислениями в task-ах
Существует и симметричная операция, <*. Так, в случае предыдущего примера:
Наконец, если использование символов вам чуждо, необязательно к нему прибегать. *> — это просто псевдоним
В личной беседе Adam Warski (спасибо, Адам!) справедливо заметил, что помимо *>(
Исходя из этого, я начал использовать *> чаще. Так или иначе, не забывайте о перечисленных выше факторах.
Многим требуется время, чтобы уложить в голове концепцию
Как и многие витающие в эфире функционального программирования термины,
В Cats простейшим примером служит Functor:
Это означает: изменить данную функцию так, чтобы она действовала на заданном типе функтора F.
Функция lift зачастую синонимична «вложенным конструкторам» для заданного типа. Так,
Вишенка на торте:
Теперь можно перейти к более насущным вопросам.
Вот как выглядит mapN в случае кортежа из двух элементов:
В сущности, она позволяет нам мапить значения внутри кортежа из любых F, которые являются полугруппой (product) и функтором (map). Итак:
Кстати, не забывайте о том, что с cats вы получаете map и
Еще одна полезная функция
Конечно, вы скорее используете для этого оператор цикла
Методы имеют схожий результат, но последний обходится без монадных трансформеров.
Помимо
Функциональное программирование в Scala во многом связано с обработкой эффекта ошибки. В
Как видите, можно ограничиться
В общем и целом, я советую ознакомиться с API ApplicativeError, являющимся одним из самых богатых в Cats и унаследованным в MonadError — а значит, поддерживаемым в
Существует еще один метод для
alley-cats — удобное решение для двух случаев:
Исторически наибольшей популярностью в этом проекте пользуется экземпляр монады для
Несмотря на это, я рекомендую ознакомиться с данным модулем, он может показаться вам полезным.
Должно быть, вы знаете — из документации, книги или еще откуда-то — что cats использует определенную иерархию импортирования:
cats.syntax.x._ для поддержки расширяющих методов, чтобы можно было вызывать sth.asRight, sth.pure и др.;
Конечно, вы заметили импорт
В принципе, при разработке с помощью Cats вам стоит начинать с определенной последовательности импортов из FAQ, а именно:
Познакомившись с библиотекой поближе, вы cможете комбинировать на свой вкус. Следуйте простому правилу:
Например, если вам нужен
С другой стороны, для получения
Благодаря ручной оптимизации вашего импорта вы ограничите implicit scopes в своих файлах Scala и сократите тем самым время компиляции.
Однако, прошу: не делайте этого при несоблюдении следующих условий:
Почему? Потому что:
Такое происходит потому, что и
При этом в иерархии имплиситов нет никакой магии — это четкая последовательность расширений типов. Вам нужно всего лишь обратиться к определению
За каких-то 10-20 минут вы сможете изучить её достаточно, чтобы избежать проблем вроде этих — поверьте, эта инвестиция точно окупится.
Возможно, вы считаете, что ваша FP-библиотека неподвластна времени, но на самом деле
Поэтому при работе с проектами не забывайте проверять версию библиотеки, читайте примечания к новым версиям и вовремя обновляйтесь.
По этой причине я решил, что будет полезно поделиться некоторыми советами по функциональному программированию в Scala. Примеры и наименования соответствуют cats, но синтаксис в scalaz должен быть аналогичным из-за общей теоретической базы.
9) Конструкторы методов расширения
Начнем с, пожалуй, самого основного средства — методов расширения любого типа, превращающих экземпляр в Option, Either и т.д., в частности:
.some
и соответствующий метод-конструкторnone
дляOption
;.asRight
,.asLeft
дляEither
;.valid
,.invalid
,.validNel
,.invalidNel
дляValidated
Два главных преимущества их использования:
- Так компактнее и понятнее (поскольку сохраняется последовательность вызовов метода).
- В отличие от вариантов конструктора, возвращаемые типы этих методов расширены до супертипа, т.е.:
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 sie masz!"
}
val liftedIntMatcher: Int => Option[String] = intMatcher.lift
liftedIntMatcher(1)
//Some(jak sie masz!)
liftedIntMatcher(0)
//None
intMatcher(1)
//jak sie 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)
("poltorak".some, 0.5.some, 3d.some).mapN(Mead)
//Some(Mead(poltorak,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. Вот лишь последние изменения:- теперь вам не нужно приписывать исключение Throwable при использовании raiseError;
- теперь есть инстансы для Duration и FiniteDuration, а значит, вы можете пользоваться d1 > d2 без использования внешних библиотек;
- а также куча других мелких и крупных нововведений.
Поэтому при работе с проектами не забывайте проверять версию библиотеки, читайте примечания к новым версиям и вовремя обновляйтесь.
Комментарии (10)
primetalk
16.04.2019 21:23+1То же самое на Haskell:
lift :: (A -> B) -> F A -> F B lift f = flip map f
не то, чтобы стало сильно легче...
Видимая сложность функции
lift
связана с её универсальностью. При использовании, вообще говоря, всё довольно удобно и не выглядит сложным:
val inc: Int => Int = _ + 1 val oinc: Option[Int] => Option[Int] = Option.lift(inc) val Option(11) = oinc(10.some)
xfaetas
И эти люди ругали COBOL...
vba
Это Scala, тут по другому никак, хотите элегантности вам к F#/Haskell/ML, а если типы особо не нужны то Clojure вам подойдет.
danslapman
И правильно ругали.
xfaetas
COBOL — верх читаемости и понятности по сравнению с этим.
primetalk
COBOL не работает на таком уровне абстракций.
lift
— мощный универсальный инструмент, который затруднительно даже представить в слабо-абстрактных языках.ayrro
Он настолько универсален, что даже сложно представить, зачем он вообще нужен.
primetalk
Есть хорошие книги и курсы, с помощью которых можно освоить абстракции, используемые в функциональном программировании. Например, FP in Scala (либо аналогичная книжка на Haskell'e — haskellbook.com). На coursera есть специализация "Functional Programming in Scala".
Ну а собственно
lift
из обычной функции делает функцию, работающую с какой-то структурой. Например,а универсальность позволяет писать код, который не зависит от конкретной структуры:
такой алгоритм будет работать с любыми структурами данных (для которых есть
Functor
) и любыми числовыми типами (для которых естьNumeric
).faoriu
В рамках функциональной парадигмы, возможно, полезно, но сама функциональная парадигма не очень понятна в принципе. COBOL (заменой которому позиционируют Java и Scala) хорош тем, что позволяет писать понятную прямолинейную бизнес-логику — пускай даже ценой большей многословности, а эти вот премудрости — сильно на любителя.
primetalk
Разные цели/задачи — разные языки.
Бизнес-логику вполне можно писать на Scala с помощью подходящего DSL. В том числе, и не столь прямолинейную.
"Премудрости" дают инструменты для хороших библиотек и DSL. На прикладном уровне хорошие библиотеки использовать достаточно легко, и можно в большинстве случаев обходиться поверхностными знаниями, не вникая в детали.
Для некоторых задач отсутствие адекватных по сложности инструментов приводит к тому, что программы получаются не очень хорошими (многословными, с повторами похожего кода, с необходимостью заново тестировать похожую функциональность).