9 tips for using the Cats library in Scala
- Transfer
Functional programming in Scala can be difficult to master due to some syntactic and semantic features of the language. In particular, some of the language tools and ways to implement what you have planned with the help of the main libraries seem obvious when you are familiar with them - but at the very beginning of studying, especially on your own, it is not so easy to recognize them.
For this reason, I decided it would be useful to share some functional programming tips in Scala. Examples and names correspond to cats, but the syntax in scalaz should be similar due to the general theoretical basis.
Let's start with, perhaps, the most basic tool - extension methods of any type that turn an instance into Option, Either, etc., in particular:
Two main advantages of their use:
Although type inference has improved over the years, and the number of possible situations in which this behavior helps the programmer to stay calm has decreased, compilation errors due to overly specialized typing are still possible in Scala today. Quite often, the desire to bang your head against a table arises when working with
Something else on the topic: y
The * * operator defined in any method
Why use an obscure symbolic operator for an operation that does not have a noticeable effect? Starting to use ApplicativeError and / or MonadError, you will find that the operation retains the error effect for the entire workflow. Take as an example
As you can see, even in the event of an error, the calculation remains short-circuited. *> it will help you in working with deferred calculations in tasks
There is a symmetric operation, <*. So, in the case of the previous example:
Finally, if the use of symbols is alien to you, it is not necessary to resort to it. *> Is just an alias
In a personal conversation, Adam Warski (thanks, Adam!) Rightly remarked that in addition to *> (
Based on this, I started using *> more often. One way or another, do not forget about the factors listed above.
Many take time to put the concept into their heads
Like many terms soaring in the air of functional programming, it
In Cats, the simplest example is Functor :
This means: change this function so that it acts on a given type of functor F.
The lift function is often synonymous with "nested constructors" for a given type. So,
Cherry on the cake:
Now we can move on to more pressing issues.
Here's what mapN looks like in the case of a tuple of two elements:
In essence, it allows us to map values inside a tuple from any F that are a semigroup (product) and a functor (map). So:
By the way, do not forget that with cats you get a map
Another useful feature
Of course, you would rather use a loop operator for this
Methods have similar results, but the latter dispenses with monadic transformers.
In addition
Functional programming in Scala has a lot to do with handling the error effect. In
As you can see, we can limit ourselves
In general, I advise you to familiarize yourself with the ApplicativeError API , which is one of the richest in Cats and inherited in MonadError - which means it is supported in
There is another method for
alley-cats is a convenient solution for two cases:
Historically, the monad instance is the most popular in this project
Despite this, I recommend that you familiarize yourself with this module , it may seem useful to you.
You must know - from the documentation, books, or from somewhere else - that cats use a specific import hierarchy:
cats.syntax.x._ to support extension methods so that you can call sth.asRight, sth.pure, etc .;
Of course, you notice an import
In principle, when developing with Cats, you should start with a certain sequence of imports from the FAQ, namely:
If you get to know the library better, you can combine it to your taste. Follow a simple rule:
For example, if you need
On the other hand, to get
By manually optimizing your import, you will limit implicit scopes in your Scala files and thereby reduce compilation time.
However, please: do not do this if the following conditions are not met:
Why? Because:
This is because both
Moreover, there is no magic in the hierarchy of implicits - this is a clear sequence of type extensions. You just need to refer to the definition
For some 10-20 minutes you can study it enough to avoid problems like these - believe me, this investment will definitely pay off.
You may think that your FP-library of timeless, but really
Therefore, when working with projects, do not forget to check the library version, read the notes for new versions and update in time.
For this reason, I decided it would be useful to share some functional programming tips in Scala. Examples and names correspond to cats, but the syntax in scalaz should be similar due to the general theoretical basis.
9) Extension method constructors
Let's start with, perhaps, the most basic tool - extension methods of any type that turn an instance into Option, Either, etc., in particular:
.some
and the corresponding constructor methodnone
forOption
;.asRight
,.asLeft
forEither
;.valid
,.invalid
,.validNel
,.invalidNel
ForValidated
Two main advantages of their use:
- It is more compact and understandable (since the sequence of method calls is saved).
- Unlike constructor options, the return types of these methods are extended to a supertype, i.e.:
import cats.implicits._
Some("a")
//Some[String]
"a".some
//Option[String]
Although type inference has improved over the years, and the number of possible situations in which this behavior helps the programmer to stay calm has decreased, compilation errors due to overly specialized typing are still possible in Scala today. Quite often, the desire to bang your head against a table arises when working with
Either
(see chapter 4.4.2 of the book Scala with Cats ). Something else on the topic: y
.asRight
and .asLeft
still one type parameter. For example, "1".asRight[Int]
this Either[Int, String]
. If this parameter is not provided, the compiler will try to output it and receive it Nothing
. Nevertheless, it is more convenient than providing both parameters each time or not providing either, as in the case of constructors.8) Fifty shades *>
The * * operator defined in any method
Apply
(that is, in Applicative
, Monad
etc.) simply means “process the initial calculation and replace the result with what is specified in the second argument”. In the language of the code (in case Monad
):fa.flatMap(_ => fb)
Why use an obscure symbolic operator for an operation that does not have a noticeable effect? Starting to use ApplicativeError and / or MonadError, you will find that the operation retains the error effect for the entire workflow. Take as an example
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)
As you can see, even in the event of an error, the calculation remains short-circuited. *> it will help you in working with deferred calculations in tasks
Monix
, IO
and the like. There is a symmetric operation, <*. So, in the case of the previous example:
success1 <* success2
//Right(a)
Finally, if the use of symbols is alien to you, it is not necessary to resort to it. *> Is just an alias
productR
, and * <is an alias productL
.Note
In a personal conversation, Adam Warski (thanks, Adam!) Rightly remarked that in addition to *> (
productR
), there is also >> from FlatMapSyntax
. >> is defined in the same way as fa.flatMap(_ => fb)
, but with two nuances:- it is determined independently of
productR
, and therefore, if for some reason the contract of this method changes (theoretically, it can be changed without violating monadic laws, but I'm not sure aboutMonadError
), you will not suffer; - more importantly, >> has a second operand called by call-by-name, i.e.
fb: => F[B]
. The difference in semantics becomes fundamental if you perform calculations that can lead to a stack explosion.
Based on this, I started using *> more often. One way or another, do not forget about the factors listed above.
7) Raise the sails!
Many take time to put the concept into their heads
lift
. But when you succeed, you will find that he is everywhere. Like many terms soaring in the air of functional programming, it
lift
came from category theory . I’ll try to explain: take an operation, change the signature of its type so that it becomes directly related to the abstract type F. In Cats, the simplest example is Functor :
def lift[A, B](f: A => B): F[A] => F[B] = map(_)(f)
This means: change this function so that it acts on a given type of functor F.
The lift function is often synonymous with "nested constructors" for a given type. So,
EitherT.liftF
essentially an EitherT.right.
example from Scaladoc is :import cats.data.EitherT
import cats.implicits._
EitherT.liftF("a".some)
//EitherT(Some(Right(a)))
EitherT.liftF(none[String])
//EitherT(None)
Cherry on the cake:
lift
present in the Scala standard library everywhere. The most popular (and perhaps the most useful in everyday work) example is 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
Now we can move on to more pressing issues.
6) mapN
mapN
- A useful helper function for working with tuples. Again, this is not a novelty, but a replacement for the good old operator |@|
, aka “Scream”. Here's what mapN looks like in the case of a tuple of two elements:
// 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)
In essence, it allows us to map values inside a tuple from any F that are a semigroup (product) and a functor (map). So:
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)
By the way, do not forget that with cats you get a map
leftmap
for tuples too:("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)
Another useful feature
.mapN
is instantiating case classes: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))
Of course, you would rather use a loop operator for this
for
, but mapN avoids monadic transformers in simple cases.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)
Methods have similar results, but the latter dispenses with monadic transformers.
5) Nested
Nested
- in fact, a generalizing double of monad transformers. As the name suggests, it allows you to perform attachment operations under certain conditions. Here is an example for.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))
In addition
Functor
, Nested
generalizes operations Applicative
, ApplicativeError
and Traverse
. Additional information and examples are here .4) .recover / .recoverWith / .handleError / .handleErrorWith / .valueOr
Functional programming in Scala has a lot to do with handling the error effect. In
ApplicativeError
and MonadError
there are several useful techniques, and you can be useful to know the subtle differences between the main four. So, withApplicativeError F[A]:
handleError
converts all errors at the dial peer to A according to the specified function.recover
It acts in a similar way, but accepts partial functions, and therefore can convert errors selected by you into A.handleErrorWith
similar tohandleError
, but its result should look likeF[A]
, which means it helps you convert errors.recoverWith
acts as recover, but also requiresF[A]
as a result.
As you can see, we can limit ourselves
handleErrorWith
and recoverWith
that cover all possible functions. However, each method has its advantages and is convenient in its own way. In general, I advise you to familiarize yourself with the ApplicativeError API , which is one of the richest in Cats and inherited in MonadError - which means it is supported in
cats.effect.IO
, monix.Task
etc. There is another method for
Either/EitherT
, Validated
and Ior
- .valueOr
. In fact, it works as .getOrElse
for Option
, but is generalized for classes containing something “on the left”.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 is a convenient solution for two cases:
- instances of tile classes that do not follow their laws 100%;
- Unusual auxiliary typklassy, which can be used properly.
Historically, the monad instance is the most popular in this project
Try
, because Try
, as you know, it does not satisfy all monadic laws in terms of fatal errors. Now he is truly introduced to Cats. Despite this, I recommend that you familiarize yourself with this module , it may seem useful to you.
2) Responsibly treat imports
You must know - from the documentation, books, or from somewhere else - that cats use a specific import hierarchy:
cats.x
for basic (kernel) types; cats.data
for data types like Validated, monad transformers, etc .; cats.syntax.x._ to support extension methods so that you can call sth.asRight, sth.pure, etc .;
cats.instances.x.
_ to directly import the implementation of various typclasses into the implicit scope for individual specific types, so that when calling, for example, sth.pure, the "implicit not found" error does not occur. Of course, you notice an import
cats.implicits._
where all the syntax and all instances of the type class are imported in implicit scope.In principle, when developing with Cats, you should start with a certain sequence of imports from the FAQ, namely:
import cats._
import cats.data._
import cats.implicits._
If you get to know the library better, you can combine it to your taste. Follow a simple rule:
cats.syntax.x
provides extension syntax related to x;cats.instances.x
provides instance classes.
For example, if you need
.asRight
which is an extension method for Either
, do the following:import cats.syntax.either._
"a".asRight[Int]
//Right[Int, String](a)
On the other hand, to get
Option.pure
you must import cats.syntax.monad
ANDcats.instances.option
:import cats.syntax.applicative._
import cats.instances.option._
"a".pure[Option]
//Some(a)
By manually optimizing your import, you will limit implicit scopes in your Scala files and thereby reduce compilation time.
However, please: do not do this if the following conditions are not met:
- you have already mastered Cats well
- your team owns the library at the same level
Why? Because:
//мы не помним, где находится `pure`,
//и стараемся быть умными
import cats.implicits._
import cats.instances.option._
"a".pure[Option]
//could not find implicit value for parameter F: cats.Applicative[Option]
This is because both
cats.implicits
, and cats.instances.option
are extensions cats.instances.OptionInstances
. In fact, we import its implicit scope twice, than we confuse the compiler. Moreover, there is no magic in the hierarchy of implicits - this is a clear sequence of type extensions. You just need to refer to the definition
cats.implicits
and examine the type hierarchy. For some 10-20 minutes you can study it enough to avoid problems like these - believe me, this investment will definitely pay off.
1) Do not forget about cats updates!
You may think that your FP-library of timeless, but really
cats
and scalaz
actively updated. Take cats as an example. Here are just the latest changes:- now you do not need to attribute a Throwable exception when using raiseError ;
- Now there are instances for Duration and FiniteDuration , which means you can use d1> d2 without using external libraries;
- as well as a bunch of other small and large innovations .
Therefore, when working with projects, do not forget to check the library version, read the notes for new versions and update in time.