はじめに
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が使える- 同様に多くの変更や微調整がある
そのため、ライブラリの何のバージョンを自分がプロジェクトで使用しているかをチェックし、リリースノートについて常に把握し、それに応じて更新してほしい。
終わりに
初めての訳でまあまあ雑な部分もあると自覚していますが、サンプルコードを見つつ書いてあることのニュアンスが伝わって貰えれば幸いです。また公式ドキュメントと合わせて目を通しておくと記憶しやすいと思います。
あと、誤字脱字があったら申し訳ありません。