はじめに
Scalaでの開発でcatsを使っているおり、使えそうなので和訳しました。(ただしところどころ日本語的に訳すのが難しい場合は、訳さなくても理解するのに影響がなければ無視し、それ以外は意訳や、直訳した上で括弧内でコメントを残しました)
この記事のもとは9 tips about using cats in Scala you might want to knowです。
本編
Scalaの関数型プログラミング(以下、FP)は言語の様々な構文と意味の特性故に、難しいのかもしれない。特に、一度は聞いたことのあるFPの中心的なライブラリの中には、いくつかの特徴的で「何かをするのに適切な方法」が存在する。それは一目瞭然だが、ガイダンスがないと始めたてのときには見つけられない。
そのため、私はこの投稿の中でScalaのFPにおけるいくつかの慣習のtipsを共有することは役立つと考えている。例や特定の名前に関してはcats用のものであるが、共通の理論的背景を持つscalazの構文でも似通っている。
9) Extention method constructors
おそらく最も基本的な特徴から始めていこう。それは任意の型に対するOption
、Either
などに変換する拡張メソッドである。すなわち:
-
Option
に対するコンストラクタメソッドの.some
と対応するnone
-
Either
に対する.asRight
と.asLeft
-
Validated
に対する.valid
、.invalid
、.validNel
、.invalidNel
これらを使う利点を以下の2点:
- 間違いなくよりコンパクトで理解しやすい(なぜならメソッドチェーンの並びと同一だから)
- これらのメソッドの返す型はそのsupertypeである
import cats.implicits._
Some("a")
// Some[String]
"a".some
// Option[String]
数年のうちに型のインターフェースは改善され、プログラマの不安を避けるような振る舞い(たぶん静的型付けの面倒な部分のことだと思います)をする場面は減ってきた一方で、モダンなScalaでもover-specializeing typing(これに関してはよくわからなかったです)が原因で、コンパイルエラーが未だに起きることがある。一般に"headdeskers"のようなものはEither
で起きる(Chapter 4.4.2 of Scala with Catsのドキュメントを参照)
もう一つ覚えておくことは、.asRight
と.asLeft
は1つの型パラメータをもつということである。例えば""1".asRight[Int]
はEither[Int, String]
である。パラメータを与えない場合は、コンパイラは推測しようとし、Nothing
にする。そのため、これは常に何も与えないか、両方与えることをするよりも便利である。
8) "*> Tales, woo-oo!"
*>
はApply
(つまりApplicative
、Monad
などにも)上で定義されていて、単純に「もとの演算を処理し、結果を2つ目に与えられた引数に置き換える」ということを意味する。
fa.flatMap(_ => fb)
なぜ一見効果のない演算子をわざわざ使うのか?それは一度ApplicativeError
やMonadError
を使えば、この演算子は全体の流れに対するエラーの効果を維持する、ということが分かるだろう。例としてEither
を使う:
import cats.implicites._
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)
ご覧のように、エラーが発生したら、計算は短くなる。*>
を使うことはMonix
タスクやIO
のような遅延の計算を扱う際に、頻繁に役に立つショートカットになる。
対称的な演算子である<*
もある。つまり前のセットアップのように:
success1 <* success2
// Right(a)
最後に、もし演算子を使いたくなければ、そうする必要はない:
-
*>
は単純にproductR
のエイリアス -
<*
はproductL
のエイリアス
7) Thoust thou even lift?
lift
は直感的に理解するのに時間がかかる概念のうちの1つだが、一度理解すれば、至るところで見ようになるだろう。
多くのFPについての用語のように、lift
はカテゴリー理論からの概念である。説明するのに最も良い方法は次の通り:
ある操作を与えると、その操作の型のシグニチャをより抽象的な型F
と直接「関連付け」られるように変更する。(抽象的な物言いで微妙に何言ってるかわかりにくいですが、コード見るとなんとなく言ってることは把握できると思います)
catsでは基本的な例はFunctor
にある。
def lift[A, B](f: A => B): F[A] => F[B] = map(_)(f)
与えられた関数を変更し、与えられたファンクターの型F
上で動作するようにすることを意味する。
リフト関数は、基本的にEitherT.right
になるEitherT.liftF
といったような、特定の型の「埋め込みコンストラクタ」と同義語であることがある。
Scaladocの例に基づくと:
import cats.data.EitherT
import cats.implicits._
EitherT.liftF("a".some)
// EitherT(Some(Right(a)))
EiterT.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"演算子構文の置き換えである。
主題に戻って、2つの要素のタプルに対する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)
基本的に、Semigroupal
(product
)やFunctor
(map
)といった任意のF
のタプルに対して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 classをインスタンス化する場合に役立つ:
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
多くのFP-in-Scalaプログラミングはエラーのハンドリングを中心にローテーションをしている。ApplicativeError
とMonadError
に役に立つ便利なメソッドがあり、おそらく4つの最も基本的はメソッドの間にある微妙な違いを知ることは重要である。すなわち、あるApplicativeError
であるF[A]
を考えたとき:
-
handleError
は呼び出しポイントでのすべてのエラーを与えられた関数に従ってA
に変換する -
recover
は似ているが、部分関数を取ることができるので、選択されたエラーもまたA
に変換できる -
handleErrorWith
はhandleError
と同じだが、結果がF[A]
になるはずなので、エラーを再マッピングすることができる -
recoverWith
はrecover
と同様だが、結果としてF[A]
も要求される
見てきたように、handleErrorWith
かrecoverWith
は残りの方を十分に表現できる(handleErrorWith
はhandleError
を表現でき、recoverWith
はrecover
を表現できるということ)。それでも、そのすべては便宜上便利である。
一般的に、私はApplicativeError
のAPIをレビューすることを強く勧める。なぜならそれはcatsのなかで最も充実したものであり、MonadError
と共有されているからだ。だからcats.effect.IO
、monix.Task
などからサポートされている。
最後に、もう一つEither
/EitherT
とValidated
とIor
-.valueOr
に与えられているメソッドがある。それは基本的にはOption
に対する.getOrElse
と同じだが、left
側に「何かしら」を持っているクラスに対して一般化されている(Either
型の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は次の2つのことを処理するための実用的な解決法である:
- 100%法則に従うわけではない型の型クラスインスタンス
- オーソドックスだがおそらく役立つであろう補助的な型クラス
歴史的に、最も顕著なプロジェクトのメンバーはTry
のモナドインスタンスである。なぜなら、よく知るように、Try
は致命的なエラーに関してはモナドの法則を完全に満たしているわけではないからだ。それは今catsに適切に含まれている。
しかし、それでもモジュールを見て、何か役立つものがあるかを探すことを勧める。
2) Be disciplined with imports
ドキュメントか特定の書籍などからおそらく学んでいるだろうが、cats
は特定のimportの階層を使用している:
-
cats.x
は、コア/「カーネル」タイプに対して -
cats.data
は、Validated
のようなデータ型、モナド変換などに対して -
cats.syntax.x._
は、拡張メソッドのサポートをimportし、sth.asRight
、sht.pure
などを呼び出すことができる -
cats.instances.x._
は、特定の型に対する様々な型クラスの実装のimplicitのスコープをimportし、例えばsth.pure
を"implicit not found"
エラーを起こさず、呼び出せる
そしてもちろん、単にすべての構文とすべての型クラスのインスタンスをimplicitのスコープに含めるだけのcats.implicits._
のimportには気づいている。
実際、catsのFAQから普段catsを使った開発の際に、cats正確なimport群と共に始めるべきであるとあり、すなわち:
import cats._
import cats.data._
import cats.implicits._
一度ライブラリに詳しくなれば、組み合わせたりするだろう。注意したいのは、経験則として:
-
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する必要がある:
import cats.syntax.applicative._
import cats.instances.option._
"a".pure[Option]
// Some(a)
手動でimportを最適化する理由としては、自分ののScalaファイル内のimplicitのスコープを制限し、コンパイルにかかる時間をへらすことができることにある。
しかし、以下の両方に当てはまらない場合はこれをしないでほしい:
- 自分がcatsに精通している、そして
- チーム全体もその水準でライブラリに精通している
なぜか?それは:
//we don't remember where `pure` is and
//we're trying to be smart
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
を拡張しているためである。基本的にそのような2つのimplicitのスコープをimportしてしまい、コンパイラが混乱する。
そのような場合の裏側において、implicitの階層に魔法は存在しない。それは型の拡張群によって十分に左右される。学ぶのに必要なことは、cats.implicits
の定義に行って型のヒエラルキーを見てみることである。
それらの問題を回避するために十分な水準の理解を得るには10~20分しかかからない。適切な時間の投資である。
自信がなくなるまでimport cats.implicits._
を使わない。
1) Keep your cats up to date on updates!
自分のFPライブラリを石でセットされたもの(微妙にわからないですね)思うだろうが、実際、cats
とscalaz
は積極的に開発されている。再び例としてcatsを挙げると、比較的最近に修正や改善がなされている:
raiseError
を使用する際にThrowable
を例外として課す必要はないDuration
とFiniteDuration
インスタンスがあり、外部のライブラリなしにd1 > d2
が使える- 同様に多くの変更や微調整がある
そのため、ライブラリの何のバージョンを自分がプロジェクトで使用しているかをチェックし、リリースノートについて常に把握し、それに応じて更新してほしい。
終わりに
初めての訳でまあまあ雑な部分もあると自覚していますが、サンプルコードを見つつ書いてあることのニュアンスが伝わって貰えれば幸いです。また公式ドキュメントと合わせて目を通しておくと記憶しやすいと思います。
あと、誤字脱字があったら申し訳ありません。